1+1=10

扬长避短 vs 取长补短

JavaScript学习笔记(四)

接前面的:

继续学习JavaScript。

JavaScript 的模块系统是一种用于组织和重用代码的机制,它允许将代码分割成独立的文件或模块。JavaScript 原生的模块系统,是 ES6 引入的。

ES6模块(ESM)

模块的概念不复杂,但是和想象的不一样:竟然只能在module文件中使用import

模块使用尝试1

Node.js环境下,先写了一个模块文件:

// mylib.mjs
export const repeat = (str) => `${str} ${str} ${str}`;
export const shout = (str) => `${str.toUpperCase()}!`;

然后写个使用它的脚本:

// myapp.js
import {repeat, shout} from './mylib.mjs';

console.log(repeat('hello 1+1=10')); // hello 1+1=10 hello 1+1=10 hello 1+1=10
console.log(shout('hello 1+1=2')); // HELLO 1+1=2!

使用使用node.js运行一下,直接报错:

> node myapp.js
(node:29488) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
C:\Users\dbzha\Desktop\js-test\myapp.js:1
import {repeat, shout} from './mylib.mjs';
^^^^^^

SyntaxError: Cannot use import statement outside a module

不过错误信息很直观,照做即可:

  • 方法一:把后缀从 .js改成 .mjs
> node mymain.mjs
hello 1+1=10 hello 1+1=10 hello 1+1=10
HELLO 1+1=2!
  • 方法二:创建一个package.json文件,指定类型:
{
  "type": "module"
}

而后

> node myapp.js
hello 1+1=10 hello 1+1=10 hello 1+1=10
HELLO 1+1=2!
  • 方法三:使用 import()函数改写myapp.js文件
import("./mylib.mjs").then((mylib) => {
    console.log(mylib.repeat('hello 1+1=10')); // hello 1+1=10 hello 1+1=10 hello 1+1=10
    console.log(mylib.shout('hello 1+1=2')); // HELLO 1+1=2!
});
  • 方法四:使用node的命令行参数
> node --experimental-default-type=module .\myapp.js
hello 1+1=10 hello 1+1=10 hello 1+1=10
HELLO 1+1=2!

模块使用尝试2

在firefox下,写一个html文件,调用前面的 mylib.mjs文件:

<!DOCTYPE html>  
<html>  
<body>  
  <p id="demo"></p>  
  <script type="module">
    import { repeat } from './mylib.mjs';
    const result = repeat("Hello 1+1=10");
    document.getElementById('demo').textContent = `${result}`;
  </script>
</body>
</html>

页面显示内容:

Hello 1+1=10 Hello 1+1=10 Hello 1+1=10

注:使用import必须指定script的类型为module,不然报错

Uncaught SyntaxError: import declarations may only appear at top level of a module

模块使用尝试3

回到Qt下,看看QJSEngine。一个完整例子如下:

#include <QCoreApplication>
#include <QJSEngine>
#include <QFile>

const char *lib_js = R"js(
export const repeat = (str) => `${str} ${str} ${str}`;
export const shout = (str) => `${str.toUpperCase()}!`;
)js";

const char *app_js = R"js(
import {repeat, shout} from './mylib.mjs';

console.log(repeat('hello 1+1=10')); // hello 1+1=10 hello 1+1=10 hello 1+1=10
console.log(shout('hello 1+1=2')); // HELLO 1+1=2!
)js";

int main(int argc, char *argv[])
{
    QCoreApplication app(argc, argv);

    {
        QFile myApp("myapp.mjs");
        if (myApp.open(QIODevice::WriteOnly))
            myApp.write(app_js);

        QFile myLib("mylib.mjs");
        if (myLib.open(QIODevice::WriteOnly))
            myLib.write(lib_js);
    }

    QJSEngine engine;
    engine.installExtensions(QJSEngine::ConsoleExtension);

    auto ret = engine.importModule("myapp.mjs");
    // auto ret = engine.evaluate(app_js, "myapp.mjs");
    if (ret.isError())
        qDebug()<<ret.toString();

    return 0;
}

结果

js: hello 1+1=10 hello 1+1=10 hello 1+1=10
js: HELLO 1+1=2!

同样注意,只能使用importModule(),而不能用evaluate(),不然报错如下:

"SyntaxError: Unexpected token `import'"

注意,QJSEngine 完全不支持import()写法,也就没办法像Node.js中的方法三那样使用。

文件后缀 .js .mjs

在WEB中,文件后缀用什么都所谓的,只要MIME类型是text/javascript即可。对浏览器来说,判断是否module是通过type="module"来进行的。尽管如此,V8文档建议模块的后缀选择.mjs

  • 开发过程中,后缀.mjs对项目中任何人都更清晰:这是一个模块而不是一个传统的脚本。二者处理方式不同,区分他们很重要。
  • 可以确保Node.js等运行时以及Babel等工具将文件解析为模块。

需要注意,web服务器需要保证发送.mjs文件时设置正确的头: Content-Type: text/javascript

传统脚本 与 模块

