JavaScript模块化
在开始本文之前,希望大家带着目的性去阅读本文,没有模块化,我们会碰到那些问题?
- 命名冲突
- 文件依赖
使用模块化又有什么好处呢?
- 可维护性
- 命名空间
- 可复用性
读完本文后,希望大家能有所收获
- JavaScript模块化的发展历程
- 一些现代标准的JavaScript模块化已经相关库的实现,包括但不局限于CommonJS、AMD、UMD、CMD、原生的ES2015的模块等
- 简单实现模块加载器
- 利用构建工具来实现模块化,包括webpack和Browserify
- 未来的模块化发展方向,例如Http2.0对模块化的影响,html和css的模块化
目录:
1. JavaScript模块化写法,基础实现
1.1 最广义的模块,函数即是模块
最开始为了代码的复用和封装,我们利用函数特性,把业务逻辑切分为几个不一样的函数。但是这样写有问题,全局的变量很容易被污染,从而引起命名冲突
function foo(){
//...
}
function bar(){
//...
}
1.2 简单封装,NameSpace模式
var M = {
foo: function(){},
bar: function(){}
}
M.foo();
M.bar();
看起来好一点了,但是这种方式M也是对象,还是存在冲突的可能性,只是说减少了全局变量的数目
1.3 匿名闭包,IIFE
var Module = (function(){
var _private = 'in private';
var foo = function() {
console.log(_private);
};
return {
foo: foo
};
})();
Module.foo();
Module._private;
这个函数是JavaScript唯一的局部作用域
1.4 依赖注入
我们有的时候需要再模块中用其他的库,例如jQuery
var Module = (function($){
var _private = $('body');
var foo = function() {
console.log(_private);
};
return {
foo: foo
};
})(jQuery);
Module.foo();
其实看到这里,我们之前的两个问题基本快解决的,例如文件冲突,我们保证Module唯一就行了,文件依赖的话我们把jQuery以参数的形式传入进去了。
2. JavaScript现代标准
第一章中我们主要解决的问题是模块化中封装的特性,但是并没有解决加载的问题
2.1 lab.js
在《高性能JavaScript》一书中提到了lab.js这个用来加载JavaScript文件的类库,lab.js是Loading And Blocking JavaScript的缩写,顾名思义,加载和阻塞JavaScript。
传统的加载方式和lab.js的加载方式的对比:
// 传统的JS加载方式
<script src="framework.js"></script>
<script src="plugin.framework.js"></script>
<script src="myplugin.framework.js"></script>
<script src="init.js"></script>
// lab.js的加载方式
<script>
$LAB
.script("framework.js").wait()
.script("plugin.framework.js")
.script("myplugin.framework.js").wait()
.script("init.js").wait();
</script>
因为plugin.framework.js依赖于framework.js,所以使用wait方法,这里如果两者不相互依赖,就不需要增加wait方法;wait方法还可以带参数,具体可以去查看api文档
2.2 YUI3 沙盒+依赖注入
// YUI - 编写模块
YUI.add('dom', function(Y) {
Y.DOM = { ... }
})
// YUI - 使用模块
YUI().use('dom', function(Y) {
Y.DOM.doSomeThing();
// use some methods DOM attach to Y
})
YUI是基于模块的依赖管理,如果去看YUI的源码会发现,其实YUI就是一个沙盒,所有的依赖模块都是通过attach的方式注入
2.3 CommonJS标准 node.js
Node.js的标准模块化规范就是CommonJS,实现方案的主要是包括require和module关键字
CommonJS对模块的定义包括模块引用、模块定义和模块表示3个部分。
- 模块引用,就是利用require方法,接受一个模块表示,来引入一个模块的API到上下问题
- 模块定义,在模块中,上下文提供了require方法来引入外部变量,对应引入功能,上下文提供了exports对象来到处当前模块的方法和变量,并且它是唯一的导出出口。在模块中还存在一个module对象,它代表模块本身,而exports是module的一个属性。在Node中,一个文件就是一个模块
- 模块标识,其实就是传递给require的参数,它必须是小驼峰命名的字符串,或者是相对路径和绝对路径
// math.js
exports.add = function(a, b){
return a + b;
}
// main.js
var math = require('math') // ./math in node
console.log(math.add(1, 2)); // 3
那么有个问题exports和module.exports有什么区别。
在Node中引入模块,需要经历3个步骤:
- 路径分析
- 文件定位
- 编译执行
2.4 AMD require.js
浏览器端的模块化解决方案,AMD规范是CommonJS规范的一个延伸,它的模块定义如下:
define(id?, dependencies?, factory)
其中id和dependencies是可选的,factory是实际代码的内容,我们知道在Node中一个文件是一个模块,而在AMD规范里面,必须使用define来定义一个模块,利用函数来实现作用域的隔离;此外还有一个区别是CommonJS是利用exports来输出变量,而AMD规范里面是利用return
其实AMD是RequireJS 对模块定义的规范化产出,require.js和定义三个变量require、define和requirejs
require 加载依赖模块(引用定义好的模块),并执行加载完后的回调函数,有三个参数:
- 第一个参数是dependencies,就算是只有一个,也必须用数组的形式
- 第二个参数是一个回调函数,用来处理加载完毕后的逻辑,当所有模块加载完成后触发
- 第三个参数也是一个回调函数,用来处理模块加载失败后的情况
require([dependencies], function, function);
接下来我们看一个实际的例子(官网的例子):
目录结构:
index.html的结构:
<!DOCTYPE html>
<html>
<head>
<title>My Sample Project</title>
<!-- data-main attribute tells require.js to load
js/app.js after require.js loads. -->
<script data-main="js/app.js" src="../node_modules/requirejs/require.js"></script>
</head>
<body>
<h1>My Sample Project</h1>
</body>
</html>
app.js
requirejs.config({
//By default load any module IDs from js/lib
baseUrl: 'js/lib',
//except, if the module ID starts with "app",
//load it from the js/app directory. paths
//config is relative to the baseUrl, and
//never includes a ".js" extension since
//the paths config could be for a directory.
paths: {
app: '../app'
}
});
// Start the main app logic.
requirejs(['jquery', 'app/sub'], function($, sub) {
//jQuery and the app/sub module are all
//loaded and can be used here now.
console.log(111);
});
sub.js
define(function () {
return {
name: 'sub'
}
});
2.5 CMD Sea.js
CMD (Common Module Definition), 是seajs推崇的规范,CMD则是依赖就近,用的时候再require。
从这个定义上就很容易发现AMD和CMD的区别,AMD对于依赖模块的处理原则是立即加载,而CMD是依赖就近的原则。
Sea.js表示一个文件就是一个模块
// util.js
define(function(require, exports) {
exports.each = function (arr) {
// 实现代码
};
exports.log = function (str) {
// 实现代码
};
});
// dialog.js
define(function(require, exports) {
var util = require('./util.js');
exports.init = function() {
// 实现代码
};
});
// index.html
<script src="sea.js"></script>
<script>
seajs.use('dialog', function(Dialog) {
Dialog.init(/* 传入配置 */);
});
</script>
但是这个东西,大家了解下就行了,Sea.js的作者都不推荐大家使用了,还是用ES6 Module的标准把。
我们先总结下AMD和CMD的区别:
- CMD是依赖就近的,AMD是依赖前置的
- 执行顺序上CMD是延迟执行的,而AMD是提前执行的
- API设计的角度:AMD 的 API 默认是一个当多个用,CMD 的 API 严格区分,推崇职责单一。比如 AMD 里,require 分全局 require 和局部 require,都叫 require。CMD 里,没有全局 require,而是根据模块系统的完备性,提供 seajs.use 来实现模块系统的加载启动。CMD 里,每个 API 都简单纯粹。
2.6 UMD
我们知道AMD和CMD都是浏览器端的模块化解决标准,对应的实现是Require.js和Sea.js,此外还有Node端的CommonJS标准,难道我们开源一个库,需要写三个标准么?UMD就是支持AMD和CommonJS的通用模块规范,从而提供一个前后端跨平台的解决方案。
UMD的实现方法:首先判断是否支持Node.js模块的格式(exports是否存在),存在就是Node.js模块,也就是CommonJS的格式,再判断是否是AMD的格式(define是否存在),存在就是AMD的加载模块。如果两者都不存在,就暴露在window或者global。
例如:
(function (root, factory) {
if (typeof exports === 'object') {
module.exports = factory();
} else if (typeof define === 'function' && define.amd) {
define(factory);
} else {
root.eventUtil = factory();
}
})(this, function() {
// module
return {
addEvent: function(el, type, handle) {
//...
},
removeEvent: function(el, type, handle) {
},
};
});
我们看一些开源库的实现,例如underscore
(function(){
var root = typeof self == 'object' && self.self === self && self ||
typeof global == 'object' && global.global === global && global ||
this ||
{};
var _ = function(obj) {
if (obj instanceof _) return obj;
if (!(this instanceof _)) return new _(obj);
this._wrapped = obj;
};
if (typeof exports != 'undefined' && !exports.nodeType) {
if (typeof module != 'undefined' && !module.nodeType && module.exports) {
exports = module.exports = _;
}
exports._ = _;
} else {
root._ = _;
}
// ..... 省略
if (typeof define == 'function' && define.amd) {
define('underscore', [], function() {
return _;
});
}
}())
再看一个最近很火的一个库day.js
!function(t, e) {
"object" == typeof exports && "undefined" != typeof module ? module.exports = e() : "function" == typeof define && define.amd ? define(e) : t.dayjs = e()
}(this, function(){
// ....
return xxxx;
});
因为ES6的模块标准的浏览器兼容性问题,我们如果自己想开源一个库,有两种办法让使用者使用的舒服,一是适用UMD的标准,二是还用ES6的写法,但是通过babel转换为ES5的方式,这种方法其实就是把ES6的module写法转成UMD的标准
2.7 ES6 Module
我们知道JavaScript是没有模块体系的,在ES6之前,比较活跃的就是上面介绍的CommonJS和AMD规范,但是这些规范都有一个缺点,就是需要在运行时才能确定依赖关系,而ES6模块的目的就是让这些尽量的静态化,最好在编译阶段就能确定模块之间的依赖关系以及输入和输出的变量。
ES6的模块自动采用了严格模式,严格模式要求大家应该都知道了,这里就不展开了。
在CommonJS中我们这么使用:
let {stat, exists, readFile} = require('fs');
// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;
这段代码代码的实质是,首先加载fs模块,包括所有的方法,然后在从这个对象中获取相应的方法。这种加载方式就是我们之前说过的运行时加载
ES6中是这样的:
import {stat, exists, readFile} from 'fs'
上面代码的实质是从fs模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载。孰轻孰重,那个更厉害,不需要我多讲了把。
除了静态编译之外,ES6模块还有以下好处:
- 不再需要UMD了,未来浏览器端和服务器端会统一,目前我们利用工具库就已经可以实现了。
- 将来可能会有新的API就能用模块格式提供
- 不再需要使用对象作为命名空间,例如Math对象。
我们看一下ES6 Module的语法:
2.8.1 export命令
模块的功能主要由两个关键字来组成,包括export和import。其中export用来规定模块对外接口,import命令用来输入其他模块的功能。
ES6 Module规定一个文件就是一个模块,如果想让其他的模块引用,可以使用export命令把变量暴露出去
export除了能输出变量外,还能输出函数或者类
// profile.js
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;
// profile2.js
var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;
export {firstName, lastName, year};
// test.js
export function multiply(x, y) {
return x * y;
};
利用as还可以对方法重新命名
function v1() { ... }
function v2() { ... }
export {
v1 as streamV1,
v2 as streamV2,
v2 as streamLatestVersion
};
需要注意的地方:
export 1;
var m = 1;
export m;
function f() {}
export f;
export命令规定了必须与模块内部的变量建立一一对应的关系,这两种写法都会报错,因为没有提供对外的接口,1只是一个值,而不是一个接口。
// 写法一
export var m = 1;
// 写法二
var m = 1;
export {m};
// 写法三
var n = 1;
export {n as m};
export function f() {};
function f() {}
export {f};
还有一点需要注意的是,export语句输出的接口,与其对应的值是动态绑定的关系,也就是说如果这个值模块内部改变了,那么在应用该模块的位置能够获取的修改之后的值
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);
最后还有一点需要注意,export必须在顶级作用域下
2.8.2 import命令
我们上一小节知道了export命令如何使用,import命令就是把export的变量引入到当前模块
// main.js
import {firstName, lastName, year} from './profile.js';
console.log(firstName);
import命令输入的变量都是只读的,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面,改写接口,如果尝试对输入变量赋值,会提示错误,当然如果输入变量的类型是对象,是可以给对象增加属性的,但是不推荐大家这么做。
import后面的from,表示的是文件的位置,可以是相对路径,也可以是绝对路径,此外import命令有变量提升的作用,会提升到整个模块的头部进行。因为import是静态执行,所以不能使用表达式和变量,例如下列的例子:
// 报错
import { 'f' + 'oo' } from 'my_module';
// 报错
let module = 'my_module';
import { foo } from module;
// 报错
if (x === 1) {
import { foo } from 'module1';
} else {
import { foo } from 'module2';
}
重复加载同一个模块,实际上只会执行一次,import语句是单例模式。
import { foo } from 'my_module';
import { bar } from 'my_module';
// 等同于
import { foo, bar } from 'my_module';`
如果有印象,我们PC版的代码里面有这样的例子:
import MacTitleBar from '../common/MacTitleBar';
import WinTitleBar from '../common/WinTitleBar';
const createReactClass = require('create-react-class');
const FluxibleMixin = require('fluxible-addons-react/FluxibleMixin');
上面两行用的是es6的import,下面两行用的是CommonJS的require方法,但是最好不要这么做,因为我们上面提到了import是会变量提升的,有时候效果可能不是预期的那样。
2.8.3 * 命令
如何加载整个模块
// circle.js
export function area(radius) {
return Math.PI * radius * radius;
}
export function circumference(radius) {
return 2 * Math.PI * radius;
}
// main.js
import * as circle from './circle';
2.8.4 export default命令
像上面的例子我们必须知道模块里有什么变量,才能输出,这对于用户来说是不友好的,对用户来说,最好有一个默认输出,用户直接用就行了。
// export-default.js
export default function () {
console.log('foo');
}
//main.js
import customName from './export-default';
customName();
这里和上面几节是有不同的地方的,from之前是没有大括号的,上面输出的函数是匿名函数,其实不是匿名函数也行,但是函数名只在当前模块有效。
比较一下默认输出和正常输出
// 第一组
export default function crc32() { // 输出
// ...
}
import crc32 from 'crc32'; // 输入
// 第二组
export function crc32() { // 输出
// ...
};
import {crc32} from 'crc32'; // 输入
区别在于第一组使用import的时候不需要大括号,第二组需要。这个原因是因为export default命令用于指定模块的输出,显然一个模块只有一个输出,所以import不需要加上大括号
export var a = 1;
var a = 1;
export default a;
export default var a = 1;
因为export default命令的本质是将后面的值,所以第三种写法是有问题的
2.8.5 import()命令
import()和import命令是有区别的,import命令上面介绍是用来做静态的分析的,这就导致我们不能够使用import命令来做动态加载,目前有一个提案来引入import(),从而实现动态加载
import()的返回值是一个Promise对象,类似于CommonJS中的require,区别是前者是异步加载模块,后者是同步加载模块。
import()的适用场景:
- 按需加载
- 条件加载
- 动态的模块路径
2.8.5 Module的加载实现
浏览器加载
浏览器加载ES6模块,还是使用script标签,但是需要加上type=module
属性,ES6的加载都是异步加载,除非自己设置了async属性<script type="module" src="./foo.js"></script>
在foo.js中有几个需要注意的地方:
- 代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。
- 模块脚本自动采用严格模式,不管有没有声明use strict。
- 模块之中,可以使用import命令加载其他模块(.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export命令输出对外接口。
- 模块之中,顶层的this关键字返回undefined,而不是指向window。也就是说,在模块顶层使用this关键字,是无意义的。
- 同一个模块如果加载多次,将只执行一次
2.ES6模块和CommonJS的差异
CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
所谓值的拷贝,就是一旦输出一个值,模块内部的变化影响不到外部。我们通过一例子来看这个差异
// lib.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
counter: counter,
incCounter: incCounter,
};
然后在main.js中引用这个模块
// main.js
var mod = require('./lib');
console.log(mod.counter);
mod.incCounter();
console.log(mod.counter);
可以看得到lib.js加载之后,它内部的变化是影响不到外部的。
我们再看一个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
CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成,而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
2.8.6 如何利用webpack来解析es6并且打包
因为目前的浏览器环境不支持ES6 Module的写法,因此要借助webpack和babel来生成可运行的代码
1.npm init
{
"name": "es6",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
2.安装相关的npm包
"dependencies": {
"babel-core": "^6.26.3",
"babel-loader": "^7.1.4",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-stage-0": "^6.24.1",
"babel-runtime": "^6.26.0",
"webpack": "^4.10.2"
}
3.新建index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>测试</title>
</head>
<body>
</body>
<script src="./out/bundle.js"></script>
</html>
4.新建entry.js
import Person from './module/person.js';
var p = new Person("hangli");
p.sayHi();
5.新建person.js
class People{
constructor(name){
this.name = name;
}
sayHi(){
console.log('Hi '+this.name)
}
}
export default People;
6.新建webpack.config.js
var path = require("path");
module.exports = {
entry:'./entry', //输入文件
output:{
path:path.join(__dirname,'out'), //输出文件路径
filename:'bundle.js',
publicPath:'./out/'
},
module:{
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
plugins: [
'transform-class-properties',
'transform-es2015-classes'
],
}
}
}
]
}
}
7.运行webpack,可以看到bundle.js,我们可以看到用了UMD的标准,然后把ES6的语法转换为ES5的语法
3.简单实现模块加载器
我们知道在Node中应用另外一个模块的方式是require,我们来看看如何实现require方法:
我们想一下,这个方式至少需要做两件事情
- 读取文件内容
- 将返回的字符串执行
前者很容易实现,后者一般我们想到了eval,但是这个有安全漏洞,我们可以使用Function构造器
const plus = new Function("name", "return name + ‘ plus'");
console.log(plus("dog")); //dog plus
//module.js
function require(name){
//调用一个模块,首先检查这个模块是否已经调用
if(name in require.cache){
return require.cache[name];
}
//此处生成一个code的函数,参数为 exports 和 module, 函数体为readFile返回的js文件中的代码字符串
var code = new Function("exports, module", readFile(name));
//定义外吐内容
var exports = {},
module = {exports: exports};
//执行函数体,如果有定义外吐,既module.exports 或者 exports.***之类的,会改写上面的外吐内容
code(exports, module);
require.cache[name] = module.exports;
//返回exports
return module.exports;
}
//缓存对象,为了应对重复调用
require.cache = Object.create(null);
//读文件,返回结果
function readFile(fileName){ ... }
4. 未来的模块化发展方向
ES2015是2015年的规范,但是到现在浏览器端也还没有完全实现,大家基本在构建工具的阴影下开发。
4.1 Http2.0对JavaScript模块化的推动
Http2.0有一个重要的改变,请求和响应可以并行,一次连接允许多个请求,意思就是我们可能不需要模块化构建工具了,例如经常用到的打包,合并
4.2 CSS模块化展望
CSS 模块化,目前不依赖预编译的方式是 styled-components,通过JS来动态的创建CSS。
CSS的进化基本是:
CSS=>SASS=>BEM=>CSS Module=>Styled-Components
大家有兴趣,可以去看看官网的介绍,这里接单介绍下Styled Components
import styled from 'styled-components';
const Title = styled.h1`
font-size: 1.5em;
color: purple;
`;
<Title>Hello World</Title>
4.3 HTML模块化展望
HTML 模块化,最近chrome小组开始调研 html Module的可行性
,其实React中的JSX就是类似的,但是未来我们可能会这样使用:
<script type="module" url="foo.html">
import * as foo from "foo.html";