前端开发核心知识进阶 5 前端工程化
前端工程化
模块化
-
模块化,将大的应用拆分成功能单一且独立的文件,通过向外暴露数据或方法,与外部其他文件交互。
-
模块化原则:可复用性、可组合性、中心化、独立性
-
模块化发展历程:
- 早期假模块化时代
- 规范标准时代
- ES原生时代
1. 早期假模块化时代
-
函数模式
- 借助函数作用域来模拟模块化,即将不同功能封装成不同的函数。
- 缺点:各个函数在同一个文件中,混乱地互相调用,且存在命名冲突。
function f1(){} function f2() {}
-
对象模式
- 利用对象,实现命名空间的概念
- 缺点:数据存储不安全,开发者可随意修改
const module1 = { foo: 'bar', f11: function f11() {}, f12: function f12() {} } const module2 = { data: 'data', f21: function f21() {}, f22: function f22() {} } // 调用 module1.foo module1.f11()
-
IIFE模式
- 闭包就是一个天生解决数据访问性问题的方案。通过立即执行函数,构造一个私有作用域,再通过闭包,将需要对外暴露的数据和接口输出。
// 将立即执行函数与闭包结合使用 const module = (function () { var foo = 'bar' var fn1 = function () {} var fn2 = function fn2() {} return { fn1: fn1, fn2: fn2 } })() // 调用 module.fn1()
-
IIFE模式+window
(function(window){ var data = 'data' function foo(){ console.log(data) } function bar () { data = 'modified data' console.log(data) } window.module1 = { foo, bar } })(window) // 调用 module1.foo() // data完全做到了私有,外界无法修改data。若要访问data值,需要模块内部设计并暴露相关接口。
2. 规范标准时代
- CommonJS、AMD、CMD、UMD
-
CommonJS
- 概念:CommonJS,一种模块化规范,核心目标是让JS能够用于编写服务器端大型应用,解决全局作用域污染和依赖管理等问题。
- 主要特点:
- 每一个文件都是一个模块,拥有自己的作用域。
- 通过module.exports或exports对外暴露模块的公共接口。
- 通过require()函数同步地加载并执行其他模块,导入其暴露的接口。
- 模块按照代码引入的顺序进行加载。
- 导出模块:一个模块可以通过两种方式导出其功能
- 使用module.exports,模块系统真正导出的对象。可以直接给它赋值一个对象、函数、变量等。推荐
- exports,module.exports的一个引用,相当于let exports = module.exports,只能给exports添加属性,不能直接赋值。
// math.js // 导出一个对象 module.exports = { add: (a, b) => a + b, subtract: (a, b) => a - b, PI: 3.14159 }; // 或者直接导出一个函数 module.exports = function(a, b) { return a + b; }; // 或者导出一个类 module.exports = class Calculator { ... }; // math.js // 正确:给 exports 添加属性 exports.add = (a, b) => a + b; exports.subtract = (a, b) => a - b; exports.PI = 3.14159; // 错误:直接给 exports 赋值会切断它与 module.exports 的联系,导致导出失败 // exports = { add: (a, b) => a + b }; // ❌ 这样导出的对象是空的
- 导入模块:使用require()函数来导入一个模块,require是同步加载的,会返回目标模块的module.exports对象。
// main.js // 导入自定义模块 (需要提供路径) const math = require('./math.js'); // .js 扩展名可省略 // 也可以 const { add, PI } = require('./math'); 使用解构赋值 console.log(math.add(5, 3)); // 输出: 8 console.log(math.PI); // 输出: 3.14159 // 导入Node.js核心模块或npm包 (不需要路径) const fs = require('fs'); // 核心模块 const axios = require('axios'); // 第三方npm包
- 核心特性与机制:同步加载、模块缓存(单例)、函数包装器
-
AMD
- 概念:AMD,一种在浏览器环境中使用的JS模块化规范。为了解决CommonJS同步加载模块在浏览器端不适用的问题。
- 核心语法:define用于定义模块,require用于加载模块。
// 1. 定义模块:define函数用于定义一个模块 // 方法一:定义具名模块 define('moduleName', ['dependency1', 'dependency2'], function(dep1, dep2) { // 模块工厂函数 // 返回模块的值 return { myMethod: function() { // 使用依赖 dep1, dep2 return dep1.something() + dep2.something(); } }; }); // 方法二:定义匿名模块 // 语法: define(dependencies, factoryFunction); define(['jquery', 'lodash'], function($, _) { // 依赖列表中的模块('jquery', 'lodash')会异步加载 // 加载完成后,它们会按顺序作为参数($, _)传入这个工厂函数 // 模块内部的代码 function privateFunc() { // ... } // 返回想要暴露给外部的对象 return { publicMethod: function() { return $.ajax({ /* ... */ }); }, anotherMethod: function() { return _.map(/* ... */); } }; }); // 方法三:定义无依赖模块 define(function() { let count = 0; return { increment: function() { return ++count; }, getCount: function() { return count; } }; });
// 加载模块:require函数用于加载模块并使用它。用于入口点或非模块代码中 // 语法: require(['module1', 'module2'], function(module1, module2) { ... }); // 加载 jQuery 和我的自定义模块 require(['jquery', './my-amd-module'], function($, myModule) { // 这个回调函数会在所有依赖都加载并执行完毕后才运行 // $ 是 'jquery' 模块的导出值 // myModule 是 './my-amd-module' 模块的导出值 // 现在可以安全地使用它们了 $(document).ready(function() { myModule.publicMethod(); }); });
- AMD核心思想:异步加载,提前声明依赖。
- AMD优点:
- 异步加载,不会阻塞浏览器渲染和后续脚本执行,用户体验更好
- 明确的依赖声明,依赖在模块定义之初就声明好,便于工具进行静态分析和优化
- 浏览器中直接使用,纯粹为浏览器环境设计的模块方案,无需打包工具
- 模块并行加载,可以同时加载多个模块,加快整体加载速度
- 支持非AMD库,通过shim配置可以很好地兼容传统的全局变量库
- AMD缺点:
- 语法繁琐,相比ES Modules,define和require的回调函数语法显得臃肿,尤其依赖较多时
- 不是语言原生标准,是社区规范,需要引入额外的库来实现如RequireJS
- 运行时分析,依赖关系是在代码运行时确定的,不如ESM的静态化设计利于编译时优化
-
CMD
- 概念:CMD,一种用于浏览器环境的JS模块化规范。
- 目标:提供一种更接近CommonJS书写风格的异步模块化方案。
- 核心语法:CMD 推崇依赖就近(按需引入):在模块代码需要用到某个依赖时,再随时引入。CMD在加载模块时,可以通过同步的形式require,也可以通过异步的形式require.async。define定义模块。
// CMD: 依赖可以在需要的地方随时引入 define(function(require, exports, module) { // 现在只需要 dep1 const dep1 = require('dep1'); dep1.doSomething(); // 很久以后,在某个条件分支下才需要 dep2 if (true) { // 假设某个条件为真 const dep2 = require('dep2'); // 此时才会去加载并执行 dep2 dep2.doSomething(); } // 另一个分支,不需要 dep3 // if (false) { // const dep3 = require('dep3'); // 这行不会执行,dep3 也就不会被加载 // } });
define(function(require, exports, module) { // 模块代码写在这里 // 1. 使用 require(String) 引入依赖 // 依赖是“就近”的,在需要的地方随时引入 const $ = require('jquery'); // 2. 使用 exports 对外提供接口 exports.sayHello = function() { $('#app').html('<h1>Hello, CMD!</h1>'); }; // 或者使用 module.exports 提供整个接口 // module.exports = { ... }; // 3. 使用 require.async 进行异步加载(可选) if (someCondition) { require.async('./heavy-module', function(heavy) { // 这个回调函数会在模块加载完成后执行 heavy.doSomething(); }); } });
- CMD优点
- 书写自然,风格接近于CommonJS
- 按需加载,依赖就近声明
- 模块定义简单,一个function包裹所有逻辑
- CMD缺点
- 静态分析困难
- 依赖前置与依赖就近的性能争议
- 生态和流行度
-
UMD
- 概念UMD,一段模式代码或一种包装技术。
- 核心目标:让同一个模块文件,既能运行在CommonJS环境如Node.js,也能运行在AMD环境如RequireJS,还能直接被浏览器通过
<script>
标签引入。 - 核心思想:利用立即执行函数根据环境来判断需要的参数类别,如果环境支持AMD,就会AMD定义模块,如果环境支持CommonJS,就用CommonJS导出,如果都不支持,就把模块挂载到全局对象上。
- 语法:
// 1. 在 AMD 环境 (如 RequireJS) 中使用 // 配置路径 require.config({ paths: { 'helper': 'path/to/helper', 'math': 'path/to/math.umd' } }); // 使用模块 require(['math'], function(math) { console.log(math.add(2, 3)); // 输出: 5 }); // 2. 在 CommonJS 环境 (如 Node.js) 中使用 // 假设 helper 是一个已安装的 npm 包 const MyMathLib = require('./math.umd.js'); console.log(MyMathLib.multiply(4, 5)); // 输出: 20 // 3. 在浏览器全局环境中使用 <!-- 先引入依赖 --> <script src="helper.js"></script> <!-- 再引入 UMD 模块 --> <script src="math.umd.js"></script> <script> // 模块被挂载到了 window.MyMathLib console.log(window.MyMathLib.add(5, 7)); // 输出: 12 // 或者直接 console.log(MyMathLib.multiply(3, 3)); // 输出: 9 </script>
- UMD优点
- 通用性,一份代码,多种环境
- 适合库开发
- 无需转换
- UMD缺点
- 代码冗余
- 调试困难
- 逐渐过时
3. ES原生时代
-
概念:ESM,语言层面的官方标准。目标:为JS提供一个统一、跨平台、静态的模块化方案,无论是在浏览器还是Node.js环境中。ES模块的设计思想是尽量静态化,这样能保证在编译时就确定模块之间的依赖关系,每个模块的输入和输出变量也都是确定的。
- 静态化优势:利于tree shaking,对无用代码进行清除,减小代码体积
- 静态化限制:
- 只能在文件顶部引入依赖
- 导出的变量类型受到严格限制
- 变量不允许被重新绑定,引入的模块名只能是字符串常量,即不可以动态确定依赖。
- 静态化限制:
- 静态化优势:利于tree shaking,对无用代码进行清除,减小代码体积
-
核心语法:export导出模块的对外接口;import导入其他模块
- 导出模块export/export default
- export default会导出整体对象结果,不利于通过tree shaking进行分析
- export default导出的结果可以随意命名变量,不利于团队统一管理
// 命名导出,一个模块可以有多个命名导出 // lib.mjs 或 lib.js (在支持ESM的环境下) // 方式一:在声明前直接导出 export const PI = 3.14159; export function add(a, b) { return a + b; } export class Calculator { // ... } // 方式二:在文件末尾统一导出 const PI = 3.14159; function add(a, b) { return a + b; } class Calculator { /* ... */ } export { PI, add, Calculator }; // 导出已定义的变量 export { PI as圆周率, add as相加 }; // 导出时可以使用别名 (as)
// 默认导出,一个模块只能有一个默认导出,通常用于导出模块的主要功能 // my-module.js // 方式一:直接导出匿名函数/类 export default function(a, b) { return a + b; } // 方式二:导出已定义的变量为默认 const myFunction = () => { ... }; export default myFunction; // 方式三:导出对象字面量 export default { version: '1.0', init() { ... } };
- 导入模块import
// 导入命名导出,需要使用与导出时相同的名称或使用as别名 // 导入指定的命名导出 import { PI, add, Calculator } from './lib.js'; // 使用 as 取别名 import { PI as圆周率, add as相加 } from './lib.js'; // 导入整个模块的所有命名导出到一个命名空间对象 import * as mathLib from './lib.js'; console.log(mathLib.PI); // 通过对象属性访问
// 导入默认导出 // 导入默认导出,可以取任何名字 import myAddFunction from './my-module.js'; // 同时导入默认导出和命名导出 import myAdd, { PI } from './my-module.js';
// 仅执行模块,不导入任何内容 import './some-polyfill.js'; // 执行该模块即可
// 动态导入 // import 关键字是静态的,必须在顶层作用域使用。为了支持按需加载,ES2020 引入了动态导入 import(),它返回一个 Promise。 // 静态导入 // import { someModule } from './some-module'; // 动态导入:按需加载,在需要的时候才导入 if (someCondition) { import('./some-module.js') .then(module => { module.doSomething(); }) .catch(err => { console.error('Module loading failed:', err); }); } // 在 async 函数中使用 async function loadModule() { const module = await import('./some-module.js'); module.doSomething(); }
- 特点
- 静态化,编译时解析,依赖关系声明时确定,利于tree shaking
- ESM导出的是值的引用
- 异步加载,不会阻塞浏览器渲染
- 环境支持与使用
<!-- 浏览器中使用 --> <!-- 直接使用 --> <script type="module"> import { functionFromModule } from './my-module.js'; functionFromModule(); </script> <!-- 引入外部模块文件 --> <script type="module" src="app.js"></script>
// Node.js中使用 // 1. 使用.mjs扩展名 // file: module.mjs export const value = 'Hello'; // file: main.mjs import { value } from './module.mjs'; console.log(value); // 2. 在 package.json 中设置 "type": "module": { "name": "my-app", "type": "module", // 所有 .js 文件都被视为ES模块 "main": "index.js" }
- 导出模块export/export default
特性 | ES Modules (ESM) | 传统规范 (CJS/AMD/CMD) |
---|---|---|
性质 | 语言官方标准 | 社区规范 |
语法 | import / export | require() / module.exports 或 define |
加载方式 | 异步(浏览器),静态分析 | 同步(CJS)或异步(AMD/CMD) |
关键优势 | Tree Shaking,静态优化,官方标准 | 简单(CJS),浏览器兼容(AMD/CMD) |
环境 | 浏览器和 Node.js 原生支持 | 需要运行时(如 Node.js)或库(如 RequireJS) |
webpack
1. webpack编译产物
- Webpack 的产出物是一个或多个 JavaScript 打包文件。其核心是将项目中分散的、相互依赖的模块(ESM, CommonJS, AMD 等)打包合并成一个或少数几个文件,并用一个自定义的运行时环境将它们包裹起来,以确保模块间的依赖关系和执行顺序是正确的。
- webpack打包输出的结果就是一个IIFE,通过这个IIFE及webpack_require来支持各种模块化打包方案。
2. webpack工作原理
-
工作流程:
- 首先,初始化,webpack读取配置文件及参数,webpack插件实例化,在webpack事件流上挂载插件钩子
- 接着,编译,根据入口文件开始进行依赖收集,对所有依赖的文件进行编译,这个编译过程依赖loaders,不同类型文件用不同loader解析。编译好的内容解析生成抽象语法树,分析文件依赖关系,将不同模块化语法替换为__webpack_require__,即使用webpack自己的加载器进行模块化实现。整个过程插件会在合适时机执行插件任务。
- 最后,输出结果,将结果打包到相应目录。
- 模块会经历加载loaded、封存sealed、优化optimized、分块chunked、哈希hashed、重新创建restored
-
AST,抽象语法树,以树状形式描述节点信息,方便开发者提取模块文件中的关键信息,然后就知晓开发者到底鞋了什么东西,方便进行分析和扩展。
-
compiler对象,它的实例包含了完整的webpack配置,且全局只有一个compiler实例。当插件被实例化时,就会收到一个compiler对象,通过这个对象可以访问webpack的内部环境。
-
compilation对象,当webpack以开发模式运行时,每当检测到文件变化时,一个新的compilation对象就会被创建。这个对象包含了当前的模块资源、编译生成资源、变化的文件等信息。即所有构建过程中产生的构建数据都会被存储在该对象上,它掌控着构建过程的每一个环节。该对象还提供了很多事件回调供插件做扩展。
-
webpack的构建过程是通过compiler控制流程,通过compilation进行代码解析的。在开发插件时,可以从compiler对象中得到所有与webpack主环境相关的内容,包括事件钩子。
3. webpack loader
-
loader的工作:接收源文件,对源文件进行处理,并返回编译后的文件。
-
执行顺序:
- loader的执行顺序和配置顺序是相反的,即配置的最后一个loader最先执行,第一个loader最后执行。
- 第一个执行的loader接收源文件中的内容作为参数,其他loadeer接收前一个执行的loader返回值作为参数。最后执行的loader会返回最终结果。
-
loader的本质就是函数,一个基于CommonJS规范的函数模块,接收内容,并返回新的内容。
import loaderUtils = require('loader-utils')
module.exports = function(source){
const options = loaderUtils.getOptions(this)
// ....
// return content
this.callback(null, content)
}
// 开发一个loader时,只需要关心输入和输出,但需注意保持其职责的单一性。还要根据开发者配置的options信息进行构建定制化处理,以输出最后的结果。
// 当使用this.callback返回内容时,该loader必须返回undefined,这样webpack知道该loader返回结果在this.callback中,而不在return中。
4. webpack plugin
- webpack事件流机制,在webpack构建的生命周期中,webpack会广播许多事件。在该机制下,开发者注册的各种插件可以根据需要监听与自身相关的事件。插件捕获事件后,可以在合适的时机通过webpack提供的API去改变编译输出结果。
- compiler暴露了和webpack整个生命周期相关的钩子,而且暴露了与模块和依赖相关的、粒度更小的钩子。
- 编写plugin:要清楚当前插件要解决什么问题,根据问题找到相应的钩子事件,在相关事件中进行操作,改变输出结果。
- 一个自定义plugin,就是一个带有apply方法的类
- 定义一个JS的class函数或在函数原型中定义一个以compiler对象为参数的apply方法。
- 在apply方法中通过compiler插入指定的事件钩子,并在钩子回调中获取compilation对象。
- 使用compilation修改webpack打包的内容。
5. webpack rollup
- Rollup,下一代打包方案
- 依赖解析,打包构建
- 仅支持ES Next模块
- 内置支持tree shaking功能
- webpack,目前最流行的打包方案
- 建库用Rollup,其他场景用webpack。非绝对。
项目组织设计
-
管理组织代码的两种主要方式:monorepo、multirepo
- multirepo,分而治之。将应用模块分别在不同的仓库中进行管理。
- monorepo,集中管理,将应用中所有的模块全部放在一个仓库中,不需要单独发包、测试,所有代码都在一个项目中管理,一同部署上线,能够在开发阶段更早地复现bug,暴露问题。如Babel、React都是monorepo。
-
multirepo缺点:
- 开发调试及版本更新效率低下
- 团队技术选型分散,不同库的实现风格可能存在较大差异
- changelog梳理困难,Issues管理混乱
-
monorepo缺点:
- 库体积超大,目录结构复杂度上升。
- 需要使用维护monorepo的工具,学习成本比较高。
-
使用Lerna实现monorepo
-
Lerna,是Babel管理自身项目的开源工具,一个管理多包共存问题的JavaScript项目工具。
-
避免在每个子包(package)下产生重复的 node_modules,从而节省磁盘空间并优化安装速度。主要通过两种方式来实现这一点:Hoisting(提升):默认行为。使用符合 Node.js 解析规则的包管理器:如 npm、yarn、pnpm,并利用其 Workspace 功能。
-
Fixed/Locked模式:开发者执行lerna publish命令后,Lerna会在lerna.json中找到指定的版本号。若这一次发布包含某个项目的更新,则会自动更新版本号。任何一个项目进行大版本升级,其他项目的大版本号也都会更新。
-
Independent模式:各个项目相互独立,开发者需要独立管理多个包的版本更新。即具体到更新每个包的版本,每次发布时,Lerna都会配合Git检查包文件的改动,只发布有改动的包。
-
// 安装依赖
// 在 monorepo 根目录或指定子包目录下,使用 pnpm 安装:
pnpm add vue --filter demo1
// 或者进入子包目录安装:
cd packages/demo1
pnpm add vue
代码规范
- prettier,格式化、规范化代码,使其更加工整。
- ESLint,基于静态分析代码原理找出代码反模式的过程,使得开发者在代码执行前就发现代码错误或者不合理的写法
- linter和prettier
- 格式化规则:max-len、no-mixed-spaces-and-tabs,代码格式方面的规范,prettier阶段会被纠正
- 代码质量规则:no-unused-vars、no-extra-bind、no-implicit-globals、prefer-promise-reject-errors,需要linter保障
- husky是通过git命令的钩子,在git命令进行到某一时段时,可以被交给开发者完成某些特定操作。
- 整个项目运行lint会很慢,所以一般只对更改的文件进行检查,需要用到lint-staged。
// package.json
"scripts": {
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"lint:debug": "eslint --debug src/",
"prettier": "prettier --write src/**/*.js"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"pre-push": "",
}
},
"lint-staged": {
"*.(js|jsx)": ["pnpm run lint:fix", "pnpm run prettier","git add"]
}
// prettier.config.js
// 根目录下创建prettier.config.js文件,并在文件中添加prettier规则
module.exports = {
printWidth: 100,
singleQuote: true,
trailingComma: 'all',
bracketSpacing: true,
jsxBracketSameLine: false,
tabWidth: 2,
semi: true,
}
// .eslintrc(传统配置)
{
"env": {
"browser": true,
"es2021": true
},
"extends": ["eslint:recommended"],
"plugins": ["vue"],
"rules": {
"semi": ["error", "always"]
}
}
// eslint.config.mjs(Flat Config 新格式)
import js from "@eslint/js";
import globals from "globals";
import pluginVue from "eslint-plugin-vue";
import { defineConfig } from "eslint/config";
export default defineConfig([
{
files: ["**/*.{js,mjs,cjs,vue}"],
plugins: { js },
extends: ["js/recommended"],
languageOptions: { globals: globals.browser }
},
pluginVue.configs["flat/essential"],
]);
参考&感谢各路大神
- [前端开发核心知识进阶-侯策]