了解webpack的基本流程和一些重要概念

1 介绍

webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle

四个核心概念

  • 入口(entry)
    构建其内部依赖图的开始
  • 输出(output)
    在哪里输出它所创建的 bundles,以及如何命名这些文件
  • loader
    让 webpack 能够去处理那些非 JavaScript 文件(webpack 自身只理解 JavaScript),将文件从不同的语言(如 TypeScript)转换为 JavaScript,或将内联图像转换为 data URL。
  • 插件(plugins) 插件可以用来处理各种各样的任务,包括:打包优化(tree-shaking)、压缩、重新定义环境中的变量。

2 基本流程

2.1 基本流程

  1. entry-option 初始化option
  2. run 开始编译
  3. make 从entry开始递归的分析依赖,对每个依赖模块进行build
  4. before-resolve - after-resolve 对其中一个模块位置进行解析
  5. build-module 开始构建 (build) 这个module,这里将使用文件对应的loader加载
  6. normal-module-loader 对用loader加载完成的module(是一段js代码)进行编译,用 acorn 编译,生成ast抽象语法树。
  7. program 开始对ast进行遍历,当遇到require等一些调用表达式时,触发call require事件的handler执行,收集依赖,并。如:AMDRequireDependenciesBlockParserPlugin等
  8. seal 所有依赖build完成,下面将开始对chunk进行优化,比如合并,抽取公共模块,加hash
  9. bootstrap 生成启动代码
  10. emit 把各个chunk输出到结果文件

详细事件流可看webpack 源码解析

2.2 内部依赖图

进入入口起点后,webpack 会找出有哪些模块和库是入口起点(直接和间接)依赖的。
如何查找这些require 语句?
用正则?如果require写在注释里也会匹配到;require('a'+'b')类似的表达式正则难以处理。
因此,使用js代码解析工具esprima或者acorn,webpackParser.js用的是acorn。),将JS代码转换成抽象语法树(AST),再对AST进行遍历,找出require表达式,收集依赖,构造依赖图。

js引擎也是使用js代码解析工具构建抽象语法树的,比如JavaScriptCore、V8。过程是:
源代码=>抽象语法树=>字节码
P.S. V8之前是直接转成机器码的,因为内存问题,在2019年又改成了转成字节码。

github上找到的一个webpack伪码parse.js

2.3 模块解析(module resolution)

resolver 是一个库(library),用于帮助找到模块的绝对路径。
resolver 帮助 webpack 找到 bundle 中需要引入的模块代码,这些代码在包含在每个 require 语句中。
当打包模块时,webpack 使用 enhanced-resolve 来解析文件路径(绝对路径/相对路径/模块路径)。

1
2
3
4
5
const resolve = require("enhanced-resolve");

resolve("/some/path/to/folder", "module/dir", (err, result) => {
result; // === "/some/path/node_modules/module/dir/index.js"
});
  • 相对路径
    1
    import '../src/file1'
    在这种情况下,使用 importrequire 的资源文件所在的目录,被认为是上下文目录(context directory)。在 import/require 中给定的相对路径,会拼接此上下文路径(context path),以产生模块的绝对路径。
  • 模块路径
    1
    2
    import 'module';
    import 'module/lib/file';
    模块将在 resolve.modules 中指定的所有目录内搜索。 你可以替换初始模块路径,此替换路径通过使用 resolve.alias 配置选项来创建一个别名。

2 manifest

在使用 webpack 构建的典型应用程序或站点中,有三种主要的代码类型:

  • 你或你的团队编写的源码。
  • 你的源码会依赖的任何第三方的 library 或 “vendor” 代码。
  • webpack 的 runtime 和 manifest,管理所有模块的交互。

使用CommonsChunkPlugin可以分离vender和manifest,以充分利用缓存。

2.1 Runtime

runtime,以及伴随的 manifest 数据,主要是指:在浏览器运行时,webpack 用来连接模块化的应用程序的所有代码。runtime 包含:在模块交互时,连接模块所需的加载和解析逻辑。包括浏览器中的已加载模块的连接,以及懒加载模块的执行逻辑。

实现加载和解析模块,主要是实现 __webpack_require__方法。__webpack_require__可以理解成webpack参考Nodejs实现的require方法,以使用 CommonJS模块。

2.2 Manifest

当编译器(compiler)开始执行、解析和映射应用程序时,它会保留所有模块的详细要点。这个数据集合称为 “Manifest”。

当完成打包并发送到浏览器时,会在运行时通过 Manifest 来解析和加载模块。无论你选择哪种模块语法(es6/CommonJS),那些 import 或 require 语句现在都已经转换为 webpack_require 方法,此方法指向模块标识符(module identifier)。通过使用 manifest 中的数据,runtime 将能够查询模块标识符,检索出背后对应的模块。

比如,在一个SPA应用中,点击一个链接,跳转到另一个路由,你会发现浏览器自动下载这个模块对应的chunk文件。这些文件就是通过使用manifest中的数据得知的。

3 模块热替换(hot module replacement)

3.1 概念

