前端模块化
webpack是这样定义模块的:
在模块化编程中,开发者将程序分解成离散功能块(discrete chunks of functionality),并称之为模块。 每个模块具有比完整程序更小的接触面,使得校验、调试、测试轻而易举。 精心编写的模块提供了可靠的抽象和封装界限,使得应用程序中每个模块都具有条理清楚的设计和明确的目的。
模块应该是职责单一、相互独立、低耦合的、高度内聚且可替换的离散功能块。
首先我们要知道为什么需要模块化?
- 可维护性:根据代码职责,把代码拆分成一块一块积木,各司其职,更有利于维护
- 命名空间:每个拆分出来的模块都有一个独立的命名空间,可以避免所有代码都在一个全局环境中互相污染
- 重用代码:复用代码逻辑
前端模块化的进化史:
-
通过函数隔离
- 需要手动管理依赖顺序
- 容易命名冲突
- 维护成本高
function fn1(){ // ... } function fn2(){ // ... } -
用对象模拟命名空间
- 可以避免污染全局
- 内部属性还是不安全(外界可篡改)
var output = { _count: 0, fn1: function(){ // ... } } -
闭包
- 独立的作用域环境
- 内存中只会存在一份copy
- 避免了外界访问篡改
- 可以避免污染全局
这也是在ES6Module出现之前,通过webpack等打包工具实现前端模块化的基础原理
var module = (function(){ var _count = 0; var fn1 = function (){ // ... } var fn2 = function fn2(){ // ... } return { fn1: fn1, fn2: fn2 } })() module.fn1(); module._count; // undefined
大家口中的前端模块化是指?
历史上,JavaScript 一直没有模块体系,在ES6之前,社区制定了一些模块加载的方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。
我们现在提到的前端模块化,主要在聊CommonJS和ES6Module就够了,首先CommonJS是Node.js环境中默认就支持的,因此服务端主要就用CommonJS(所以我们聊的前端模块化是指前端JS开发人员接触到的模块化规范,而不仅仅指前端开发用的模块化);但是我们也可以用webpack、browserify实现在前端使用CommonJS,也可以直接使用ES6Module。
CommonJS
-
语法:使用
require()加载和module.exports输出 -
目标环境:浏览器环境之外的 javascript 生态系统
-
加载机制:运行时加载(什么时候用
require引入,什么时候执行),同步加载(只有加载完成,才能执行后面的操作) -
导入方式:可动态导入
let path = "./cmB"; // 修改path const xxx = require(path); // 可根据上下文得到的变量作为导入路径(导入的内容可是动态的,因为是运行时加载) -
输出:值的拷贝(一个Module对象)
// 在commonJs中,每个模式本质上就是一个Module对象 console.log(module) // { // id: ".", // path: "...", // exports: { }, // parent: null, // filename: "...", // loaded: false, // children: [], // paths: [ // ... // ], // } // 其中exports初始值就是空对象 {} let value = 1; exports.value = value; // 仔细想下,exports.value和该模块中的value变量还有关系吗?没有了,因此基本数据类型直接被拷贝了一份新的 给exports.value let obj = {name: 'obj'} exports.obj = obj; // 再仔细想下,exports.obj和该模块中的obj变量还有关系吗?有,因为还是同一个引用地址 console.log(module) // { // id: ".", // path: "...", // exports: { value: 1, obj: {name: 'obj' } }, // parent: null, // filename: "...", // loaded: false, // children: [], // paths: [ // ... // ], // }-
当这个模块首次被加载时,会整个执行一遍,并将需要到导出的所有东西 挂载到
module.exports这个空对象{}上,并将整个module对象缓存下来 -
之后每当该模块再次被导入时,直接**读取缓存中的
module.exports**来使用 -
上述过程中,
将需要到导出的东西 挂载到 exports 这个空对象{} 上,这个行为好比是把 所有需要导出的东西浅拷贝到了这个空对象上,所以有人说:导出的是**值的拷贝**(在我看来这种说法有点容易产生误解~),理解这种表述的原因就好
-
为什么CommonJS用于服务端
- 服务端加载一个模块,无需经过网络,直接读取硬盘或内存,消耗时间成本低
- CommonJS是同步的,需要阻塞后面代码的执行,从而会阻塞浏览器渲染页面,容易让页面出现假死状态。
ES6Module
-
语法:使用
import加载和export输出<script type="module"> import xxx from 'xxx' </script> -
目标环境:浏览器环境
-
加载机制:编译时加载(在静态编译时分析出所有依赖关系,在编译时,从最深的依赖先执行)
-
导入方式:静态导入
import xxx from `xxx.js`; // 导入必须是静态的路径(因为静态编译时就要加载执行) -
输出:值的引用(严格意义讲,不算是引用,而是一种绑定,也可以理解为一个对外的接口)
例如,当我们导入两个模块a、b,这两个模块又都各自导入模块c时,
-
我们修改模块c中的变量
-
即使变量值是基本数量类型,模块a、b中导入的c也会直指模块中的变量c的最新值
由此可以看出,
export { c, changeC }并不是简单的导出一个对象,而是对模块导出的变量进行一种绑定这就是一个与CommonJS模块化导出表现的最大不同点
// c.js let c = 1; function changeC() { c++; } export { c, changeC }; // b.js import { c, changeC } from "./mC.js"; changeC(); console.log(c); // 输出:3 // a.js import { c, changeC } from "./mC.js"; changeC(); console.log(c); // 输出:2<script type="module"> import './a.js' import './b.js' </script> -
注意:
ES6 的模块自动采用严格模式,不管你有没有在模块头部加上"use strict";。
严格模式主要有以下限制:
- 禁止
this指向全局对象(尤其注意,ES6模块之中,顶层this指向undefined) - 变量必须声明后再使用
- 函数的参数不能有同名属性,否则报错
- 不能删除变量
delete prop,会报错,只能删除属性delete global[prop] - 不能使用
with语句 - 增加了保留字(比如
protected、static和interface) - 等等
什么是运行时加载,什么是编译时加载
CommonJS运行时加载
注意:虽然是运行时加载,但也不是每次加载都会执行的,首次加载完成会缓存一个
cmB.js
const b = "b";
console.log("执行cmB");
module.exports = {
b,
};
cmA.js
const a = "a";
console.log("执行cmA"); // 这里先执行
const { b } = require("./cmB");
module.exports = {
a,
b,
};
app.js
const { a, b } = require("./cmA");
console.log("commonJS: ", a, b);
// 执行输出顺序:
// 执行cmA
// 执行cmB
// commonJS: a b
ES6Module编译时加载
mB.js
const b = "b";
console.log("执行mB"); // 通过静态依赖分析,最深的依赖最先执行
export { b };
mA.js
const a = "a";
console.log("执行mA");
import { b } from "./mB.js";
export { a, b };
index.html
<body>
<script type="module">
import { a, b } from './mA.js'
console.log("ES6 Module: ", a, b);
// 执行输出顺序:
// 执行cmB
// 执行cmA
// ES6 Module: a b
</script>
</body>
使用browserify打包实现前端使用CommonJS (与Node环境下的效果一致)
cmB.js
const b = "b";
console.log("执行cmB");
module.exports = {
b,
};
cmA.js
const a = "a";
console.log("执行cmA");
const { b } = require("./cmB.js");
module.exports = {
a,
b,
};
app.js
const { a, b } = require("./cmA.js");
console.log("commonJS in browserify: ", a, b);
安装 npm i browserify -g ,通过 browserify src/app.js -o src/dist/bundle.js 打包出产物:
bundle.js
(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
const { a, b } = require("./cmA.js");
console.log("commonJS in browserify: ", a, b);
},{"./cmA.js":2}],2:[function(require,module,exports){
const a = "a";
console.log("执行cmA");
const { b } = require("./cmB.js");
module.exports = {
a,
b,
};
},{"./cmB.js":3}],3:[function(require,module,exports){
const b = "b";
console.log("执行cmB");
module.exports = {
b,
};
},{}]},{},[1]);
在index.html引入使用
<body>
<script src="./src/dist/bundle.js"></script>
</body>
// 执行输出顺序:
// 执行cmA
// 执行cmB
// commonJS in browserify: a b
面试题:
有人问过你CommonJS和ES6Module哪个是拷贝、哪个是引用吗?
人家想听到的是:CommonJS是拷贝,ES6 Module 是引用,尽管这种讲法其实很粗俗。