模块内具有作用域。
运行时同步加载(动态加载)
同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMD CMD解决方案
CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。
输出值的拷贝,会缓存第一次运行的结果
基本语法
暴露模块:module.exports.xxx = value
或exports.xxx = value
exports指向的是module.exports
// module.exports和exports必须指向同一个值
exports = module.exports = {};
// wrong: exports不再指向module.exports
exports = {};
保险起见,可使用以下方式:
modules.exports.foo = bar
export.foo = bar
引入模块:require(xxx)
,如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径
在浏览器端中并行异步加载
依赖前置,换句话说,在解析和执行当前模块之前,模块作者必须指明当前模块所依赖的模块
提前执行依赖
实现:RequireJS
在浏览器端中异步加载
按需加载
依赖就近,依赖可以就近书写,可以把依赖写进你的代码中的任意一行
延迟执行依赖
实现:Sea.js
统一AMD和CommonJS规范,解决跨平台问题。既可以在 node/webpack 环境中被 require
引用,也可以在浏览器中直接用 CDN 被 script.src
引入。
(function (root, factory) {
if (typeof define === "function" && define.amd) {
// AMD
define(["jquery"], factory);
} else if (typeof exports === "object") {
// CommonJS
module.exports = factory(require("jquery"));
} else {
// 全局变量
root.returnExports = factory(root.jQuery);
}
})(this, function ($) {
// ...
});
浏览器和服务器通用的模块解决方案
ES6 模块设计思想:尽量的静态化、使得编译时就能确定模块的依赖关系,以及输入和输出的变量(CommonJS和AMD模块,都只能在运行时确定这些东西)。
编译时加载(静态加载)
动态引用,不会缓存值
输出值的引用
当ES6遇到import时,不会像CommonJS一样去执行模块,而是生成一个动态的只读引用(在代码静态解析阶段就会生成),当真正需要的时候再到模块里去取值,所以ES6模块是动态引用,并且不会缓存值。
编译:类似翻译,就是将源代码翻译成机器能识别的代码。
运行:就是将代码跑起来,被装载到内存中去了。
AST
是 Abstract Syntax Tree
的简称,是前端工程化绕不过的一个名词。它涉及到工程化诸多环节的应用,比如:
而在语言转换的过程中,实质上就是对其 AST 的操作,核心步骤就是 AST 三步走
以下是一段代码,及其对应的 AST
// Code
const a = 4
// AST
{
"type": "Program",
"start": 0,
"end": 11,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 11,
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 11,
"id": {
"type": "Identifier",
"start": 6,
"end": 7,
"name": "a"
},
"init": {
"type": "Literal",
"start": 10,
"end": 11,
"value": 4,
"raw": "4"
}
}
],
"kind": "const"
}
],
"sourceType": "module"
}
AST 的生成这一步骤被称为解析(Parser),而该步骤也有两个阶段: 词法分析(Lexical Analysis)和语法分析(Syntactic Analysis)
词法分析用以将代码转化为 Token
流,维护一个关于 Token 的数组
// Code
a = 3
// Token
[
{ type: { ... }, value: "a", start: 0, end: 1, loc: { ... } },
{ type: { ... }, value: "=", start: 2, end: 3, loc: { ... } },
{ type: { ... }, value: "3", start: 4, end: 5, loc: { ... } },
...
]
词法分析后的 Token 流也有诸多应用,如:
语法分析将 Token 流转化为结构化的 AST,方便操作
{
"type": "Program",
"start": 0,
"end": 5,
"body": [
{
"type": "ExpressionStatement",
"start": 0,
"end": 5,
"expression": {
"type": "AssignmentExpression",
"start": 0,
"end": 5,
"operator": "=",
"left": {
"type": "Identifier",
"start": 0,
"end": 1,
"name": "a"
},
"right": {
"type": "Literal",
"start": 4,
"end": 5,
"value": 3,
"raw": "3"
}
}
}
],
"sourceType": "module"
}
可通过自己写一个解析器,将语言 (DSL) 解析为 AST 进行练手,以下两个示例是不错的选择
或可参考一个最简编译器的实现 the super tiny compiler
https://webpack.docschina.org/concepts/manifest/#root
webpack runtime 和 manifest用来引导所有模块的加载和连接交互。
当webpack complier执行、解析和映射应用程序时,它把模块的详细的信息都记录到了manifest中。当模块被打包并运输到浏览器上时,runtime就会根据manifest来处理和加载模块。利用manifest就知道从哪里去获取模块代码。
manifest数据被包含在某个js文件中,可使用 optimization.runtimeChunk
选项将 runtime 代码(包含manifest数据)拆分为一个单独的 chunk。
在 Rollup 中,并不会将所有模块置于 modules
中使用 Module Wrapper 进行维护,它仅仅将所有模块铺平展开。
// index.js
import name from "./name";
console.log(name);
// name.js
const name = "shanyue";
export default name;
在打包后,直接把所有模块平铺展开即可,可见实时示例
// output.js
const name = "shanyue";
console.log(name);
HMR,Hot Module Replacement,热模块替换,见名思意,即无需刷新在内存环境中即可替换掉过旧模块。与 Live Reload 相对应。
PS: Live Reload,当代码进行更新后,在浏览器自动刷新以获取最新前端代码。
在 webpack 的运行时中 __webpack__modules__
用以维护所有的模块。
而热模块替换的原理,即通过 chunk
的方式加载最新的 modules
,找到 __webpack__modules__
中对应的模块逐一替换,并删除其上下缓存。
其精简数据结构用以下代码表示:
// webpack 运行时代码
const __webpack_modules = [
(module, exports, __webpack_require__) => {
__webpack_require__(0);
},
() => {
console.log("这是一号模块");
},
];
// HMR Chunk 代码
// JSONP 异步加载的所需要更新的 modules,并在 __webpack_modules__ 中进行替换
self["webpackHotUpdate"](0, {
1: () => {
console.log("这是最新的一号模块");
},
});
其下为更具体更完整的流程,每一步都涉及众多,有兴趣的可阅读 webpack-dev-server
及开发环境 webpack 运行时的源码。
webpack-dev-server
将打包输出 bundle 使用内存型文件系统控制,而非真实的文件系统。此时使用的是 memfs模拟 node.js fs
APIwebpack
将会重新编译,webpack-dev-server
将会监控到此时文件变更事件,并找到其对应的 module
。此时使用的是 chokidar监控文件变更webpack-dev-server
将会把变更模块通知到浏览器端,此时使用 websocket
与浏览器进行交流。此时使用的是 ws
websocket
接收到 hash,并通过 hash 以 JSONP 的方式请求更新模块的 chunk使用 speed-measure-webpack-plugin可评估每个 loader/plugin 的执行耗时。
在 webpack
中耗时最久的当属负责 AST 转换的 loader。
当 loader 进行编译时的 AST 操作均为 CPU 密集型任务,使用 Javascript 性能低下,此时可采用高性能语言 rust 编写的 swc
。
比如 Javascript 转化由 babel
转化为更快的 swc。
module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules)/,
use: {
loader: "swc-loader",
},
},
];
}
webpack5
内置了关于缓存的插件,可通过 cache 字段配置开启。
它将 Module
、Chunk
、ModuleChunk
等信息序列化到磁盘中,二次构建避免重复编译计算,编译速度得到很大提升。
{
cache: {
type: "filesystem";
}
}
如对一个 JS 文件配置了 eslint
、typescript
、babel
等 loader
,他将有可能执行五次编译,被五次解析为 AST
acorn
: 用以依赖分析,解析为 acorn
的 ASTeslint-parser
: 用以 lint,解析为 espree
的 ASTtypescript
: 用以 ts,解析为 typescript
的 ASTbabel
: 用以转化为低版本,解析为 @babel/parser
的 ASTterser
: 用以压缩混淆,解析为 acorn
的 AST而当开启了持久化缓存功能,最耗时的 AST 解析将能够从磁盘的缓存中获取,再次编译时无需再次进行解析 AST。
得益于持久化缓存,二次编译甚至可得到与 Unbundle 的 vite 等相近的开发体验
在 webpack4 中,可使用 cache-loader 仅仅对 loader
进行缓存。需要注意的是该 loader 目前已是 @depcrated
状态。
module.exports = {
module: {
rules: [
{
test: /\.ext$/,
use: ["cache-loader", ...loaders],
include: path.resolve("src"),
},
],
},
};
thread-loader为官方推荐的开启多进程的 loader
,可对 babel 解析 AST 时开启多线程处理,提升编译的性能。
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: "thread-loader",
options: {
workers: 8,
},
},
"babel-loader",
],
},
],
},
};
在 webpack 中,可以使用 webpack-bundle-analyzer分析打包后体积分析。
其原理是根据 webpack 打包后的 Stats数据进行分析。
在默认配置下,webpack-bundle-analyzer 将会启动服务打开一个各个 chunk 下各个 module 占用体积的可视化图。
你可以通过它,找到在在打包中占用体积最大的模块,并进行优化。
在查看页面中,有三个体积选项:
stat
: 每个模块的原始体积parsed
: 每个模块经 webpack 打包处理之后的体积,比如 terser 等做了压缩,便会体现在上边gzip
: 经 gzip 压缩后的体积通过 AST 分析,根据选项配置一些策略,来生成一颗更小体积的 AST 并生成代码。
目前前端工程化中使用 terser和 swc进行 JS 代码压缩,他们拥有相同的 API。
常见用以压缩 AST 的几种方案如下:
// 对两个数求和
function sum (a, b) {
return a + b;
}
此时文件大小是 62 Byte
, 一般来说中文会占用更大的空间。
多余的空白字符会占用大量的体积,如空格,换行符,另外注释也会占用文件体积。当我们把所有的空白符合注释都去掉之后,代码体积会得到减少。
去掉多余字符之后,文件大小已经变为 30 Byte
。 压缩后代码如下:
function sum(a,b){return a+b}
替换掉多余字符后会有什么问题产生呢?
有,比如多行代码压缩到一行时要注意行尾分号。
function sum (first, second) {
return first + second;
}
如以上 first
与 second
在函数的作用域中,在作用域外不会引用它,此时可以让它们的变量名称更短。但是如果这是一个 module
中,sum
这个函数也不会被导出呢?那可以把这个函数名也缩短。
// 压缩: 缩短变量名
function sum (x, y) {
return x + y;
}
// 再压缩: 去除空余字符
function s(x,y){return x+y}
在这个示例中,当完成代码压缩 (compress
) 时,代码的混淆 (mangle
) 也捎带完成。 但此时缩短变量的命名也需要 AST 支持,不至于在作用域中造成命名冲突。
通过分析代码逻辑,可对代码改写为更精简的形式。
合并声明的示例如下:
// 压缩前
const a = 3;
const b = 4;
// 压缩后
const a = 3, b = 4;
布尔值简化的示例如下:
// 压缩前
!b && !c && !d && !e
// 压缩后
!(b||c||d||e)
在编译期进行计算,减少运行时的计算量,如下示例:
// 压缩前
const ONE_YEAR = 365 * 24 * 60 * 60
// 压缩后
const ONE_YAAR = 31536000
以及一个更复杂的例子,简直是杀手锏级别的优化。
// 压缩前
function hello () {
console.log('hello, world')
}
hello()
// 压缩后
console.log('hello, world')
Tree Shaking
基于 ES Module 进行静态分析,通过 AST 将用不到的函数进行移除,从而减小打包体积。
/* TREE-SHAKING */
import { sum } from "./maths.js";
console.log(sum(5, 5)); // 10
// maths.js
export function sum(x, y) {
return x + y;
}
// 由于 sub 函数没有引用到,最终将不会对它进行打包
export function sub(x, y) {
return x - y;
}
最终打包过程中,sub
没有被引用到,将不会对它进行打包。以下为打包后代码。
// maths.js
function sum(x, y) {
return x + y;
}
/* TREE-SHAKING */
console.log(sum(5, 5));
当使用语法 import *
时,Tree Shaking 依然生效。
import * as maths from "./maths";
// Tree Shaking 依然生效
maths.sum(3, 4);
maths["sum"](3, 4);
import * as maths
,其中 maths
的数据结构是固定的,无复杂数据,通过 AST 分析可查知其引用关系。
const maths = {
sum() {},
sub() {},
};
Tree Shaking
甚至可对 JSON 进行优化。原理是因为 JSON 格式简单,通过 AST 容易预测结果,不像 JS 对象有复杂的类型与副作用。
{
"a": 3,
"b": 4
}
import obj from "./main.json";
// obj.b 由于未使用到,仍旧不会被打包
console.log(obj.a);
为了减小生产环境体积,我们可以使用一些支持 ES 的 package,比如使用 lodash-es
替代 lodash
。
我们可以在 npm.devtool.tech中查看某个库是否支持 Tree Shaking。
https://github.com/browserslist/browserslist
关于前端打包体积与垫片关系,我们有以下几点共识:
那在前端工程化实践中,当我们确认了浏览器版本号,那么它的垫片体积就会确认。
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。