**第一步 基础筑基**(建立完整的工程化认知体系)
- 工程化核心概念:模块化(`《javascript高级程序设计 - 第六章》,模块化七日谈,ESM、CJS、UMD的区别,tree shaking实现原理,动态导入最佳实践`。 ***`模块化改造报告`***)
// - Webpack/Vite实战(`Webpack解谜课程,Vite源码解析,Webpack Loader/Plugin机制,Vite预构建原理,Rollup打包配置`。***`自定义Loader开发`***)
// - 规范体系:Eslint(`Airbnb规范中文版,Commitizen配置指南,Eslint规则定制,Pre-commit流程设计,代码提交规范`。***`团队规范文档`***)
**第二步 性能优化**(掌握从指标分析到方案落地的完整能力)
// - 指标体系:
// Core Web Vitals深度解读
// RAIL性能模型
// 性能预算解读
// - 构建优化
// - 加载优化:
// 资源优先级控制(preload/prefetch)
// HTTP/2 Server Push实战
// 渐进式静态生成
// - 运行时优化:
// 虚拟列表实现原理对比(react-window vs vue-virtual-scroller)
// Web Work复杂计算卸载
// 内存泄漏检测(Chrome Memory面板)
// - 监控体系
// 搭建性能监控平台(Sentry + Prometheus)
// 用户真实数据采集(RUM)
// 自动化报警规则配置
// ###实践
// - 使用Lighthouse CI搭建自动化评分体系
// - 实现首屏加载时间从3s→1s优化
// - 开发性能异常追溯工具
**第三步 工程化进阶** (具备企业级工程方案设计能力)
// - 微前端落地
// 乾坤(qiankun)沙箱机制
// 模块联邦共享策略
// 跨团队协作规范
// - CI/CD建设
// Jenkins Pipeline设计
// 多环境发布策略
// 安全扫描集成(SAST/DAST)
// - 质量保障体系
// 自动化测试覆盖率统计
// 代码变更影响分析
// 线上故障应急方案
## 一、模块化核心学习内容(已列出的部分)
1. **模块化基础概念**
- **ESM、CJS、UMD 的区别**(已完成)
- **前端模块化演变史**(结合《JavaScript高级程序设计》第六章)
- 无模块化时代 → IIFE → AMD/CMD → CJS → ESM → 现代工具链
- **动态导入最佳实践**:
- `import()` 的用法与代码分割(Code Splitting)
- 结合 React/Vue 的懒加载(Lazy Loading)
- 预加载(Preloading)与预获取(Prefetching)策略
2. **Tree Shaking 原理**
- 补充:工具链中的 Tree Shaking 配置(Webpack/Rollup/Vite)
3. **产出物:前端模块化改造报告**
- 旧项目模块化分析 → 改造方案设计 → 性能对比(体积、加载速度)
* * *
## **二、扩展学习内容**
1. **模块化与工具链的深度结合**
- **Babel 对模块化的支持**:
- `@babel/preset-env` 的 `modules` 配置(`false` 保留 ESM)
- **Webpack/Rollup 的模块化处理**:
- Webpack 的 `output.libraryTarget` 配置(UMD/CJS/ESM)
- Rollup 的 `format` 配置与多格式打包
- **Vite 的模块化设计**:
- 基于 ESM 的 Dev Server 与生产构建
2. **模块化进阶问题**
- **CJS 与 ESM 的互操作**:
- Node.js 中 `.mjs` 与 `package.json` 的 `"type": "module"`
- 混合项目中 `require` 和 `import` 的共存问题
- **第三方库的模块化兼容性**:
- 如何判断库是否支持 ESM?
- 处理仅提供 CJS 的库(如 `lodash` 的 ESM 替代方案)
3. **模块化性能优化**
- **代码分割(Code Splitting)** :
- Webpack 的 `splitChunks` 配置
- 按路由/组件拆分代码
- **HTTP/2 对模块化的影响**:
- 多文件并发加载是否仍需打包?
4. **历史与现代模块化方案对比**
- **AMD/CMD 的兴衰**:
- RequireJS vs Sea.js
- **IIFE 模式的遗留问题**:
- 全局变量污染与命名冲突
前端工程化基础 -- 模块化
模块化的本质就是将复杂的系统拆分为独立、可复用的单元(模块),包括以下原则:
- 封装:模块内部信息的私密性,不应该直接被外部访问或修改。
- 接口与实现分离:模块通过接口与外部通信,内部实现可独立修改。
- 高内聚、低耦合:模块内功能高度相关,模块间的依赖最小化。
- 可复用与可维护:模块独立开发、测试,便于复用和更新。
1、《JavaScript高级程序设计》 第六章
认识对象 - 对象创建 - 对象继承
Object.defineProperty(o, prop, attr), Object.defineProperties(o,{ attr:{}, attr2:{} })Object.getOwnPropertyDescriptor(o, prop)isPrototypeOf(), Object.getPrototypeOf()in, for-in, hasOwnPorperty(), getOwnPropertyNames(), Object.keys()Object.create()
认识对象
对象就是一组无序属性的集合(可以把它看做是一个散列表)
数据属性、访问器属性
对象的每一个属性都有四个属性描述符,是ES5为了实现javascript引擎而定义的,所以一般在js代码中无法直接访问,他们分别是:
// 数据属性
[[Configurable]]: 属性是否可以通过delete删除;属性是否可配置(可否设置后面介绍的三项);设置为false以后就不能改回为true 并且不能设置enumerable、value。
[[Enumerable]]: 属性是否为可枚举。
[[Writable]]: 属性是否可修改。
[[Value]]: 属性值。
直接通过对象字面量的方式定义的属性默认值分别为:true true true undefined。
通过Object.defineProperty()定义的属性默认值分别为:false false false undefined。
通过Object.defineProperty()定义时,全部小写(configurable, enumerable,writable, value)。
// 访问器属性
[[Configurable]]: 同上。
[[Enumerable]]: 同上。
[[Get]]: 函数,返回值 为访问属性时的值。
[[Set]]: 函数,给属性赋值使的中间操作。
对象创建
创建对象的方式:工厂模式、构造函数模式,原型模式,组合模式,动态原型模式,借用构造函数模式,稳妥模式。
1. 工厂模式
function createPerson(name, age) {
const o = new Object();
o.name = name;
o.age = age;
o.sayName = () => {
alert(this.name);
}
return o;
}
const person1 = createPerson('ding', 28)
工厂模式就是将创建一个对象的过程封装到一个函数中,调用这个函数就可以创建很多类似的同类型对象。具有一定的封装性。
函数中的内容对于外部作用域是不可见的,这样我们不能知道函数返回的对象究竟是什么类型。所以构造函数模式。
2. 构建函数模式
function Person(name, age) {
this.name = name;
this.age = age;
this.sayName = () => {
alert(this.name);
}
}
var o = new Person('ding', 28);
构造函数使用到了this指针以及new操作符,来创建新对象,这样创建出来的对象实例都是Person类型。
[重点] new操作符做了哪些事情:
1. 创建一个新的对象;
2. 将构造函数的作用域赋给新对象(因此this指向这个新对象);
3. 在这个空白对象的作用域下运行这个函数,为对象添加属性。
4. 默认返回这个新创建的对象实例。
构造函数与普通函数没有任何区别,首字母大写只是约定写法,为了表示这是一个作为构造函数使用的函数。直接运行函数也是可以的,只是此时这个函数的作用就是全局对象,或者通过call借用作用域。
const a = {}
Person.call(a, 'ding', 28) // 这样a对象上就会有name和age属性
构造函数创建的对象问题就是:类似于上面构造函数中的sayName方法,每创建一个实例对象,就会重新创建一个sayName函数对象,是一种浪费。所以原型模式。
3. 原型模式
function Person(name, age) {}
Person.prototype.name = 'ding';
Person.prototype.age = 28;
Person.prototype.sayName = () => {};
const person1 = new Person();
const person2 = new Person();
Person.prototype是Person函数的原型对象,原型对象中的属性是所有Person函数创建的实例对象所共用的,上面就不会在每一个实例对象上都创建一次sayName函数对象。
每一个函数都有一个prototype属性,指向这个函数对应的原型对象,
每一个原型对象都有一个constructor属性指向其对应的构造函数,
构造函数通过new创建的实例对象中有一个[[prototype]]属性(在chrome、safari、firfox、edge浏览器的js引擎中为__prototype__属性)指向原型对象。
new创建的实例对象与构造函数之间没有链接关系。
构造函数的原型对象可以通过赋值对象字面量来修改,但是[注意]对象字面量也是一个对象,它的constructor指向的是构造函数Object(),
funtion Person() {}
Person.prototype.name = 'ding';
Person.sayName = () => {}
const person1 = new Person();
Person.prototype = { // 此时使用对象字面量来修改原型对象
age: 28,
sayHah: () => {}
}
person1.age; // 报错,因为person1的__prototype__还指向原来的原型对象,之前的原型对象上没有age属性
///////////////
Person.prototype = {
constructor: Person, // 手动修正指向,但这种方式会将原型对象的constructor的enumerable设置为true,即会变成可枚举属性,可以通过Object.defineProperty()来修正这一情况
age: 28,
sayHah: () => {}
}
person1.age; // 仍然报错,即便修正了原型对象的constructor,但新创建的这个对象字面量和原来的原型对象依旧是两个对象,person1仍然指向之前的原型对象
实例对象访问原型上的属性是一次查找过程,比如person1.sayName()将在person1实例对象上查找sayName(),没有查找到,再去原型上查找。
实例上只能读取原型上的属性,不能修改,如person1.name = 'yaun',则会在person1实例对象上创建一个name属性,当使用person1.name访问属性值时,会先去查找person1实例对象上的name属性,有这个属性则不会去prototype原型对象上查找这个属性。此时将person.name = null 设置为null也不会改变这个覆盖的情况,除非在person1上delete这个name属性,才能重新访问到原型上的name属性。
通过isPrototypeOf(), Obejct.getPrototypeOf()来确定一个对象是否为另一个对象的原型对象 以及获取一个对象的原型对象。
in可以返回一个属性是否存在于某个对象中(实例对象 + 原型对象)
for-in返回一个属性是否位于某个对象中(实例对象 + 原型对象,仅可枚举属性)
Object.hasOwnProperty()返回一个属性是否位于某个对象中(实例对象)
Object.keys()返回对象上的所有属性(key)的字符串数组(实例对象,仅可枚举属性)
Object.getPropertyKeys()返回对象上所有属性(key)的字符串数组(实例对象)
原型模式的缺点就是 你无法向构造函数传递参数来动态生成不同属性值的同类对象(小问题),
对于原型模式来说比较大的缺陷是,如果原型对象上的属性是一个引用类型(数组),创建出来的不同实例对象会共用这个引用类型,在某一个实例属性中发生改变 就会影响全部实例对象,如person1往数组中push了一条数据,那么person2访问的这个原型属性时也会有多这条数据。
Person.prototype.roles = ['student', 'teacher'];
const person1 = new Person();
const person2 = new Person();
person1.roles.push('kids');
console.log(person2.roles); // 'student', 'teacher', 'kids'
所以组合模式
4. 组合模式
function Person(name, age) {
this.name = name;
this.age = age;
this.roles = ['student', 'teacher'];
}
Person.prototype.sayName = () => {}
const person1 = new Person();
const person2 = new Person();
这是创建对象比较广泛的方式。
5. 动态原型模式 6. 寄生构造函数模式 7.稳妥模式
这三种模式不是特别重要,动态原型模式就是在构造或函数通过if语句判断一下原型上的sayName属性是否为function,不是才在原型上初始化这个函数,是则不会。
寄生构造函数模式就是构造函数模式,但是显式return返回了一个实例对象,虽然new操作符会返回一个实例对象,但是当函数显式返回了一个函数,则使用这个实例对象。
对象继承
javascript语言中函数没有签名,所以js中的对象继承没有接口继承,都是实现继承。
js中的对象继承基本原理由原型链实现。
原型链模式,借用构造函数模式,组合式,原型式,寄生式,寄生组合式
1. 原型链模式
function Sub() {};
function Sup() {
this.role = ['students', 'teacher'];
};
Sub.prototype = new Sup();
这样Sub()构造函数就继承了Sup构造函数的实例对象、原型对象的所有属性。
原型链继承的问题和前面讲到的直接使用原型创建对象的问题一样,
之前为了避免将引用类型(如数组roles=['students','teacher'])放到原型对象上共享导致问题发生,所以将引用类型放到构造函数中在每一个实例对象中都创建一个副本。
在这里直接将new Sup()创建的实例对象(实例对象将会存在引用类型roles),直接作为Sub()构造函数的原型对象,那么就会导致Sub.prototype里面也会有引用类型,从而因为共享导致问题发生。
所以不能直接单独使用原型链作为对象继承的方式,要做一定改造。
寄生构造函数。
2. 借用构造函数模式
function Sup() {
this.roles = ['students','teacher'];
}
function Sub() {
Sup.call(this)
}
const sub1 = new Sub();
在子类构造函数中内置超类构造函数运行。
这样子类型sub1的实例对象就会有超类型构造函数定义的所有属性。
缺点:此时子类型实例对象并没有继承超类型的原型对象。
3. 组合式
function Sup() {
this.roles = ['students','teacher'];
}
function Sub() {
Sup.call(this);
}
Sub.prototype = new Sup();
const sub1 = new Sub();
这是最常用的方式。
直接结合原型模式以及构造函数模式,
虽然Sub.prototype原型对象上有new Sup()实例对象的引用类型roles,
但是通过在Sub构造函数中运行了一遍Sup构造函数,将Sup实例对象中的应用类型在Sub实例对象上都定义了一遍,sub实例对象里面将会存在roles引用类型副本,对原型对象有一个覆盖作用,从而不会共享
但是这样就两次Sup() 构造函数,并且在Sub.prototype原型对象中内置了很多不需要的、被实例对象上的属性覆盖的属性。
4. 原型式
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
const person1 = object(person)
const person1 = object(person)
原型模式这里可以将F()构造函数看作一个临时的什么东西都没有的空构造函数
ES5将这个简易的方法规范化了,以Object.create()的方式提供,实现和返回与上面的object()函数一样,只是可以接收第二个参数,第二参数与Object.defineProperties()的第二个参数相同,是返回的对象新创建的属性值。
这种方式适用于基于一个已有对象 快速的创造多个类似的其他对象。
结合寄生构造函数以及原型模式,我们可以解决上面提到的直接使用原型链继承的问题。
5. 寄生式
function createPerson(o) {
const clone = object(o);
clone.sayName = () => {};
return clone;
}
寄生式就是原型式的一种衍生,对原型式创建的对象添加一定属性后返回。
5. 寄生组合式继承
function Sup() {
this.roles = ['students', 'teacher'];
}
function Sub() {
Sup.call(this);
}
function createPrototype(sup, sub) {
const proto = Object.create(sup.prototype);
proto.construtor = sub;
sub.prototype = proto;
}
createPrototype(Sup, Sub);
const sub = new Sub();
这样子类型构造函数的原型对象只继承了超类型的原型对象。
子类型的实例对象上也继承了超类型的实例属性。
这样既完成了属性的完全继承,也不会在子类型的原型类型上定义多余的引用类型。
2. 《javascript模块化七日谈》
上古时期:对象命名空间、IIFE
石器时代:Labjs,YUI3.js
第一次工业革命:CJS,AMD,CMD
第二次工业革命:browserify, webpack, babel(babel-browserify, babel-loader)
第三次工业革命:ESM
上古时期:模块模式
模块化的两个核心点:私有变量、局部作用域。
虽然对象字面量能够实现划分全局作用域的命名空间,但是仅通过对象划分全局环境的命名空间,每一个对象中的变量属性,都可以在外部访问并修改的,没有私密性,并且对象字面量没有局部作用域。所以使用IIFE是更契合模块模式的。
const Module = (function(){
var _private = 'ding';
function sayA() {
alert(_private);
}
return {
sayA
}
})()
Module._private; // undefined
Module.sayA(); // ding
// 那么向IIFE中传入依赖,就可以加载依赖
const Module1 = (function($body) {
const a = $body;
function sayA() {
console.log(a);
}
return {
sayA
}
})(jQuery);
Module1.sayA();
石器时代:模块加载
当前端有了模块化,对于大型项目(依赖多、杂,代码多)模块的加载以及依赖管理是避不开的问题。
// 最初项目中引入不同的script模块
body
script(src="jquery.js")
script(src="lodash.js")
script(src="a.js")
script(src="b.js")
script(src="c.js")
script(src="d.js")
script(src="e.js")
...
此时依赖杂乱,顺序十分重要,串行加载,会阻塞影响应用页面加载响应速度
依赖请求过多,依赖模糊,难以维护
1. LABjs的链式书写模块,异步加载模块, 基于文件的依赖管理
script(src="LAB.js" async)
$LAB.script("jQqery.js").
.script("lodash.js").wait().
.srcript("a.js").
.srcript("b.js").wait().
.srcript("c.js")
)
Labjs实现了模块的并行异步加载,减少页面阻塞,
并通过链式调用,明确了脚本之间的执行顺序,
但是当依赖模块庞杂时,编写链式逻辑依然麻烦。
2. YUI3的模块定义与模块使用,基于模块进行依赖管理,
沙箱思路,所有的依赖都通过attach的方式被注入沙盒
YUI.add("hello", function(Y) {
Y.sayName = function() {}
},"3.0.0", { require: ["jQuery", "lodash"] } );
YUI.use("hello", function(Y) {
Y.sayName();
});
YUI loader相较于LABjs,不用链式书写依赖,自动依赖解析。
YUI loader也是实现了模块的并行异步加载,
并且可以基于依赖来定义模块,在沙箱中使用模块,减少全局变量冲突。
但是当依赖模块过多,YUI loader手动定义模块也会相当繁杂。
LABjs、YUI的出现,依赖管理、模块化封装、按需异步加载的思想直接影响了后续的CJS、AMD、CMD等模块化解决方案。
第一次工业革命:CJS,AMD,CMD
1. 跟随nodejs一同推出的CommonJS(CJS),
将js代码以文件为单位划分模块:一个文件就是一个模块。
通过require,exports语法导入与导出模块暴露的属性。
// math.js
exports.add = function(a, b) {
return a + b;
}
// main.js
var math = require("math");
console.log(math.add(1, 2));
nodejs中,模块的加载时机为执行到require依赖时,同步下载并执行
2. AMD(requirejs)
当CJS在nodejs中取得了成功,社区讨论将这一套方式推广到浏览器中。
有人认为CJS的module 1.0规范不能直接使用于浏览器,应该再起一套模块化规范,于是有了requirejs社区,打造出了requirejs模块化解决方案。
define(["react", "lodash", "moduleA", "types/Employee"], function(react, lodash, moduleA, Employee) {
// 模块预先下载并且预先执行好了,与CJS不同,CJS是require时才执行
})
3. CMD(seajs)
在将CJS推广到浏览器过程中,社区有人认为CJS的module 1.0规范不能完全适用于浏览器,需要做一些调整,基于1.0推出了module 2.0规范,基于这个规范推出了flyScript。
玉伯大佬基于module 2.0规范,借鉴requirejs,推出了seajs
define(function (require, exports, module) {
var react = require("react"); // 依赖提前下载,延迟执行
exports.math = {
PI: 3.14
}
});
第二次工业革命: browserify, webpack
1. Browserify的出现主要是为了解决在浏览器中使用CommonJS模块的问题。
CommonJS是Node.js的模块系统,使用require和module.exports,但浏览器原生不支持。
Browserify通过静态分析代码,将依赖打包成一个文件,使得前端开发者可以使用Node.js风格的模块化。
这样,开发者可以在浏览器中写类似于Node.js的代码,提高了代码的可维护性和复用性。
但是browserify
- **仅支持 CJS**:无法处理 AMD 或其他模块规范。
- **打包单一文件**:未解决代码分割、按需加载问题。
- **无资源处理能力**:仅处理 JS 文件,无法处理 CSS、图片等资源。
webpack
Webpack 在 Browserify 的基础上进一步扩展,提出“一切皆模块”的理念,是现代前端工程化的核心工具。
1.**多模块规范支持**
- 支持 CJS、AMD、ESM、UMD 等所有主流模块规范。
- 不同规范的模块可混合使用,通过工具统一处理。
2.**资源模块化**
- **一切皆模块**:JS、CSS、图片、字体等资源均可通过 `import` 或 `require()` 引入。
3.**代码分割与按需加载**
- **SplitChunks**:自动拆分公共依赖,减少重复代码。
- **动态导入(Dynamic Import)** :通过 `import()` 实现代码分割与懒加载。
// 按需加载模块
button.onclick = () => {
import('./module.js').then(module => {
module.doSomething();
});
};
4.**Tree Shaking 与优化**
- 基于 ESM 的静态分析,删除未使用的代码。
- 结合 `TerserPlugin` 压缩混淆代码。
5.**插件与 Loader 生态**
- **Loaders**:处理非 JS 资源(如 `css-loader`、`file-loader`)。
- **Plugins**:扩展构建流程(如 `HtmlWebpackPlugin` 生成 HTML)。
6.**开发体验优化**
- **热更新(HMR)** :模块热替换,保留应用状态。
- **Dev Server**:本地开发服务器与实时重载。
第三次工业革命:ESM
ESM
ESM是ES6提出来的浏览器模块化方案,至此以后,JS有了官方规范的模块化方案,而不是依赖于社区的实现(CJS, AMD, CMD, browserify)
以文件为基础模块,通过export, export default, import的语法导入与导出模块的属性
// math.js
const Length = 2
export const PI = 3.14;
export const sayName = () => {};
export default Length;
// main.js
import Length, { PI, sayName as say } from "math.js"
Babel 一个JavaScript 编译器(Compiler)
尽管 ES6 提出了 ESM,但现实中存在以下问题:
1.**运行环境兼容性**
- 旧浏览器(如 IE11)和早期 Node.js 版本**不支持 ESM 语法**。
- 开发者希望**提前使用新语法**,同时保证代码在旧环境中运行。
Babel为这些浏览器填补运行时(Polyfill垫片):
注入缺失的API(如Promise、Object.assign)
为不支持ESM的浏览器注入模块加载逻辑
2.**模块化统一问题**
- 项目中可能混合使用 ESM、CommonJS、AMD 等不同模块规范。
- 需将不同模块语法**转换为目标环境支持的格式**。
Babel 的工作流程
1.**解析(Parse)**
将源代码转换为抽象语法树(AST)。
2.**转换(Transform)**
通过插件修改 AST,将 ESM 语法转换为目标语法(如 CommonJS)。
3.**生成(Generate)**
将修改后的 AST 转换为目标代码。
3. CJS, ESM, UMD的区别
CJS (CommonJS) nodejs的模块化方案
* 基于文件的模块化方案,将文件内容包裹在一个函数(不是IIFE)中,形成闭包以隔离作用域,来实现模块化。
(function(exports, require, module, __filename, __dirname){})
* 同步加载(运行到require位置时,同步加载依赖文件模块,文件读取的过程是阻塞性的,适用于服务器),可以require动态拼接模块路径,通过if语句判断决定引入依赖模块。
* 灵活,但也因此无法在编译时确定模块的依赖关系,无法进行TreeShaking,难以进行编译时优化。
* require引入的是模块的值浅拷贝(对象是引用)。
* 缓存机制:首次加载后模块会被缓存,后续require()直接读取缓存。
ESM ES6提出的浏览器JS模块化的官方规范
* 基于文件的模块化方案,在每个文件中自动启用了严格模式以及模块作用域。
* 模块文件自动开启strict严格模式
* 禁止直接使用未声明的变量(即禁止隐式声明全局变量)
* 删除with,限制eval作用域
* 函数中的this在非对象调用时默认为undefined(而非全局对象)
* 模块作用域
* 类似于闭包,但不是闭包,是JS引擎层面实现的特性。
* 通过<script type="module">开启模块作用域。
* 普通script脚本顶层变量都会挂在到全局对象中,但ESM模块的顶层变量都会限定在当前文件中,在独立的词法环境中执行。
* 异步加载,静态解析(适用于浏览器)。模块可以在编译时通过export / import语法来确定各个模块间的依赖关系,所以可以在编译阶段使用TreeShaking排除掉没有使用到的模块,优化代码。
* 实时绑定:import的变量是原始值的引用(在导入方为只读,导出引入的都是存储该值的内存地址)。模块内的修改会同步到所有导入方。
// math.js
export var PI = 3.14;
setTimeOut(() => { PI = 3; }, 1000);
// main.js
import { PI } from "./math.js";
console.log(PI); // 1s后打印值变为3
UMD 兼容CJS、ESM、AMD等的模块化方案。
* 根据不同的环境自动导出适用于该环境模块化规范的模块,使用IIFE包裹模块,在不同环境导出不同的模块对象。
* 运行时动态适配。
* 包的体积会稍大,因为有额外的兼容逻辑。
4. tree shaking实现原理
摇树算法,前端工程优化性能的核心手段之一。基于ESM的静态特性实现,编译时静态分析代码的import、export(ESM的export/import是静态声明)关系,将未引用的export代码、不可达代码(如if(false) {...}或未执行代码)删除。
1. 编译代码为AST树时,分析代码中export、import语句
2. 分析变量和函数的引用关系,根据依赖关系,确定哪些导出未被使用,标记需要删除的代码
3. 删除标记的Dead Code,生成优化后的代码
4. 根据配置或注释保留可能产生副作用的代码
摇树算法的局限性:
1. 对于动态解析引入的代码(CJS中的require()),不适用。
2. 摇树算法对于有副作用的代码不会处理,除非在package.json中显示定义了sideEffect。
// 有副作用的代码(如修改了全局状态)
window.sayHi = {word: 'hello';}
// package.json中显示标记副作用
{
"sideEffects": ["*.css", "polyfill.js"]
}
5. 动态导入最佳实践
动态导入就是在Javascript中使用import()语法按需加载模块。
动态导入能够显著提升应用性能,主要体现在:
- 减少初始包大小,减少首屏加载耗时(拆分非关键代码)。
- 配合vite、webpack等工具自动拆分代码块(chunks)。
// 1. 路由级懒加载
const routes = [
{
path: '/home',
component: () => import('./home');
},
]
// 2. 按钮点击、虚拟滚动等函数触发的模块懒加载
const handleClick = async () => {
const moduleA = await import('./moduleA.js');
moduleA.render();
}
// 3. 错误处理
try {
// ...
} catch(err) {
const moduleB = await import('./moduleB');
}
// 4. 对大型库进行按需加载,减少主包体积
// 仅在使用时加载特定功能
async function formatDate(date) {
const moment = await import('moment');
return moment(date).format('YYYY-MM-DD');
}
// 5. 结合Web Worker在线程空闲时,加载模块,避免阻塞主线程。
// 6. 对即将使用的模块进行预加载,平衡首屏性能和后续体验。
// 7. 避免过度分割。每个动态导入会产生一个HTTP请求。
原则
首屏关键路径优先静态加载
非关键功能动态加载
监控与优化加载策略,避免过度分割
// 1. 动态加载为什么可以实现将代码拆分出去并不影响关键代码运行?
webpack、vite等工具在对源代码进行打包构建时,会将import()引入的模块进行标记,
将其打包为单独的Chunk独立文件,
在应用运行到特定阶段触发动态加载时,再请求该Chunk文件,解析并运行(运行后缓存)。
// 2. 前端应用加载动态导入Chunk文件,是在初始化时请求下载,还是触发动态加载模块(如点击事件)时才去请求Chunk文件,然后解析运行?
默认是在事件触发以后,才去请求Chunk文件。
button.onclick = async () => {
const module = await import('./chart.js'); // 动态导入
module.render();
};
此时:
- 主 Chunk(如 `main.js`):包含初始化逻辑,但不含 `chart.js` 代码。
- 异步 Chunk(如 `chart.[hash].js`):独立文件,仅在被触发时请求文件,并解析运行。
可以使用预加载策略来提前加载Chunk文件:
a. window.requestIdleCallback()方法在主线程空闲的时候加载资源(仅下载文件):
window.requestIdleCallback(() => { import('./chart.js'); });
b. 使用webpack注释生成预加载标签,强制在初始化时预加载Chunk文件:
import(/* webpackPreload: true */ './chart.js');
模块化改造报告
-
打开工程
.gzip压缩算法- 整包体积
22.3Mb →15.6Mb - 主包体积
3977kb →1621kb
- 整包体积
-
打开工程
brotli压缩算法- 整包体积
22.3Mb →15Mb - 主包体积
3977kb →1269kb
- 整包体积
-
开启压缩
terser- 整包体积
22.3Mb →14.9Mb - 主包体积
3977kb →1200kb
- 整包体积
-
删除工程中
没有使用但在package.json中引用了的依赖"dependencies": { "moment": "2.30.1", "dayjs": "1.11.10" }工程中已经引入了day.js(体积仅 2KB 左右)但在SystemOverviewDetail.vue卡片中仍然引用了moment老旧大体积日期处理库(约 300KB+ (压缩后约 64KB))【待优化】- 删除无用依赖
@noction/vue-draggable-grid,@wangeditor/editor,@wangeditor/editor-for-vue,calint,driver.js,echarts,echarts-liquidfill,highlight.js,lodash,md5,mockjs,print-js,vuedraggable
-
主包中应存在:
-
应用启动核心
coremain.ts入口文件:vue, vue-router, pinia初始化 -
全局注册的组件以及样式:
element-plus(需按需引入),element-plus/icon-vueca-ui(需按需引入)全局样式(全局CSS, SCSS文件,Element-plus核心样式(需按需引入))- 多语言(
voerlaI18n) - 埋点(
gio, cloudcare) Garfish初始化,应用与卡片间事件监听器- 自定义指令
directives CkEditor(已删除)
- 多语言(
-
首屏路由组件
- 登录页
/login - 首页
/home
- 登录页
-
核心状态管理
- 用户认证状态:
userStore - 全局配置状态:
globalStore
- 用户认证状态:
-
-
不应该包含在主包中的内容 (×××)
- 非首屏路由组件
- 重型第三方库(
Echarts, vxe-table,vxe-pc-ui,xe-utils, xgplayer,sortablejs) - 业务模块组件(复杂数据表格)
- 工具类函数(数据校验工具,日期格式化库,加密工具函数)
-
优化策略
- 路由级代码分割
ESM动态导入路由组件(√)
- 路由级代码分割
-
构建配置优化
build: { rollupOptions: { output: { manualChunks: (id) => { // 处理 element-plus 的 css 单独拆包 if (/node_modules[/\]element-plus/.test(id) && id.endsWith('.css')) { return 'element-plus' } // 模拟对象形式的拆包逻辑 const vendorMap = { // 不将vue单独打包,异步加载vue核心模块会导致使用先于定义的问题出现 // vue: ['vue', 'pinia', 'vue-router'], element: ['element-plus'], unocss: ['unocss'], utils: ['lodash-es', 'dayjs', 'axios'], }; for (const [chunkName, deps] of Object.entries(vendorMap)) { if (deps.some((dep) => id.includes(`/node_modules/${dep}`))) { return chunkName; } } }, }, }, }, plugins: [ // Element Plus 按需加载 Components({ resolvers: [ElementPlusResolver()] }) ] // 将主包中的一些单独打Chunk包,拆分主包体积 // 优点: 1. 不用等待大体积主包完全加载完毕后,再解析(不拆包加载时间更长,解析时间也更长) 2. 拆分后可以利用浏览器的缓存、并行加载、预加载Chunks包,提高整体加载灵活度(适合大型项目情况) 3. 拆分后可以更快的完成主包解析,进入业务逻辑的运行主包体积
1200kb →926kb-
将
@ca-ui/plus, voerkaI18n, element-plus/icon-vue单独拆包,gio不首屏加载rollupOptions: { output: { manualChunks: (id) => { // 处理 element-plus 的 css 单独拆包 if (/node_modules[/\]element-plus/.test(id) && id.endsWith('.css')) { return 'element-plus' } // ca-ui 的 css 单独拆包 if (/node_modules[/\]ca-ui/.test(id) && id.endsWith('.css')) { return 'caui'; } // 模拟对象形式的拆包逻辑 const vendorMap = { element: ['element-plus', '@element-plus/icons-vue','@ca-ui/plus'], unocss: ['unocss'], utils: ['lodash-es', 'dayjs', 'axios'], i18n: ['@voerkai18n/vue'] }; for (const [chunkName, deps] of Object.entries(vendorMap)) { if (deps.some((dep) => id.includes(`/node_modules/${dep}`))) { return chunkName; } } }, }, }, // 控制预构建行为 optimizeDeps: { include: ['vue', 'pinia', 'vue-router', 'i18n'], // 首屏必须的依赖提前构建 exclude: ['gio-webjs-sdk-cdp'] // 非首屏必须的排除 }主包体积
926kb →167kb【注意事项】
a. 没有将
vue, vue-router, pinia单独打包,因为主包以及其他异步加载的包中有模块依赖vue核心,如果单独将之打包为独立的Chunk异步加载,会导致使用先于定义的问题出现。b. 需要将
element-plus, @ca-ui两个组件库合并打包到一个Chunk中,因为他们之间似乎存在着相关依赖(需后续进一步理清),拆分为两个独立的Chunk包异步加载也会导致使用先于定义的问题出现。c. 因为工程使用和
Garfish微前端框架,在main.ts入口文件中就进行了微前端的初始化,需要主工程向子工程全量注入element-plus, @ca-ui,所以必须在初始化的时候就请求拉去全量的element-plus, @ca-ui包,不能够使用 按需引入 来减小初始化请求文件的总量以及体积。
【总结】改造前后对比
-
改造后:
| **指标** | 改造前 | 改造后 | **变化** | **含义与影响** | **优化建议** |
| ---------------------------------- | ----- | ---- | ------ | ---------------------------------- | ------------------------ |
| **First Contentful Paint (FCP)** | 6.3s | 5.7s | ✅ 提升 | 用户首次看到**任何内容**的时间,减少0.6s提升初始加载感知速度 | 优化关键CSS/JS、预加载关键资源 |
| **Largest Contentful Paint (LCP)** | 7.7s | 7.6s | ➖ 持平 | **核心内容**加载时间,仅优化0.1s,用户仍可能觉得页面"慢" | 优先加载首屏图片/视频、服务端渲染 |
| **Total Blocking Time (TBT)** | 10ms | 30ms | ❌ 变差 | 主线程阻塞时间增加20ms,可能导致交互延迟(如按钮响应) | 减少长任务、代码拆分、优化Web Workers |
| **Cumulative Layout Shift (CLS)** | 0 | 0 | ➖ 保持 | 无布局偏移,用户体验稳定(满分) | 保持现有策略 |
| **Speed Index** | 13.5s | 5.7s | ✅ 大幅提升 | **视觉完成速度**提升58%,用户更快看到完整页面 | 继续优化资源加载顺序 |
改造效果综合评分
| **维度** | 改造前 | 改造后 | 改进幅度 |
| -------- | ----- | ----- | ---- |
| 首屏速度 | ★★☆☆☆ | ★★★☆☆ | +50% |
| 交互响应 | ★★★★☆ | ★★★☆☆ | -25% |
| 视觉稳定性 | ★★★★★ | ★★★★★ | 0% |
| **综合体验** | ★★★☆☆ | ★★★★☆ | +33% |
结论:拆包策略成功提升了视觉加载速度,但牺牲了部分交互性能。下一步需平衡资源加载优先级,同时优化主线程任务。