尽管单靠后缀不能区分 传统脚本和模块。我们还是要看看传统脚本 与 模块的区别:

  • 模块默认启用strict mode。(传统脚本需要使用'use strict';来启用)
  • 模块不支持HTML风格的注释<!-- Comments -->
  • 模块具有词法顶层作用域。var foo='1+1=10';不会创建名为foo的全局变量。模块中的this也不指向全局this,而是undefined
  • importexport只能在模块中使用,在传统脚本中无效。
  • 模块中可以使用顶级的await。传统脚本不行。

比如,传统脚本中,只能:

(async function() {
    await Promise.resolve(console.log('1+1=10'));
  }());

在模块中,可以:

await Promise.resolve(console.log('1+1=10'));
  • 在浏览器中,传统脚本可以多次执行,模块只执行一次
<script src="classic.js"></script>
<script src="classic.js"></script>
<!-- classic.js executes multiple times. -->

<script type="module" src="module.mjs"></script>
<script type="module" src="module.mjs"></script>
<script type="module">import './module.mjs';</script>
<!-- module.mjs executes only once. -->

export

在模块中,函数、var、let、const 和 类 都可以被导出。但它们必须是顶级项,不能在函数内部使用 export

// mylib.mjs
export const myVal = '1+1=10';
export function hello() {
    console.log('hello 1+1=10');
}

export class MyClass {
    constructor(name) {
        this.name = name;
    }
    hello() {
        console.log(`Hello ${this.name}`);
    }
}

或者在最后一行统一导出

const myVal = '1+1=10';
function hello() {
    console.log('hello 1+1=10');
}

class MyClass {
    constructor(name) {
        this.name = name;
    }
    hello() {
        console.log(`Hello ${this.name}`);
    }
}

export { myVal, hello, MyClass };

导出的时候,还可以顺便改下名字:

export { myVal as myValue, hello as myHello, MyClass as myClass};

使用它:

// myapp.mjs
import {myHello, myClass} from './mylib.mjs';

myHello(); // hello 1+1=10
new myClass('1+1=10').hello(); // Hello 1+1=10

export default

关于这个东西,mdn解释说:

this is designed to make it easy to have a default function provided by a module, and also helps JavaScript modules to interoperate with existing CommonJS and AMD module systems

看个例子:

// mylib.mjs
const myVal = '1+1=10';
export default function hello() {
    console.log('hello 1+1=10');
}

// myapp.mjs
import myHello from './mylib.mjs';
myHello(); // hello 1+1=10

特点:import时,不需要使用大括号{},而且名字随便取

等同于

// myapp.mjs
import {default as myHello} from './mylib.mjs';
myHello(); // hello 1+1=10

import

它有import声明和类似函数的import()两种用法

import声明

import声明(不要和import函数)的语法如下:

import defaultExport from "module-name";
import * as name from "module-name";
import { export1 } from "module-name";
import { export1 as alias1 } from "module-name";
import { default as alias } from "module-name";
import { export1, export2 } from "module-name";
import { export1, export2 as alias2, /* … */ } from "module-name";
import { "string name" as alias } from "module-name";
import defaultExport, { export1, /* … */ } from "module-name";
import defaultExport, * as name from "module-name";
import "module-name";

模块名module-name和 JavaScript 运行环境相关:

  • 一般都是指向.js文件的 相对或绝对路径。
  • Node.js下支持不带后缀的名字,指向node_modules中的包。
  • 浏览器下支持一个importmap东西,通过JSON格式建立模块指示符(module specifier)和真实路径的关系。这样import时使用模块指示符就行了。

四种形式:

  • Named import: import { export1, export2 } from "module-name";
  • Default import: import defaultExport from "module-name";
  • Namespace import: import * as name from "module-name";
  • Side effect import: import "module-name";

import()

好处是在普通js文件中可以使用

import * as mod from "/my-module.js";

import("/my-module.js").then((mod2) => {
  console.log(mod === mod2); // true
});

ES6之前

有了ESM之后,用CommonJS和AMD实现模块似乎意义不大了,不过思想还是很有用。

对比一下CommonJS和AMD:

  • 设计目标: CommonJS 主要为服务器端设计,而 AMD 主要为客户端设计。
  • 加载方式: CommonJS 是同步加载,而 AMD 是异步加载。
  • 导出和引入: CommonJS 使用 module.exportsrequire,而 AMD 使用 definerequire
  • 加载时机: CommonJS 在编译时加载模块,而 AMD 在运行时异步加载模块。

CommonJS

CommonJS是一种规范,用于统一JavaScript在浏览器之外的实现。它定义了一套通用的应用程序使用的API,以填补JavaScript标准库过于简单和不足。每个模块内部有两个变量可以使用,require和module。

一个js文件可看作是一个模块,比如myLib.js:

// myLib.js
exports.repeat = (str) => `${str} ${str} ${str}`;
exports.shout = (str) => `${str.toUpperCase()}!`;

要在myApp.js中使用它:

// myApp.js
const myLib = require('./myLib.js');

console.log(myLib.repeat('hello 1+1=10')); // hello 1+1=10 hello 1+1=10 hello 1+1=10
console.log(myLib.shout('hello 1+1=2')); // HELLO 1+1=2!