模块热替换(HMR - Hot Module Replacement)功能会在应用程序运行过程中替换、添加或删除模块,而无需重新加载整个页面(区别于live reload)。主要是通过以下几种方式,来显著加快开发速度:

  • 保留在完全重新加载页面时丢失的应用程序状态。
  • 只更新变更内容,以节省宝贵的开发时间。
  • 调整样式更加快速 - 几乎相当于在浏览器调试器中更改样式。

3.2 基本流程


主要过程如下:

  1. webpack-dev-server启动本地服务,和客户端使用websocket实现长连接,客户端请求初始资源。
  2. webpack-dev-server监听代码文件变化,当开发者修改了代码并保存,webpack会重新编译,生成新文件包括:
    • hash值
    • 更新后的 manifest(JSON)。manifest 包括新的编译 hash 和所有的待更新 chunk 目录。
    • 一个或多个更新后的 chunk (JavaScript)。
  3. 服务端通过websocket向客户端推送当前编译的hash戳。
  4. 客户端的websocket监听到有文件改动推送过来的hash戳,会和上一次对比。一致则走缓存,不一致则判断是否支持热更新,如果支持的话发射webpackHotUpdate事件,如果不支持则直接刷新浏览器。
  5. webpack相关模块会监听webpackHotUpdate事件,调用module.hot.check方法。HMR runtime请求Manifest和chunk文件。
  6. HMR runtime调用hotAddUpdateChunk动态更新模块代码,然后调用hotApply方法进行热更新。

    webpack-dev-server是一个小型的Node.js Express服务器,它使用webpack-dev-middleware来服务于webpack的包。在实际操作中,它将在localhost:8080(或其他端口)启动一个express静态资源web服务器,并且以监听模式自动运行webpack,并通过socket.io服务实时监听资源的变化并自动刷新页面(热更新)。
    在开发过程中,可以将 HMR 作为 LiveReload 的替代。webpack-dev-server 支持 hot 模式,在试图重新加载整个页面之前,热模式会尝试使用 HMR 来更新。

4 代码分割

常用的代码分离方法有三种:

  • 入口起点:使用 entry 配置手动地分离代码。
  • 防止重复:使用CommonsChunkPlugin去重和分离 chunk。
    optimization.splitChunks.maxSize配置可以解决某个chunk特别大的问题。

    CommonsChunkPlugin用于避免它们之间重复的依赖关系,但是无法进行进一步的优化。从webpack v4开始,CommonsChunkPlugin被删除,转而使用了optimization.splitChunks。

  • 动态导入:通过模块中的内联函数调用来分离代码。(使用react-loadable 动态加载组件实现组件懒加载)

4.1 dllPlugin和external插件

webpack中DllPluginexternals在本质上其实是解决的同一个问题:避免将某些外部依赖库打包进我们的业务代码,而是在运行时提供这些依赖。

DllPlugin

  • 符合前端模块化的要求
  • webpack配置上稍微复杂一些,需要预打包所需的dll资源,并在构建时配置相应的plugin
  • 使用dll的前提是,这些外部依赖一般不需要发生变更。所以,如果某天发生了变更,那就需要将项目重新构建,比较麻烦。
  • 注意manifest.json命名冲突

external

  • 不太符合前端的模块化思想,所需要的外部库需要在浏览器全局环境下可访问
  • 外部库升级的话,如果兼容之前的API,不需要项目重新构建,只需要更新链接
  • webpack配置上稍微简单些,但是同样需要将所需的外部库打包为所需要的格式,并在运行态下引用(如果module有提供cdn地址可以直接使用)

5 tree shaking

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块系统中的静态结构特性,例如 importexport

新的 webpack 4 正式版本,扩展了这个检测能力,通过 package.json"sideEffects" 属性作为标记,向 compiler 提供提示,表明项目中的哪些文件是 “pure(纯的 ES2015 模块)”,由此可以安全地删除文件中未使用的部分。

要使用tree-shaking,需要做到以下:

  • 使用 ES2015 模块语法(即 importexport)。
  • 确保没有 compiler 将 ES2015 模块语法转换为 CommonJS 模块(这也是流行的 Babel preset 中 @babel/preset-env 的默认行为 - 更多详细信息请查看 文档)。
  • 在项目 package.json 文件中,添加一个 “sideEffects” 属性。
    如果所有代码都不包含副作用,我们就可以简单地将该属性标记为 false。如果有则可以提供一个数组,如antd的package.json

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    {
    // ...
    "sideEffects": [
    "dist/*",
    "es/**/style/*",
    "lib/**/style/*",
    "*.less"
    ],
    // ...
    }

    「副作用」的定义是,在导入时会执行特殊行为的代码,而不是仅仅暴露一个 export 或多个 export。举例说明,例如 polyfill,它影响全局作用域,并且通常不提供 export。

  • 通过将 mode 选项设置为 production,启用 minification(代码压缩) 和 tree shaking。

6 优化策略

webpack可以做到的:

  • 代码压缩(uglify)
  • code splitting(分入口多页应用、splitChunks防止重复、动态导入)
  • tree shaking
  • 将不常更新的模块单独打包(dllPlugin),或者放到cdn(externals)

其他方法:

  • 代码优化:<link>样式文件放在头部,<script>放在底部等
  • 减少请求、合并请求
  • Nginx配置gzip
  • SSR(服务端渲染)

7 参考