JS中的模块化
要明白我们的打包工具究竟做了什么,首先必须明白的一点就是JS中的模块化, 在ES6规范之前,我们有commonjs, amd等主流的模块化规范。
CommonJS
JS在早期时原生时没有模块化规范的。Node.js就是一个基于V8引擎,事件驱动I/O的服务端运行环境,在09年推出时它就实现了一套名为CommonJS的模块化规范。
对于死循环截断操作
基于文件同步加载
require函数加载的时候去访问文件里的模块代码进行解析。
单例加载只执行一次
多次require相同的module,最后加载的结果都是相同的,并不是每加载一次就去执行一次代码。每个模块都是单例的。
关键词require和module.exports
在CommonJS规范里,每个JS文件就是一个模块(module),每个模块内部可以使用require函数和module.exports对象来对模块进行导入和导出。
//moduleB.js
module.exports = new Date().toLocaleTimeString();
//moduleA.js
const time = require("./moduleB")
setTimeout(()=>{
console.log('moduleA',time)
},3000)
//index.js
require("./moduleA")
const str = require("./moduleB")
console.log(str)
- index.js 代表的模块通过执行 require 函数,分别加载了相对路径为。./moduleA 和 ./moduleB 两个模块, 同时输出 moduleB 模块的结果。
- moduleA.js 文件内也通过 require 函数加载了 moduleB.js 模块,在3s后页输出了加载进来的结果。
- moduleB.js 文件内部相对来说就简单得多,仅仅定义了一个时间戳,然后通过 module.exports 导出。
AMD
另一个为WEB开发者所熟知的JS运行环境就是浏览器了。浏览器并没有提供像 Node.js 里一样的require 方法。不过,收到CommonJS模块化规范的启发,WEB端还是逐渐发展起来了AMD,SystermJS规范等适合浏览器端运行的JS模块化规范。
异步获取
AMD 全称是 Asynchronous module definition,意为 异步的模块定义, 不同于CommonJS规范的同步加载, AMD正如其名所有模块默认都是异步加载,这也是早期为了满足web开发的需要,因为如果在web端也使用同步加载,那么页面在解析脚本文件的过程中可能会造成页面暂停响应。
基于参数和回调形式来确定模块被完全加载
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>requirejs</title>
</head>
<body>
<script src="./require.js"></script>
<script src="./index.js"></script>
</body>
</html>
关键词通过define和require定义和加载模块
//moduleB.js
define(function(){
return new Date().toLocaleTimeString()
});
//moduleA.js
define(function(require){
var time = require("moduleB")
setTimeout(()=>{
console.log(time)
},3000)
})
//index.js
require(["moduleA", "moduleB"], function (moduleA, moduleB) {
console.log(moduleB);
});
单例-同一个文件不管被加载多少次,结果也是只执行一次,后面加载的都是缓存
RequireJS
RequireJS文档
下载RequireJS
https://requirejs.org/docs/release/2.3.6/comments/require.js
如果想要使用 AMD 规范, 我们还需要在页面中添加一个符合AMD规范的加载器脚本,符合AMD规范实现的库有很多,比较有名的就是 require.js
ESModule
前面我们说到的 CommonJS 规范和 AMD 规范有这么几个特点?
- 语言上层的运行环境中实现的模块化规范,模块化规范是由环境自己定义。
- 相互之间不能共用模块。例如不能在Node.js运行 AMD 模块, 不能直接在浏览器运行 CommonJS 模块
在ES6之后,JS有了语言层面的模块化导入导出关键词与语法以及与之匹配的ESModule规范。使用ESModule规范,我们可以通过 import 和 export 两个关键词来对模块进行导入和导出。
JavaScript modules 模块 - JavaScript | MDNhttps://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules
模块化的背景
Javascript 程序本来很小——在早期,它们大多被用来执行独立的脚本任务,在你的 web 页面需要的地方提供一定交互,所以一般不需要多大的脚本。过了几年,我们现在有了运行大量 Javascript 脚本的复杂程序,还有一些被用在其他环境(例如 Node.js)。
因此,近年来,有必要开始考虑提供一种将 JavaScript 程序拆分为可按需导入的单独模块的机制。Node.js 已经提供这个能力很长时间了,还有很多的 Javascript 库和框架 已经开始了模块的使用(例如, CommonJS 和基于 AMD 的其他模块系统 如 RequireJS, 以及最新的 Webpack 和 Babel)。
好消息是,最新的浏览器开始原生支持模块功能了,这是本文要重点讲述的。这会是一个好事情 — 浏览器能够最优化加载模块,使它比使用库更有效率:使用库通常需要做额外的客户端处理。
浏览器支持
使用JavaScript 模块依赖于import和 export,浏览器兼容性如下(绿色方框中的数字对应相应平台上支持该功能的发布版本):
IE全线不支持
代码
//moduleB.js
let time = new Date().toLocaleTimeString;
export default time;
//moduleA.js
import m from "./moduleB";
setTimeout(() => {
console.log(m);
}, 3000);
//index.js
import "./moduleA";
import m from "./moduleB";
console.log(m);
每个JS的运行环境都有一个解析器,否则这个环境也不会认识JS语法。
大部分环境都不直接支持ESModule,所以需要进行编译。
使用webpack打包,babel转换
yarn add webpack webpack-cli @babel/core babel-loader @babel/preset-env
webpack.config.js
webpack基本使用系列-js兼容性处理 babel-loader @babel/core @babel/preset-env - SegmentFault 思否
https://segmentfault.com/a/1190000022413068
const path = require("path");
module.exports = {
mode: "none",
entry: "./index.js",
output: {
path: path.resolve(__dirname, "dist"), //绝对路径
filename: "index.bundle.js",
// publicPath: "dist/",
},
module: {
rules: [
{
test: /\/js$/,
exclude: /node_modules/,
loader: "babel-loader",
options: {
// 预设:指示babel做怎么样的兼容性处理
presets: [["@babel/preset-env"]],
},
},
],
},
};
npx webpack
@babel/preset-env
只能对基本js兼容处理,也就是只能转换基本语法,遇到promise高级语法不能转换
解决: 可以全部js兼容处理 安装 @babel/polyfill ,支持全部高级语法兼容转换,但是问题是我只要解决部分兼容性问题,但是将所有兼容性代码全部引入,体积太大了~
最终解决方案:兼容处理只需:按需加载 ,需要安装core-js
解析器的作用就是用ECMAScript的规范去解释JS语法,也就是处理和执行语言本身的内容,例如按照逻辑正确执行 var a = "123";function func(){}
之类的内容.
在解析器的上层,每个运行环境都会在解释器的基础上封装一些环境相关的 API。例如Node.js 中的global对象、process对象,浏览器中的window对象,document对象等等。
这些运行环境的API收到各自规范的影响。例如浏览器端的W3C规范,他们规定了window对象和document对象上的API内容,以使得我们能够让这些API正常运行。
ESModule就属于JS Core层面的规范,而AMD,commonjs是运行环境的规范,所以想要是的运行环境支持超ESModule其实是比较简单的,只需要升级自己环境中的JS Core解释引擎导入够的版本,引擎层面就能认识这种语法,从而不认为这是一个 语法错误(syntax error),运行环境只需要做一些兼容工作即可。
Node.js在V12版本之后才可以使用ESModule,需要升级Node到高版本。
Node.js 如何处理 ES6 模块 - 阮一峰的网络日志
http://www.ruanyifeng.com/blog/2020/08/how-nodejs-use-es6-module.html
ES6 模块和 CommonJS 模块有很大的差异。
语法上面,CommonJS 模块使用require()
加载和module.exports
输出,ES6 模块使用import
和export
。
用法上面,require()
是同步加载,后面的代码必须等待这个命令执行完,才会执行。import
命令则是异步加载,或者更准确地说,ES6 模块有一个独立的静态解析阶段,依赖关系的分析是在那个阶段完成的,最底层的模块第一个执行。
Node.js 要求 ES6 模块采用.mjs
后缀文件名。也就是说,只要脚本文件里面使用import
或者export
命令,那么就必须采用.mjs
后缀名。Node.js 遇到.mjs
文件,就认为它是 ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定"use strict"
。
如果不希望将后缀名改成.mjs
,可以在项目的package.json
文件中,指定type
字段为module
。
{
"type": "module"
}
一旦设置了以后,该目录里面的 JS 脚本,就被解释用 ES6 模块。
# 解释成 ES6 模块
$ node my-app.js
如果这时还要使用 CommonJS 模块,那么需要将 CommonJS 脚本的后缀名都改成.cjs
。如果没有type
字段,或者type
字段为commonjs
,则.js
脚本会被解释成 CommonJS 模块。
总结为一句话:**.mjs**
文件总是以 ES6 模块加载,**.cjs**
文件总是以 CommonJS 模块加载,**.js**
文件的加载取决于**package.json**
里面**type**
字段的设置。
注意,ES6 模块与 CommonJS 模块尽量不要混用。require
命令不能加载.mjs
文件,会报错,只有import
命令才可以加载.mjs
文件。
反过来,.mjs
文件里面也不能使用require
命令,必须使用import
。
后模块化的编译时代
通过前面分析,使用ESModule的模块明显更符合JS开发的历史进程,因为任何一个支持JS的环境,随着应用解释器的升级,最终一定会支持ESM的标准。
但是web端受制于用户使用的浏览器版本,我们并不能随心所欲随时使用JS最新特性,为了是我们的代码能够运行在老旧的浏览器中,需要能够静态把高版本规范的代码编译为低版本规范的代码的工具,例如熟知的babel。
然而,不幸的是,对于模块化相关的import和export关键字,babel最终会把它编译为包含require和exports的CommonJS规范。
babel编译无法解决模块化通用的问题
Babel 中文网 · Babel - 下一代 JavaScript 语法的编译器
https://www.babeljs.cn/repl
这就产生了问题,这样带有模块化关键词的模块,编译之后还是无法直接运行在浏览器中,因为浏览器并不能直接运行CommonJs规范的模块,除了编译之外,我们还需要一个步骤叫做 打包(Module)。
打包可以将模块化内部实现的细节抹平
打包工具的作用,就是将模块化内部实现的细节抹平。无论是AMD还是CommonJs模块化规范的模块,经过打包处理后能够变成直接运行在浏览器、Node中的代码。
如何处理打包
NodeJS如何进行commonjs模块化
const str = `require("./moduleA");
const str = require("./moduleB");
console.log(str);`;
const functionWrapper = ["function a(require,module,exports){", "}"];
//1.将文件进行包裹 成为一个字符串函数
const result = functionWrapper[0] + str + functionWrapper[1];
console.log(result)
const vm = require('vm');
//eval new Function
//把字符串变成可执行函数 注入require,module,exports就可以进行导入导出操作
vm.runInNewContext(result)
vm_runinnewcontext | Node.js API 文档
const vm = require('vm');
const contextObject = {
animal: 'cat',
count: 2
};
vm.runInNewContext('count += 1; name = "kitty"', contextObject);
console.log(contextObject);
// 打印: { animal: 'cat', count: 3, name: 'kitty' }
http://nodejs.cn/api/vm.html#vm_vm_runinnewcontext_code_contextobject_options
参考Node.js源码来熟悉CommonJS的处理方式
(function () {
var moduleA = function (require, module, exports) {
console.log("hello bundler");
module.exports = "hello world";
};
var module = { exports: {} };
moduleA(null,module);
})();
浏览器中对CommonJS处理
只有导出的CommonJS的简单脚本
index.js
console.log("hello bundler");
module.exports = "hello world";
index.module.js
(function () {
//为了不用起名字 放到数组里面 数组里面的每一项都是匿名的模块
var moduleList = [
//index.js
function (require, module, exports) {
console.log("hello bundler");
module.exports = "hello world";
},
//上面这部分都是通用的
];
var module = { exports: {} };
moduleList[0](null, module,module.exports);
})();
bundle.js
读取样本然后replace替换然后写入新文件
const path = require("path");
const fs = require("fs");
const boiler = fs.readFileSync(
path.resolve(__dirname, "index.bundle.boilerplate"),
"utf-8"
);
const target = fs.readFileSync(
path.resolve(__dirname, "..", "index.js"),
"utf-8"
);
const content = boiler.replace("/* template */", target);
fs.writeFileSync(path.resolve(__dirname, "dist/index.bundle.js"), content, "utf-8");
index.bundle.boilerplate
(function () {
var moduleList = [
function (require, module, exports) {
/* template */
},
];
var module = { exports: {} };
moduleList[0](null, module,module.exports);
})();
打包结果
(function () {
var moduleList = [
function (require, module, exports) {
console.log("hello bundler");
module.exports = "hello world";
},
];
var module = { exports: {} };
moduleList[0](null, module);
})();
针对导入的脚本
需要找依赖关系然后同步处理导入导出依赖
index.bundle-require.js
(function () {
//为了不用起名字 放到数组里面 数组里面的每一项都是匿名的模块
var moduleList = [
//index.js
function (require, module, exports) {
const moduleA = require("./moduleA");
console.log("moduleA", moduleA);
console.log("hello bundler");
module.exports = "hello world";
},
// moduleA.js
function (require, module, exports) {
module.exports = new Date().toLocaleTimeString();
},
];
// 模块依赖数组
var moduleDepIdList = [{ "./moduleA": 1 }, {}];
function require(id, parentId) {
var currentModuleId =
parentId !== undefined ? moduleDepIdList[parentId][id] : id;
var module = { exports: {} };
var moduleFunc = moduleList[currentModuleId];
moduleFunc((id) => require(id, currentModuleId), module, module.exports);
return module.exports;
}
require(0);
// var module = { exports: {} };
// moduleList[0](null, module);
})();
替换模板 index.bundle.boilerplate
(function () {
var moduleList = [
/* template-module-list */
];
var moduleDepIdList = [
/* template-module-dep-id-list */
];
function require(id, parentId) {
var currentModuleId =
parentId !== undefined ? moduleDepIdList[parentId][id] : id;
var module = { exports: {} };
var moduleFunc = moduleList[currentModuleId];
moduleFunc((id) => require(id, currentModuleId), module, module.exports);
return module.exports;
}
require(0);
})();
异步组件打包
动态import导入在webpack中是如何实现的?
index.js
setTimeout(() => {
import("./moduleA").then((content) => {
console.log(content);
});
}, 5000);
moduleA.js
import m from "./moduleB";
setTimeout(() => {
console.log(m);
}, 3000);
moduleB.js
let time = new Date().toLocaleTimeString();
export default time;
针对异步加载的,webpack把他们打成多个包,然后异步加载,通过JSONP实现
require.ensure
(function () {
var moduleList = [
function (require, exports, module) {
require.ensure("1").then((res) => {
console.log(res);
});
},
];
var moduleDepIdList = [];
var cache = {}; //异步
function require(id, parentId) {
var currentModuleId =
parentId !== undefined ? moduleDepIdList[parentId][id] : id;
var module = { exports: {} };
var moduleFunc = moduleList[currentModuleId];
moduleFunc((id) => require(id, currentModuleId), module, module.exports);
return module.exports;
}
//JSONP作用就是从全局cache里面读取到刚才存储的resolve方法
//在resolve里面 通过函数执行把模块导出的对象resolve出去
//就可以在then里面拿到模块
window.__JSONP = function (chunkId, moduleFunc) {
var currentChunkStatus = cache[chunkId];
var resolve = currentChunkStatus[0];
var module = { exports: {} };
moduleFunc(require, module, module.exports);
resolve(module.exports);
};
//对于import() 遇到require.ensure 通过jsonp进行异步加载
//通过全局的对象对当前模块的状态进行缓存
//没有就创建一个script标签通过jsonp形式异步通过trunkId拿到打包结果
//最终返回一个promise
//把两个状态添加到全局的cache
//第一个是resolve状态 记录当前status 为true就是还在加载
require.ensure = function (chunkId, parentId) {
var currentModuleId =
parentId !== undefined ? moduleDepIdList[parentId][chunkId] : chunkId;
var currentChunkStatus = cache[currentModuleId];
if (currentChunkStatus === undefined) {
//没有
var $script = document.createElement("script");
// $script.src = chunkId + currentChunkStatus + ".js";
$script.src = "chunk_" + chunkId + ".js";
document.body.appendChild($script);
var promise = new Promise(function (resolve) {
var chunkCache = [resolve];
chunkCache.status = true;
cache[currentModuleId] = chunkCache;
});
cache[currentModuleId].push(promise);
return promise;
}
if (currentChunkStatus.status) {
return currentChunkStatus[1];
}
return chunkCache;
};
// require(0);
moduleList[0](require, null, null);
})();