不同于ESM中export和import两个关键字,CommonJS中的exports和require分别是对象和函数。

其中:

var exports = module.exports;

给exports添加属性是安全的,但是直接赋值会有问题,会断开和module.exports链接,而module.exports是真正要导出的东西。

基本原理

  • 每个模块都是一个独立的文件: 在 Node.js 中,每个文件都被视为一个模块。每个模块都有自己的作用域,不会污染全局作用域。

  • module 对象的存在: 在每个模块的执行过程中,Node.js 在内部创建了一个名为 module 的对象。这个对象包含了模块的一些信息和接口。

  • exports 和 require: module 对象上有一个属性叫做 exports,最初指向一个空对象 {}。通过给 exports 赋值,可以将模块的接口导出。另外,require 函数用于引入其他模块,实际上是获取其他模块的 exports 导出的内容。

  • 包装模块代码: 在实际执行模块代码之前,Node.js 会在模块代码的周围加上一个函数包装,形成一个闭包,这个闭包的参数包括 exportsrequiremodule__filename__dirname 等变量。这个包装函数的大致形式如下:

(function (exports, require, module, __filename, __dirname) {
    // 模块的实际代码在这里
});

这个包装函数使得模块的代码在一个相对独立的作用域中执行。

AMD

AMD:Asynchronous Module Definition。AMD 规范是由 RequireJS 实现并推广的。

RequireJS 是一个用于浏览器端的 JavaScript 模块加载器,它遵循 AMD 规范,提供了在浏览器环境下实现模块化开发的工具,definerequire两个函数是它实现的。

  • 使用define定义AMD模块:
// mylib.js
define(function() {
    var repeat = function(str) {
        return `${str} ${str} ${str}`;
    };

    var shout = function(str) {
        return `${str.toUpperCase()}!`;
    };

    return {
        repeat: repeat,
        shout: shout
    };
});

这个define函数返回一个对象,这个对象的属性就是模块的公开API。

  • 在HTML,通过requried使用它:
<!DOCTYPE html>  
<html>  
<body>  
  <p id="demo"></p>  
  <script src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js"></script>
  <script>
    require(['./mylib'], function(mylib) {
      const result = mylib.repeat("Hello 1+1=10");
      document.getElementById('demo').textContent = `${result}`;
    });
  </script>
</body>
</html>

在这个例子中,我们首先加载了RequireJS库。然后,我们使用require函数来加载我们的模块。这个函数接受两个参数:一个是模块的路径数组,另一个是一个回调函数。当所有模块都加载完成时,回调函数会被调用,加载的模块会作为回调函数的参数。

define

define 函数是 AMD(异步模块定义)规范中用于定义模块的关键函数。它接受三个参数:模块的名称、依赖数组和模块的工厂函数。以下是 define 函数的基本结构:

define(moduleName, dependencies, factoryFunction);
  • moduleName(可选):指定模块的名称,通常是一个字符串。如果省略,一些 AMD 实现会尝试根据脚本文件的路径来猜测模块名称。
  • dependencies(可选):一个数组,包含当前模块所依赖的其他模块的名称。这些模块会在加载当前模块之前被异步加载。
  • factoryFunction:一个函数,用于定义模块的实现。该函数在模块加载时执行,并接收参数,这些参数是依赖模块的导出对象。函数的返回值将被视为当前模块的导出内容。

前面例子,可以写成:

define("mylib", [], function() {
    var repeat = function(str) {
        return `${str} ${str} ${str}`;
    };

    var shout = function(str) {
        return `${str.toUpperCase()}!`;
    };

    return {
        repeat: repeat,
        shout: shout
    };
});

require

require 函数是 AMD(异步模块定义)规范中用于加载模块的函数。它接受两个参数:模块的名称和一个回调函数。以下是 require 函数的基本结构:

require(moduleName, callback);
  • moduleName:指定要加载的模块的名称。
  • callback:一个回调函数,用于在模块加载完成后执行。这个回调函数通常包含对加载模块导出内容的操作。

Node.js确定模块类型

截至Node.js v21,它同时支持CommonJS和ESM两种模块。如何确定和区分模块类型?

  • https://nodejs.org/docs/latest-v21.x/api/packages.html#determining-module-system

手册中说了很多,只关心最简单的规则:

  • .mjs文件,识别为 EMS模块
  • .cjs文件,识别为 CommonJS模块
  • .js文件,如果最近的(上层)package.json文件中,如果type为module,则识别为EMS模块,如果为type为commonjs,则识别为CommonJS模块,如果没有type,由命令行--experimental-default-type(当前默认值是commonjs,以后可能改为module)确定。

参考

  • https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
  • https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
  • https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap
  • https://v8.dev/features/modules#mjs
  • https://en.wikipedia.org/wiki/CommonJS
  • https://wiki.commonjs.org/wiki/Modules/1.1
  • https://javascript.ruanyifeng.com/nodejs/module.html
  • https://github.com/amdjs/amdjs-api/blob/master/AMD.md

Comments