前言
前端模块化是一种开发管理规范,前端开发发展到现在,已经有很多成熟的构建工具可以帮助我们完成模块化的开发需求,但我们仍需要深入探究一下,这些模块化构建工具到底帮助我们做了哪些事情,这要我们才能更好的利用它们,从而提高我们的开发效率,本篇我们将以 webpack 为例,进行分析。
webpack 究竟解决了什么问题
如何在前端项目中更高效的管理和维护项目中的每一个资源
-
模块化的演化进程
-
Stage 1 - 文件划分方式
- 好处:提高了代码复用性,代码可抽离,可维护,方便模块间组合分解。
- 弊端:所有 JS 文件共用全局作用域,会有命名冲突,污染全局环境;
没有私有的模块空间,可以在外面任意修改。
// a.js var a = "hello a"; console.log(a); // b.js var a = "hello b"; console.log(a);
Document -
Stage 2 - 命名空间方式
- 好处:解决了命名冲突问题。
- 弊端:模块成员依然可以被修改。
// module-a.js window.moduleA = { var a = 'hello a' console.log(a) } // module-b.js window.moduleB = { var a = 'hello b' console.log(b) }
Document -
Stage 3 - IIFE 依赖参数
- 好处:解决了命名冲突问题,全局作用域的问题,模块依赖
- 弊端:模块加载顺序,文件数量过多
// module-a.js ;(function(){ window.moduleA = { var name = 'module-a'; console.log(name) } })() // module-b.js ;(function(){ window.moduleB = { var name = 'module-b'; console.log(name) } })()
-
-
由模块化产生的规范
-
CommonJS、AMD、 ESModules 规范
// CommonJS 服务端规范(node环境) // lib.js var counter = 3; function incCounter() { counter++; } module.exports = { counter, incCounter, }; // main.js var counter = require("./lib").counter; var incCounter = require("./lib").incCounter; console.log(counter); // 3 incCounter(); console.log(counter); // 3
//AMD规范来源于 require.js // 使用步骤 // 1. index.html中引入(require.js):https://cdn.bootcdn.net/ajax/libs/require.js/2.3.6/require.js(cdn) // 2. script中设置,amd.js 是自己的代码文件 // // 3. 代码示例
Document // amd.js requirejs.config({ baseUrl: './', paths: { app: './app' } }); requirejs(['app/main']); // app/main.js define(function (require) { var messages = require('./messages'); console.log(messages.getHello()); }); // app/messages.js define(function () { return { getHello: function () { return 'Hello World'; } }; });// ESModules 浏览器环境
Document // app.js import { name, age } from './module.js' console.log(name, age); // module.js const name = 'xinmin' const age = 18 export { name, age } -
总结:
我们所使用的 ES Modules 模块系统本身就存在环境兼容问题。尽管现如今主流浏览器的最新版本都支持这一特性,但是目前还无法保证用户的浏览器使用情况。所以我们还需要解决兼容问题。随着前端业务复杂度的增加,开发过程中,模块化是必须的,所以我们需要引入工具来解决模块化所带来的兼容性问题。因此,各类如 webpack、gulp、vite 等打包工具就产生了。
ES Modules 采用的是编译时就会确定模块依赖关系的方式。
CommonJS 的模块规范中,Node 在对 JS 文件进行编译的过程中,会对文件中的内容进行头尾包装,在头部添加
(function(export, require, modules, __filename, __dirname){\nxxxxxx\n})
-
-
更为理想的方式
- 在页面中引入一个 js 入口文件,其余用到的模块通过代码控制,按需加载
- 同时在编码代码的过程中有着相应的约束规范保证所有的开发者实现一致
-
引申出两点需求
- 一个统一的模块化标准规范
- 一个可以自动加载模块的基础库
如何使用 webpack 实现模块化打包
本质上,webpack 是一个用于现代 JavaScript 应用程序的 静态模块打包工具。当 webpack 处理应用程序时,它会在内部从一个或多个入口点构建一个 依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个 bundles,它们均为静态资源,用于展示你的内容。
-
核心概念
入口(entery):指示 webpack 应该使用哪个模块来作为构建内部依赖图的开始,可以配置单入口或者多入口
// 单个入口(简单)写法 const config = { entry: "./path/to/my/entry/file.js", }; // 单个入口,对象写法 const config = { entry: { main: "./path/to/my/entry/file.js", }, }; // 多页面应用 const config = { entry: { pageOne: "./src/pageOne/index.js", pageTwo: "./src/pageTwo/index.js", pageThree: "./src/pageThree/index.js", }, };
输出(output):指定打包输出文件路径与名称
// 基础使用 const config = { output: { filename: 'bundle.js', path: '/home/proj/public/assets' } }; // 多入口起点(使用占位符) const config = { entry: { app: './src/app.js', search: './src/search.js' }, output: { filename: '[name].js', path: __dirname + '/dist' } } // 使用cdn和资源hash output: { path: "/home/proj/cdn/assets/[hash]", publicPath: "http://cdn.example.com/assets/[hash]/" }
Module:模块,在 webpack 里一切皆模块,一个模块对应着一个文件。webpack 会从配置的 Entry 开始递归找出所有依赖的模块。
Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。
loader:loader 用于对模块的源代码进行转换(安装相应处理的 loader)
- 三种使用方式(配置(推荐)、内联、CLI)
// 配置 module.exports = { module: { rules: [ { test: /\.css$/, use: [ { loader: 'style-loader' }, { loader: 'css-loader', options: { modules: true } } ] }, { test: /\.ts$/, use: 'ts-loader' } ] } }; // 内联 import Styles from 'style-loader!css-loader?modules!./styles.css'; // CLI webpack --module-bind jade-loader --module-bind 'css=style-loader!css-loader'
插件(plugin):插件目的在于解决 loader 无法实现的其他事。
const HtmlWebpackPlugin = require("html-webpack-plugin"); //通过 npm 安装 const webpack = require("webpack"); //访问内置的插件 const path = require("path"); const config = { entry: "./path/to/my/entry/file.js", output: { filename: "my-first-webpack.bundle.js", path: path.resolve(__dirname, "dist"), }, module: { rules: [ { test: /\.(js|jsx)$/, use: "babel-loader", }, ], }, plugins: [ new webpack.optimize.UglifyJsPlugin(), new HtmlWebpackPlugin({ template: "./src/index.html" }), ], }; module.exports = config;
模式(mode):根据开发和生产环境加载不同的插件进行处理
-
webpack 的构建流程
webpack 打包的执行流程
- 在 webpack 函数中如传入配置信息,返回 compiler 实例
- 调用 compiler 实例的 run 方法进行编译
插件处理
- 插件是在 complier 创建之后完成挂载的,但是挂在不意味着执行、
- 某些插件是在整个流程的某些时间点上触发的,所以这种情况就要是使用到钩子 tapable
- 插件其实就是一个具有 apply 函数的类
处理入口
- 从配置文件中读取 entry 的值,内部转化为对象进行处理
新增属性
- 整个打包结束之后,会产生出很多的内容,这些内容需要存储
初始化编译
- 定位入口文件的绝对路径
- 统一路径分隔符
- 调用自己的方法来实现编译
loader 参与打包工作
- 读取被打包模块的源文件
- 使用 loader 来处理源文件(依赖的模块)
- loader 就是一个函数,接受原始数据,处理之后返回给 webpack 继续使用
- 以降序的方式的方式来执行 loader
模块编译实现(单模块)
- webpack 找到 a.js 模块之后,就是对它进行处理,处理之后的内容就是一个键值对
- 键:
./src/a.js
,而值就是 a.js 的源代码 -
- 获取被打包模块的模块 id
ast 语法树,实现 ast 遍历(webpack 中解析使用 acorn)
- @babel/parser 解析器,将源代码转化成 ast 语法树
- @babel/traverse 实现 ast 语法树遍历
- @babel/generator 将处理后 ast 转换成可执行的源代码
- @babel/core 和 @babel/preset-env 将 AST 语法树转换为浏览器可执行代码
-
实现一个简单的 loader
const marked = require("marked"); module.exports = (source) => { const html = marked.parse(source); const code = `module.exports = ${JSON.stringify(html)}`; // const code = `exports =${JSON.stringify(html)}` // const code = `export default = ${JSON.stringify(html)}` return code; };
-
实现一个简单的 plugin
// 去除开发环境打包中多余的注释 class RemoveCommentsPlugin { apply(compiler) { compiler.hooks.emit.tap("RemoveCommentsPlugin", (compilation) => { // compilation 可以理解为此次打包的上下文 for (const name in compilation.assets) { console.log("compilation", compilation.assets[name].source()); if (name.endsWith("js")) { const contents = compilation.assets[name].source(); const noComments = contents.replace(/\/\*{2,}\/\s?/g, ""); compilation.assets[name] = { source: () => noComments, size: () => noComments.length, }; } } }); } } module.exports = RemoveCommentsPlugin;
-
实现一个 min-pack
const parser = require("@babel/parser"); const traverse = require("@babel/traverse").default; const babel = require("@babel/core"); const { SyncHook } = require("tapable"); const fs = require("fs"); const path = require("path"); class Compiler { constructor(options) { this.options = options; // this.entries = new Set(); // 保存打包过程中的入口信息 webpack4中是数组 this.modules = []; // 保存打包过程中出现的module信息 // this.chunks = new Set(); // 保存代码块信息 // this.files = new Set(); // 保存所有产出文件的名称 this.assets = {}; // 资源清单 this.context = this.options.context || process.cwd(); this.hooks = { entryInit: new SyncHook(["compilation"]), beforeCompile: new SyncHook(), afterCompile: new SyncHook(), afterPlugins: new SyncHook(), emit: new SyncHook(), afterEmit: new SyncHook(), }; } // 构建启动 run() { // 执行 plugins // this.hooks.entryInit.call(this.assets); /// 1. 确定入口信息 let entry = {}; if (typeof this.options.entry === "string") { entry.main = this.options.entry; } else { entry = this.options.entry; } /// 2. 确定入口文件的绝对路径 for (let entryName in entry) { // TODO: 调用自定义的方法来实现具体的编译过程,得到结果 const entryModule = this.build(entry[entryName]); // 添加到module中 this.modules.push(entryModule); } /// 3. 递归调用获取所有依赖内容 this.modules.forEach(({ dependecies }) => { if (Object.keys(dependecies).length > 0) { Object.keys(dependecies).forEach((deps) => { this.modules.push(this.build(dependecies[deps])); }); } }); /// 4. 生成依赖关系图 const dependencyGraph = this.modules.reduce( (graph, item) => ({ ...graph, [item.filename]: { dependecies: item.dependecies, code: item.code, }, }), {} ); // console.log('dependencyGraph', dependencyGraph); this.assets = dependencyGraph; // console.log('this.assets', this.assets) // 执行 plugins this.hooks.entryInit.call(this.assets); /// 5. 生成 bundle this.generate(dependencyGraph); } // 获取ast getAst(filePath) { let code = fs.readFileSync(filePath, "utf-8"); let loaders = []; // console.log('filePath', filePath); const rules = this.options.module?.rules; for (let i = 0; i < rules?.length; i++) { // 从众多的 rules 当中找到 匹配的文件的配置 if (rules[i].test.test(filePath)) { loaders = [...loaders, ...rules[i].use]; } } //* 调用loader for (let i = loaders.length - 1; i >= 0; i--) { let abPath = loaders?.[i]; if (loaders[i]?.includes("./")) { abPath = path.resolve(this.context, loaders[i]); } code = require(abPath)(code); } const ast = parser.parse(code, { sourceType: "module" }); return ast; } // 获取依赖关系 getDependecies(ast, fileName) { const dependencies = {}; traverse(ast, { CallExpression: (nodePath) => { const dirname = path.dirname(fileName); const node = nodePath.node; // 在ast中找到了require if (node.callee.name === "require") { const rPath = node.arguments[0].value; // 获取相对路径 const aPath = path.resolve(dirname, rPath); dependencies[rPath] = aPath; } }, // 在ast中找到import ImportDeclaration: (nodePath) => { const dirname = path.dirname(fileName); const rPath = nodePath.node.source.value; const aPath = path.resolve(dirname, rPath); dependencies[rPath] = aPath; }, }); return dependencies; } // 编译ast getTranslateCode(ast) { const { code } = babel.transformFromAst(ast, null, { presets: ["@babel/preset-env"], }); return code; } // 编译 build(filename) { const ast = this.getAst(filename); const dependecies = this.getDependecies(ast, filename); const code = this.getTranslateCode(ast); return { filename, dependecies, code, }; } // 生成 generate(code) { const filePath = path.join(this.options.output.path, "main.js"); const bundle = `(function(graph){ function require(moduleId){ function localRequire(relativePath){ return require(graph[moduleId].dependecies[relativePath]) } var exports = {}; (function(require,exports,code){ eval(code) })(localRequire,exports,graph[moduleId]?.code); return exports; } require('${this.options.entry}') })(${JSON.stringify(code)})`; // console.log('filePath', filePath, bundle); fs.writeFileSync(filePath, bundle, "utf-8"); // try { // fs.writeFileSync(filePath, bundle, "utf-8") // } catch (e) { // fs.mkdirSync(path.dirname(filePath)) // fs.writeFileSync(filePath, bundle, "utf-8") // } } } module.exports = Compiler;
-
配合 min-pack 实现 css-loader
module.exports = (source) => { // console.log('source', source); let str = ` let style = document.createElement("style"); style.innerHTML = ${JSON.stringify(source)}; document.head.appendChild(style); `; return str; };
-
配合 min-pack 实现 DemoPlugin
class DemoPlugin { apply(compiler) { compiler.hooks.entryInit.tap("DemoPlugin", (compilation) => { if ( Array.isArray(Object.keys(compilation)) && Object.keys(compilation).length > 0 ) { for (let k in compilation) { if (k.endsWith("b.js")) { compilation[k].code = compilation[k].code + `console.log('min-webpack v1.1')`; console.log(" compilation[k].code"); } } } // compilation 可以理解为此次打包的上下文 return compilation; }); } } module.exports = DemoPlugin;
webpack 的性能优化
优化方向:构建性能、传输性能、运行性能
-
构建性能
-
优化开发体验
- 自动更新:watch,webpack-dev-server,webpack-dev-middleware
- 热更新:@pmmmwh/react-refresh-webpack-plugin react-refresh
-
加快编译速度
- 使用最新 node,npm,webpack 版本,有助于提升性能
- cache:提升二次构建速度,缓存 webpack 模版和 chunk(webpack5)
- 减少非必要 loader、plugins 的使用,都会增加编译时间
- 使用 loader 时,配置 rule.exclude:排除模块范围,减少 loader 的应用范围
- 使用 webpack 资源模块代替原来的 assets loader(如:file-loader/url-loader)(webpack5)
- 优化 resolve 配置(配置别名,根据项目中的文件类型定义 extensions,加快解析速度。(如:resolve: { extensions: ['.tsx', '.ts', '.js'] }
- 多进程(如 babel-loader 构建时间较长,使用 thread-loader 可将 loader 放在独立的 work 池中运行,仅对非常耗时的 loader 使用)
- 其他:区分环境([fullhash]/[chunkhash]/[contenthash])devtool 设置
-
-
传输性能
-
减小打包体积
- js 压缩(webpack5 开箱即用,默认开启多进程与缓存:terser-webpack- plugin)
- css 压缩( optimize-css-assets-webpack-plugin)
- splitChunks
3.1 新的 chunk 可以被共享,或者模块来自于 node_modules 文件夹
3.2 新的 chunk 体积大于 20kb(在进行 min+gz 之前的体积)
3.3 当按需加载 chunks 时,并行请求的最大数量小于或等于 30
当加载初始化页面时,并发请求的最大数量小于或等于 30 - css 文件分离(mini-css-extract-plugin)
Tree Shaking(摇树)通过配置:sideEffects,只能清除无副作用的引用,有副作用需要通过优化引用的方式。(css Tree Shaking:purgecss-webpack-plugin)
- CDN 加速:将字体,图片等静态资源上传 CDN
-
-
运行性能
-
加快加载速度
- import 动态导入
- 浏览器缓存,创建 hash id
- moduleIds: "deterministic", 公共包 hash 不因为新的依赖而改变
- 静态资源使用 cdn 缓存
-
-
总结
小型项目,添加过多优化配置,反而会因为添加额外的 loader 与 plugin 增加构建时间
构建阶段,使用 cache,可大大加快二次构建速度
减少打包体积,作用最大的是压缩代码,分离重复代码,Tree Shaking 作用也比较大
加载速度:按需加载,浏览器缓存,CDN 缓存效果都不错