Module 的加载实现
浏览器加载
传统方法
<!-- 页面内嵌的脚本 -->
<script type="application/javascript">
// module code
</script>
<!-- 外部脚本 -->
<script type="application/javascript" src="path/to/myModule.js">
</script>
- 由于浏览器脚本的默认语言是 JavaScript,因此
type="application/javascript"
可以省略 - 默认情况下,浏览器是同步加载 JavaScript 脚本,即渲染引擎遇到
<script>
标签就会停下来,等到执行完脚本,再继续向下渲染。如果是外部脚本,还必须加入脚本下载的时间。 - 如果脚本体积很大,下载和执行的时间就会很长,因此成浏览器堵塞,用户会感觉到浏览器“卡死”了,没有任何响应。
异步加载
<script src="path/to/myModule.js" defer></script>
<script src="path/to/myModule.js" async></script>
-
<script>
标签打开defer或async属性,脚本就会异步加载。渲染引擎遇到这一行命令,就会开始下载外部脚本,但不会等它下载和执行,而是直接执行后面的命令。 -
defer
与async
的区别是:前者要等到整个页面正常渲染结束,才会执行;后者一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。一句话,defer是“渲染完再执行”,async是“下载完就执行”。另外,如果有多个defer脚本,会按照它们在页面出现的顺序加载,而多个async脚本是不能保证加载顺序的。
ES6模块加载规则
<script type="module" src="foo.js"></script>
-
浏览器加载 ES6 模块,也使用
<script>
标签,但是要加入type="module"
属性。 -
异步加载,即等到整个页面渲染完,再执行模块脚本,等同于默认打开了
注意事项
import utils from 'https://example.com/js/utils.js';
const x = 1;
console.log(x === window.x); //false
console.log(this === undefined); // true
delete x; // 句法错误,严格模式禁止删除变量
- 代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见
- 模块之中,顶层的
this
关键字返回undefined
,而不是指向window
。也就是说,在模块顶层使用this关键字,是无意义的
ES6 模块与 CommonJS 模块的差异
两大差异
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
- CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
第一个差异
-
CommonJS 模块输出的是值的拷贝,即一旦输出一个值,模块内部的变化就影响不到这个值。
// 模块文件——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(); //执行了函数counter也不会改变 //这是因为counter是一个原始类型的值,会被缓存。 console.log(mod.counter); // 3
//一种办法就是把counter写成函数 get counter() { return counter },
-
ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令
import
,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。// 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); // 4
由于 ES6 输入的模块变量,只是一个“符号连接”,所以这个变量是只读的,对它进行重新赋值会报错
// lib.js export let obj = {}; // main.js import { obj } from './lib'; obj.prop = 123; // //变量obj指向的地址是只读的,不能重新赋值 obj = {}; // TypeError
第二个差异
CommonJS 加载的是一个对象(即module.exports
属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成
Node 加载
Node 对 ES6 模块的处理比较麻烦,因为它有自己的 CommonJS 模块格式,与 ES6 模块格式是不兼容的。解决方案是:将两者分开,ES6 模块和 CommonJS 采用各自的加载方案。
在静态分析阶段,一个模块脚本只要有一行import
或export
语句,Node 就会认为该脚本为 ES6 模块,否则就为 CommonJS 模块。
如果不输出任何接口,但是希望被 Node 认为是 ES6 模块,可以写成如下代码。这不是输出一个空对象,而是不输出任何接口的 ES6 标准写法
export {};
如何不指定绝对路径,Node 加载 ES6 模块会依次寻找以下脚本,与require()
的规则一致
import 'baz';
// 依次寻找
// ./node_modules/baz.js
// ./node_modules/baz/package.json
// ./node_modules/baz/index.js
// 寻找上一级目录
// ../node_modules/baz.js
// ../node_modules/baz/package.json
// ../node_modules/baz/index.js
// 再上一级目录
import 命令加载 CommonJS 模块
Node 采用 CommonJS 模块格式,模块的输出都定义在module.exports
这个属性上面。
在 Node 环境中,Node 会自动将module.exports
属性,当作模块的默认输出,即等同于export default
,使用import
命令加载该 CommonJS 模块时,module.exports
会被视为默认输出
// CommonJS 模块
// a.js
module.exports = {
foo: 'hello',
bar: 'world'
};
// 在node环境中,自动当作模块的默认输出,等同于
export default {
foo: 'hello',
bar: 'world'
};
如果采用整体输入的写法(import * as xxx from someModule
),default
会取代module.exports
,作为输入的接口
// c.js
module.exports = function two() {
return 2;
};
// es.js
import foo from './c';
foo(); // 2
import * as bar from './c';
bar.default(); // 2
bar(); // throws, bar is not a function
//bar本身是一个对象,不能当作函数调用,只能通过bar.default调用
CommonJS 模块的输出缓存机制,在 ES6 加载方式下依然有效
// foo.js
module.exports = 123;
setTimeout(_ => module.exports = null); //一直都会是123
由于 ES6 模块是编译时确定输出接口,CommonJS 模块是运行时确定输出接口,所以采用import
命令加载 CommonJS 模块时,不允许采用下面的写法。
import {readfile} from 'fs';
//因为fs是 CommonJS 格式,只有在运行时才能确定readfile接口,而import命令要求编译时就确定这个接口
解决方法就是改为整体输入
import * as express from 'express';
const app = express.default();
import express from 'express';
const app = express();
require 命令加载 ES6 模块
采用require
命令加载 ES6 模块时,ES6 模块的所有输出接口,会成为输入对象的属性。
// es.js
let foo = {bar:'my-default'};
export default foo;
foo = null;
// cjs.js
const es_namespace = require('./es');
console.log(es_namespace.default);
// {bar:'my-default'}
//default接口变成了es_namespace.default属性。另外,由于存在缓存机制,es.js对foo的重新赋值没有在模块外部反映出来。
ES6模块的转码
浏览器目前还不支持ES6模块,为了现在就能使用,可以将转为ES5的写法。除了Babel可以用来转码之外,还有以下两个方法,也可以用来转码。
ES6 module transpiler
ES6 module transpiler是 square 公司开源的一个转码器,可以将 ES6 模块转为 CommonJS 模块或 AMD 模块的写法,从而在浏览器中使用
首先,安装这个转码器。
$ npm install -g es6-module-transpiler
然后,使用compile-modules convert
命令,将 ES6 模块文件转码。
$ compile-modules convert file1.js file2.js
-o
参数可以指定转码后的文件名
$ compile-modules convert -o out.js file1.js
SystemJS
另一种解决方法是使用 SystemJS。它是一个垫片库(polyfill),可以在浏览器内加载 ES6 模块、AMD 模块和 CommonJS 模块,将其转为 ES5 格式。它在后台调用的是 Google 的 Traceur 转码器。
使用时,先在网页内载入system.js
文件。
<script src="system.js"></script>
然后,使用System.import
方法加载模块文件。
<script>
System.import('./app.js');
</script>
上面代码中的./app
,指的是当前目录下的app.js文件。它可以是ES6模块文件,System.import
会自动将其转码。
需要注意的是,System.import
使用异步加载,返回一个 Promise 对象,可以针对这个对象编程。下面是一个模块文件。
// app/es6-file.js:
export class q {
constructor() {
this.es6 = 'hello';
}
}
然后,在网页内加载这个模块文件。
<script>
System.import('app/es6-file').then(function(m) {
console.log(new m.q().es6); // hello
});
</script>
ESLint的使用
ESLint是一个语法规则和代码风格的检查工具,可以用来保证写出语法正确、风格统一的代码。
首先,安装ESLint。
$ npm i -g eslint
然后,安装Airbnb语法规则。
$ npm i -g eslint-config-airbnb
最后,在项目的根目录下新建一个.eslintrc
文件,配置ESLint。
{
"extends": "eslint-config-airbnb"
}
现在就可以检查,当前项目的代码是否符合预设的规则。
index.js
文件的代码如下。
var unusued = 'I have no purpose!';
function greet() {
var message = 'Hello, World!';
alert(message);
}
greet();
使用ESLint检查这个文件。
$ eslint index.js
index.js
1:5 error unusued is defined but never used no-unused-vars
4:5 error Expected indentation of 2 characters but found 4 indent
5:5 error Expected indentation of 2 characters but found 4 indent
✖ 3 problems (3 errors, 0 warnings)
上面代码说明,原文件有三个错误,一个是定义了变量,却没有使用,另外两个是行首缩进为4个空格,而不是规定的2个空格。