Appearance
Module 的加载实现
浏览器加载
传统方法
浏览器通过<script>标签加载 JavaScript 脚本
html
<!-- 页面内嵌的脚本 -->
<script type="application/javascript">
// module code
</script>
<!-- 外部脚本 -->
<script type="application/javascript" src="path/to/myModule.js"></script>默认情况下,浏览器是同步加载 JavaScript 脚本,即渲染引擎遇到<script>标签就会停下来,等到执行完脚本,再继续向下渲染。如果是外部脚本,还必须加入脚本下载的时间。
问题:脚本体积过大,会导致浏览器堵塞
浏览器允许脚本异步加载,以下是两种异步加载语法
html
<script src="path/to/myModule.js" defer></script>
<script src="path/to/myModule.js" async></script>defer 与 async 的区别:defer 是“渲染完再执行”,async 是“下载完就执行”
defer:等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;
async:一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。
多个 defer 脚本,会按照它们在页面出现的顺序加载,而多个 async 脚本是不能保证加载顺序的
加载规则
浏览器加载 ES6 模块,需要加入 type="module"属性
html
<script type="module" src="./foo.js"></script><script type="module">为异步加载,不会造成堵塞浏览器
html
<script type="module" src="./foo.js"></script>
<!-- 等同于 -->
<script type="module" src="./foo.js" defer></script>多个 <script type="module"> ,会按照在页面出现的顺序依次执行。可以开启 async 属性,但是使用后,就不会按照页面出现顺序执行。
html
<script type="module" src="./foo.js" async></script>ES6 模块也允许内嵌在网页中,语法行为与加载外部脚本完全一致
js
<script type="module">import utils from "./utils.js"; // other code</script>对于外部的模块脚本,有几点需要注意点
- 代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。
- 模块脚本自动采用严格模式
- 使用 import 命令加载其他模块(.js 后缀不可省略,需要提供绝对 URL 或相对 URL),使用 export 命令输出对外接口
- 模块之中,顶层 this 关键字返回 undefined,而不是指向 window
- 同一个模块如果加载多次,将只执行一次
通过第 4 点,检测当前代码是否在 ES6 模块
js
const isNotModuleScript = this !== undefinedES6 Module 与 CommonJS 差异
ES6 模块与 CommonJS 模块三个重大差异
| CommonJS | ES6 Module | |
|---|---|---|
| 输出值 | 输出的是一个值的拷贝 | 输出的是值的引用 |
| 加载时机 | 运行时加载 | 编译时加载 |
| 加载类型 | require()是同步加载模块 | import 命令是异步加载 |
输出值差异分析
CommonJS,一旦输出一个值,模块内部的变化就影响不到这个值。
ES6 Module 在 JS 引擎对脚本静态分析时,加载一个命令 import,就会生成一个只读引用。代码执行时,通过引用取值。
js
// lib.js
var counter = 3
function incCounter() {
counter++
}
module.exports = {
counter: counter,
incCounter: incCounter
}
// main.js
var mod = require('./lib')
console.log(mod.counter) // 3
mod.incCounter()
console.log(mod.counter) // 3ES6 模块不会缓存运行结果,而是动态地去被加载的模块取值,并且变量总是绑定其所在的模块。变量是只读的,对它进行重新赋值会报错。
js
// lib.js
export let counter = 3
export function incCounter() {
counter++
}
// main.js
import { counter, incCounter } from './lib'
console.log(counter) // 3
incCounter()
console.log(counter) // 4js
// m1.js
export var foo = 'bar'
setTimeout(() => (foo = 'baz'), 500)
// m2.js
import { foo } from './m1.js'
console.log(foo)
setTimeout(() => console.log(foo), 500)export 输出的是同一个值。不同的脚本加载这个接口,得到的都是同样的实例。
js
// mod.js
function C() {
this.sum = 0
this.add = function () {
this.sum += 1
}
this.show = function () {
console.log(this.sum)
}
}
export let c = new C()js
// x.js
import { c } from './mod'
c.add()
// y.js
import { c } from './mod'
c.show()
// main.js
import './x'
import './y'加载时机差异分析
因为 CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成
Node.js 的模块加载方法
概述
JavaScript 现在有两种模块。一种是 ES6 模块,简称 ESM;另一种是 CommonJS 模块,简称 CJS。
CommonJS 模块使用 require()和 module.exports,ES6 模块使用 import 和 export
CommonJS 模块是 Node.js 专用的,与 ES6 模块不兼容。
从 Node.js v13.2 版本开始,Node.js 已经默认打开了 ES6 模块支持
Node.js 要求 ES6 模块采用.mjs 后缀文件名。Node.js 遇到.mjs 文件,标识为 es6 模块。
不希望将后缀名改成.mjs,可在 package.json 进行如下配置,设置后,项目 JS 脚本,都被解释成 ES6 模块
js
{
"type": "module"
}总结:.mjs以 ES6 模块加载,.cjs以 CommonJS 模块加载,.js文件的加载取决于package.json里面type字段的设置
package.json 的 main
package.json 中有两个字段指定模块入口文件:main 和 exports。
json
// ./node_modules/es-module-package/package.json
{
"type": "module",
"main": "./src/index.js"
}import 命令就可以加载这个模块
js
// ./my-app.mjs
import { something } from 'es-module-package'
// 实际加载的是 ./node_modules/es-module-package/src/index.js注: CommonJS 模块的 require()命令去加载 es-module-package 模块会报错,因为 CommonJS 模块不能处理 export 命令
package.json 的 exports
exports 优先级高于 main
- 子目录别名
package.json 文件通过 exports 指定脚本或子目录的别名
json
// ./node_modules/es-module-package/package.json
{
"exports": {
"./submodule": "./src/submodule.js"
}
}js
import submodule from 'es-module-package/submodule'
// 加载 ./node_modules/es-module-package/src/submodule.js未指定别名,就不能用“模块+脚本名”这种形式加载脚本
js
// 报错
import submodule from 'es-module-package/private-module.js'
// 不报错
import submodule from './node_modules/es-module-package/private-module.js'- main 的别名
别名是.,代表模块主入口。
json
{
"exports": {
".": "./main.js"
}
}
// 等同于
{
"exports": "./main.js"
}polyfill
json
{
"main": "./main-legacy.cjs",
"exports": {
".": "./main-modern.cjs"
}
}- 条件加载
利用.别名,可为 ES6 模块和 CommonJS 指定不同入口。但要在 Node.js 运行的时候,打开--experimental-conditional-exports 标志
json
{
"type": "module",
"exports": {
".": {
"require": "./main.cjs",
"default": "./main.js"
}
}
}简写
json
{
"exports": {
"require": "./main.cjs",
"default": "./main.js"
}
}同时有其他别名,就不能采用简写,否则会报错
json
{
// 报错
"exports": {
"./feature": "./lib/feature.js",
"require": "./main.cjs",
"default": "./main.js"
}
}CommonJS 模块加载 ES6 模块
问题:CommonJS 的 require()命令不能加载 ES6 模块,会报错。
原因(之一):require() 是同步加载, ES6 模块可以使用顶层 await,导致无法被同步加载
解决:使用 import()方法加载。
js
;(async () => {
await import('./my-app.mjs')
})()ES6 模块加载 CommonJS 模块
ES6 模块的 import 命令可以加载 CommonJS 模块,但是只能整体加载,不能只加载单一的输出项
js
// 正确
import packageMain from 'commonjs-package'
// 报错
import { method } from 'commonjs-package'ES6 模块需要支持静态代码分析,而 CommonJS 模块的输出接口是 module.exports,是一个对象,无法被静态分析,所以只能整体加载
变通加载方法:使用 Node.js 内置的 module.createRequire()方法
js
// cjs.cjs
module.exports = 'cjs'
// esm.mjs
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const cjs = require('./cjs.cjs')
cjs === 'cjs' // true支持两种格式模块
原始模块 ES6,需要给出整体输出接口
js
export default obj原始 CommonJS ,加一个包装层进行转发
js
import cjsModule from '../index.js'
export const foo = cjsModule.foo两种格式文件输出写法
方法一:文件的后缀名改为.mjs,或者将它放在一个子目录,再在这个子目录里面放一个单独的 package.json 文件,指明{ type: "module" }
方法二:利用 exports 指定不同 加载入口
js
"exports":{
"require": "./index.js",
"import": "./esm/wrapper.js"
}Node.js 内置模块
Node.js 的内置模块可以整体加载,也可以加载指定的输出项
js
// 整体加载
import EventEmitter from 'events'
const e = new EventEmitter()
// 加载指定的输出项
import { readFile } from 'fs'
readFile('./foo.txt', (err, source) => {
if (err) {
console.error(err)
} else {
console.log(source)
}
})加载路径
ES6 模块加载路径:必须给出脚本的完整路径,不能省略脚本的后缀名。import 命令和 package.json 文件的 main 字段如果省略脚本的后缀名,会报错
js
// ES6 模块中将报错
import { something } from './index'为了与浏览器的 import 加载规则相同,Node.js 的.mjs 文件支持 URL 路径
js
import './foo.mjs?query=1' // 加载 ./foo 传入参数 ?query=1同一个脚本只要参数不同,就会被按照 URL 规则进行解析。会被加载多次,并且保存成不同的缓存。文件名中含有: % # ?等特殊字符,最好对这些字符进行转义
Node.js 的 import 命令只支持加载本地模块(file:协议)和 data:协议,不支持加载远程模块。脚本路径只支持相对路径,不支持绝对路径(即以/或//开头的路径)。
内部变量
ES6 模块应该是通用,同一个模块不用修改,就可以用在浏览器环境和服务器环境。Node.js 规定 ES6 模块之中不能使用 CommonJS 模块的特有的一些内部变量
以下顶层变量在 ES6 中不存在
- arguments
- require
- module
- exports
- __filenam
- __dirname
循环加载
循环加载(circular dependency):a 脚本的执行依赖 b 脚本,而 b 脚本的执行又依赖 a 脚本
补充:CommonJS 模块的加载原理
require 第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。
- id:模块名
- exports:模块输出接口
- loaded:脚本是否执行完毕
js
{
id: '...',
exports: { ... },
loaded: true,
...
}CommonJS 无论加载多少次,都只会在第一次加载时运行一次。以后加载直接读取第一次结果,除非手动清除缓存。
CommonJS 模块的循环加载
CommonJS 模块的重要特性:加载时执行,require 时代码会全部执行。
js
// a.js
exports.done = false
var b = require('./b.js')
console.log('在 a.js 之中,b.done = %j', b.done)
exports.done = true
console.log('a.js 执行完毕')js
// b.js
exports.done = false
var a = require('./a.js')
console.log('在 b.js 之中,a.done = %j', a.done)
exports.done = true
console.log('b.js 执行完毕')代码分析:
a.js 执行到第二行,会去加载 b.js。
进入 b.js 执行到第二行,会去加载 a.js,发生循环加载。a.js 还未加载完毕,只能取回部分执行,所以 b.done 为 false。
b.js 继续执行,执行完后,回到 a.js 执行
js
// main.js
var a = require('./a.js')
var b = require('./b.js')
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done)
/*
在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true
*/由于 CommonJS 模块遇到循环加载时,返回的是当前已经执行的部分的值,而不是代码全部执行后的值,两者可能会有差异。
js
var a = require('a') // 安全的写法
var foo = require('a').foo // 危险的写法
exports.good = function (arg) {
return a.foo('good', arg) // 使用的是 a.foo 的最新值
}
exports.bad = function (arg) {
return foo('bad', arg) // 使用的是一个部分加载时的值
}分析:发生循环加载, require('a').foo 可能会被改写
ES6 模块的循环加载
ES6 模块是动态引用,使用 import 从一个模块加载变量,此变量是引用而不是缓存
js
// a.mjs
import { bar } from './b'
console.log('a.mjs')
console.log(bar)
export let foo = 'foo'
// b.mjs
import { foo } from './a'
console.log('b.mjs')
console.log(foo)
export let bar = 'bar'执行结果:ReferenceError: foo is not defined
分析:执行 a.mjs 后,发现依赖 b.mjs,回去执行 b.mjs。执行 b.mjs 时,发现依赖 a.mjs,此时默认 a.mjs 已经存在,不会执行 a.mjs,而是向下执行。
解决:写成函数形式
js
// a.mjs
import { bar } from './b'
console.log('a.mjs')
console.log(bar())
function foo() {
return 'foo'
}
export { foo }
// b.mjs
import { foo } from './a'
console.log('b.mjs')
console.log(foo())
function bar() {
return 'bar'
}
export { bar }分析:利用函数提升,import {bar} from './b'; 时,函数 foo 已经定义
ES6 模块加载器 SystemJS 给出的一个例子
js
// even.js
import { odd } from './odd'
export var counter = 0
export function even(n) {
counter++
return n === 0 || odd(n - 1)
}
// odd.js
import { even } from './even'
export function odd(n) {
return n !== 0 && even(n - 1)
}js
$ babel-node
> import * as m from './even.js';
> m.even(10);
true
> m.counter
6
> m.even(20)
true
> m.counter
17如果是 commonJS,将无法执行
js
// even.js
var odd = require('./odd')
var counter = 0
exports.counter = counter
exports.even = function (n) {
counter++
return n == 0 || odd(n - 1)
}
// odd.js
var even = require('./even').even
module.exports = function (n) {
return n != 0 && even(n - 1)
}js
$ node
> var m = require('./even');
> m.even(10)
TypeError: even is not a function分析:引入 event.js 后,第一行加载 odd.js。在 odd.js,运行 require('./even').even,会去获取 even.js 已执行部分,但没有结果,所以 even 是 undefined,向下执行,发现不是函数。