前端工程化_知识点精讲

JavaScript/前端
356
0
0
2023-01-17
❝浮躁背后的本质是自卑感 ❞

大家好,我是「柒八九」

今天,我们继续「前端面试」的知识点。我们来谈谈关于「前端工程化」的相关知识点和具体的算法。

该系列的文章,大部分都是前面文章的知识点汇总,如果想具体了解相关内容,请移步相关系列,进行探讨。

如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。

好了,天不早了,干点正事哇。

img

你能所学到的知识点

  1. 常见脚手架
  2. Source Map 「推荐阅读指数」 ⭐️⭐️⭐️⭐️
  3. Webpack 打包过程 「推荐阅读指数」 ⭐️⭐️⭐️
  4. 微前端
  5. Webpack Loader vs Plugin 「推荐阅读指数」 ⭐️⭐️⭐️⭐️
  6. Webpack 生命周期
  7. Webpack「编译阶段」提效 「推荐阅读指数」 ⭐️⭐️⭐️⭐️⭐️
  8. Webpack「打包阶段」提效 「推荐阅读指数」 ⭐️⭐️⭐️⭐️⭐️
  9. Webpack 「缓存优化」 「推荐阅读指数」 ⭐️⭐️⭐️⭐️⭐️

概念介绍

脚手架

❝脚手架作为一种创建项目「初始文件」的工具被广泛地应用于「新项目」或者「迭代初始阶段」

使用工具替代人工操作能够避免人为失误引起的低级错误,同时结合整体前端工程化方案,快速生成功能「模块配置」「自动安装依赖」等,降低了时间成本。

常见脚手架工具

现在主流的前端脚手架工具有两类:

名称

模板框架

多选项生产

支持自定义模板

特点

Create-React-App

React

React 官方维护

Vue CLI

Vue

Vue官方维护

  • 「CRA」: Facebook 官方提供的 React 开发工具集,
  1. 包含两个基础包
  2. create-react-app 用于选择脚手架「创建项目」
  3. react-scripts 则作为所创建项目中的「运行时依赖包」,提供了封装后的项目「启动、编译、测试」等基础工具
  4. 自定义配置能力
  5. react-app-rewired:利用config-overrides.js 文件来对 webpack 配置进行扩展
  6. customize-cra:利用react-app-rewired的配置文件config-overrides.jswebpack配置进行修改
  • 「Vue CLI」: Vue CLI 由 Vue.js 官方维护,其定位是 Vue.js 快速开发的完整系统。完整的 Vue CLI 由三部分组成
  1. 作为全局命令的 @vue/cli
  2. 作为项目内集成工具的 @vue/cli-service
  3. 作为功能插件系统的 @vue/cli-plugin

脚手架模板

在实际开发中,我们可以通过创建脚手架对应的模板对项目进行「定制化处理」

定制化模板可以「弥补」官方提供基础工具集不满足特定需求的场景。

定制化有如下优点(但有不仅限这些优点)

  • 为项目引入「新的」通用特性
  • 针对构建环节的 webpack 配置优化,来提升开发环境的效率和生产环境的性能等
  • 定制符合「团队内部规范」的代码检测规则配置
  • 定制单元测试等「辅助工具模块」的配置项
  • 定制符合团队内部规范的「目录结构与通用业务模块」
  • 业务组件库
  • 辅助工具类
  • 页面模板

我们简单的为CRA配置一个最简单的模板。

为 CRA 创建自定义模板

作为一个最简化的 CRA 模板,模板中包含如下必要文件

  • README.md:用于在 npm 仓库中显示的「模板说明」
  • package.json:用于描述模板本身的「元信息」, 例如名称、运行脚本、依赖包名和版本等
  • template.json:用于描述基于模板创建的项目中的 package.json 信息
  • template 目录:用于「复制」到创建后的项目中,gitignore 在复制后重命名为 .gitignore,「public/index.html和src/index 为运行 react-scripts 的必要文件」

对应的目录结构如下:

cra-template-[template-name]/
  README.md (for npm)
  template.json
  package.json
  template/
    README.md (for projects created from this template)
    gitignore
    public/
      index.html
    src/
      index.js (or index.tsx)

在使用时,还是需要将模板通过 npm link 命令「映射」到全局依赖中或发布到 npm 仓库中。

然后执行创建项目的命令

 npx create-react-app [app-name] --template [template-name]
❝脚手架的「功能和本质」 功能是「创建项目初始文件」,本质是「方案的封装」

SourceMap

Source Map:「映射」转换后的代码与源代码之间的关系

一段转换后的代码,通过转换过程中生成的 Source Map 文件就可以「逆向解析」得到对应的源代码。

Source Map是一个 JSON 格式的文件,这个 JSON 里面记录的就是「转换后和转换前」代码之间的映射关系。

Source Map 的基本原理

在编译器(Babel/SWC)编译处理的过程中,在生成产物代码的同时,也生成产物代码中被转换的部分与源代码中相应部分的「映射关系表」

有了完整的映射表,就可以通过 Chrome 控制台中的"Enable Javascript source map"来实现调试时的显示与定位源代码功能。

Source map的格式

{
    version : 3,
    file: "out.js",
    sourceRoot : "",
    sources: ["foo.js", "bar.js"],
    names: ["src", "maps", "are", "fun"],
    mappings: "AAgBC,SAAQ,CAAEA"
}

这里额外对mappings字段做一个介绍

❝这是一个很长的字符串,它分成「三层」
  1. 第一层是「行对应」,以分号(;)表示,「每个分号对应转换后源码的一行」
  2. 第二层是「位置对应」,以逗号(,)表示,「每个逗号对应转换后源码的一个位置」
  3. 第三层是「位置转换」,以VLQ编码表示,「代表该位置对应的转换前的源码位置」

mappings:"AAAAA,BBBBB;CCCCC":转换后的源码分成「两行」,第一行有两个位置,第二行有一个位置。

❝一般我们会在「转换后的代码中」通过「添加一行注释」的方式来去「引入 Source Map 文件」

对于同一个源文件,根据不同的目标,可以生成不同效果的 Source Map。在开发环境和生产环境下,对于 source map 功能的期望也有所不同。

  • 在开发环境中,要求构建速度快/质量高/便于提升开发效率,而不关注生成文件的大小和访问方式
  • 在生产环境中,需要考虑是否需要提供线上Source Map/生成的文件大小/访问方式是否会对页面性能造成影响,最后才考虑质量和构建速度

由于,现在在打包领域,Webpack还是一个绕不过去的大山,所以,在了解到基础的知识点后,需要将知识配合实际项目进行分析和学习。

Source Map 处理插件

根据不同规则,实际上 Webpack 是从三种「插件」中选择其一作为 source map 的处理插件。

  1. EvalDevToolModulePlugin
  2. EvalSourceMapDevToolPlugin
  3. SourceMapDevToolPlugin
❝插件中有eval字段的,模块产物是通过eval()封装的,只有SourceMapDevToolPlugin(AKA:SMDP)生成.map文件 ❞

不同预设的效果总结

分别从「质量」/「构建速度」/「包大小和生成方式」三个角度来分析

质量

生成的 source map 的质量分为 「5 个级别」,对应的「调试便捷性」依次降低。

img

构建速度

不同环境下关注的速度也不同

  • 在开发环境下
  • 一直开着 devServer「再次构建的速度」对我们的效率影响远大于初次构建的速度
  • 在生产环境下
  • 通常不会开启再次构建,因此相比再次构建,「初次构建的速度更值得关注」

包大小和生成方式

「开发环境」下我们并不需要关注这些因素。

开发环境下 Source Map 推荐预设

利用EvalSourceMapDevToolPlugin对开发环境提速。

在开发阶段,调试的是开发的业务代码部分,而非依赖的第三方模块部分,所以在生成 source map 的时候如果可以「排除第三方模块」的部分,而只生成业务代码的 source map,无疑能进一步提升构建的速度。

webpack.config.js 

  ... 

  //devtool: 'eval-source-map', 

  devtool: false, 

  plugins: [ 

    new webpack.EvalSourceMapDevToolPlugin({ 

      exclude: /node_modules/, 

      module: true, 

      columns: false 

    }) 

  ], 

  ...

devtool 设为 false,就是丢弃webpack或者CRA的默认配置,而是直接使用 EvalSourceMapDevToolPlugin,通过传入 module: truecolumn:false,达到和预设 eval-cheap-module-source-map 一样的质量。但是,我们传入 exclude 参数,「排除第三方依赖包的 source map 生成」。进一步提升了构建的速度。

捕捉Source Map文件& 反编译Source Map

❝在控制台的「网络面板」中通常看不到 source map 文件的请求,其原因是出于安全考虑 Chrome 「隐藏」source map 的请求 ❞

我们可以通过一些抓包工具进行文件的抓取

  • Chrome自带的 chrome://net-export/
  • whistle
  • WireShark

既然能通过抓包工具进行Source Map文件的捕获,那么在前面我们就说了,「Source Map:映射转换后的代码与源代码之间的关系」,那么我们反其道而行,我们是不是可以通过Source Map来反向推出源代码。

这里推荐一个库shuji。可以通过x.map (x可以是js也可以是css),反编译出源代码。

Webpack 打包过程

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

打包流程

「一切」都从entry对象开始 ❞
「模块是文件的升级版」。 模块,一旦创建和构建,除了「源代码」,还包含很多有意义的信息,如:
  • 使用的「加载器」
  • 它的「依赖关系」
  • 它的「出口」(如果有的话)
  • 它的「哈希值」

「同时entry对象中的每一项都可以被认为是模块树中的根模块」「模块树」是因为根模块可能需要一些其他的模块,这些模块可能需要其他的模块,等等。「所有这些模块树都被储存在 ModuleGraph中」

「webpack是建立在很多插件之上的」

「webpack的可扩展性是通过hook实现的」。例如,你可以在 ModuleGraph 建立后,当一个新的资源asset被生成时,在模块即将被「建立前」(运行加载器和解析源代码),添加自定义逻辑。大多数时候,hook「根据它们的目的分组的」,对于任何定义好的目的,都有一个插件plugin。

  • 对于entry对象中的每一项,都会有一个 EntryPlugin 实例,其中创建了一个 EntryDependency
  • 这个 EntryDependency 「保存了模块的请求」(即文件的路径),并且通过映射到一个模块工厂,即 NormalModuleFactory「提供了一种使该请求有用的方法」
  • 模块工厂ModuleFactory知道如何「从一个文件路径中创建对webpack有用的实体」
  • 依赖关系dependency对创建一个模块至关重要,因为它拥有重要的信息,比如模块的请求和如何处理该请求。依赖关系有几种类型,并不是所有的依赖关系都对创建一个新模块有用。
  • 从每个 EntryPlugin 实例,在新创建的 EntryDependency 的帮助下,将「创建一个模块树」。模块树是建立在模块和它们的依赖关系之上的,这些模块也可以有依赖关系。

Module

「模块是一个文件的升级版」。 一个模块,一旦创建和构建,除了「源代码」,还包含很多有意义的信息,如:
  • 使用的「加载器」
  • 它的「依赖关系」
  • 它的「出口」(如果有的话)
  • 它的「哈希值」

Chunk

「一个Chunk封装了一个或多个模块」 一般情况下,entry文件(一个entry文件=entry对象的一个项目)的数量与所产生的Chunk的数量「成正比」
  • 因为entry对象可能只有一个项目,而结果块的数量可能大于1。的确,对于每一个entry项目,在dist目录中都会有一个相应的chunk

❝但也可能是「隐式创建」其他的chunk,例如在使用import()函数时。但不管它是如何被创建的,每个chunkdist目录下「都会有一个对应的文件」。 ❞

ChunkGroup

「一个ChunkGroup包含一个或多个chunks」 一个 ChunkGroup 可以是另一个 ChunkGroup 的父或子。
  • 例如,当使用「动态导入」时,「每使用一个import()函数,就会有一个ChunkGroup被创建」,它的父级是一个「现有的」 ChunkGroup,即包括使用import()函数的文件(即模块)的那个。

微前端

「微前端」是一套用于「组织大型前端应用的指导规范」。是受后端「微服务」启发而发展而来。

微前端试图解决什么问题?

  • 「独立的可构建的前端资源」允许团队拥有属于自己的「构建流程」。这避免了冗长的构建时间,并防止个别团队被其他成员的误操作而影响项目构建。
  • 「独立的可部署的前端资源」允许解耦的这些团队定期将他们的代码「独自」发布到生产环境,而不需要复杂的「部署顺序」。 这也意味着当bug出现和事故发生时,代码可以自动回滚,而不会干扰到其他团队的项目开发。
  • 「清晰的所有权」在大型项目上是必要的。在大型项目中,如果某个功能不被维护,那几乎就像它不存在一样。因为没有人负责保持它的更新,它往往会变得难以维护。

微前端的潜在问题

  1. 潜在的性能隐患
  • 在团队成员庞杂和项目应用比较复杂的情况下,让团队自由地选择他们想使用的技术栈,需要有一些重要的权衡。
  • 用户下载和运行解决各种「常见问题」的代码,如样式设计、获取和改变数据、日期格式等。「以不同的方式,用不同的工具和框架,重复多次」。这是个很大的开销。
  1. 困难的依赖性管理
  • Tree-Shaking -有了依赖关系后,可以通过依赖分析,去掉一些没用的代码
  • Code-Splitting - 这些模块根据功能和类型拆分到不同的分组(chunk)里,然后生成不同的文件,然后将变更频繁和几乎不动的模块划分到不同的chunk,并封装到特定文件中,针对几乎不会变更的资源和模块,则可以利用浏览器缓存进行资源的优化处理
  • 即使通过同一个库来解决「功能共享」的问题,但是,在规模比较大的情况下,总是存在着该库的「不同版本」需要保持更新的问题。特别是对于像高优先级的更新。
  • 解决方案,使用同一个打包工具,能够做到优化处理。
  • Lazy-Load:生成的代码,拥有自己的runtime,这样可以实现模块的 lazy load,也就是把Code-Splitting分出来的 chunk,在运行时「动态加载」
  1. 服务的粒度划分

Webpack Loader vs Plugin

  • loader「文件加载器」,能够加载资源文件,并对这些文件进行一些处理,诸如编译、压缩等,最终一起打包到指定的文件中
  • plugin 赋予了 webpack 各种灵活的功能,例如打包优化、资源管理、环境变量注入等,目的是「解决 loader 无法实现的其他事」

img

两者在运行时机上的区别

  • loader 运行在「打包文件之前」
  • plugins 在整个编译周期都起作用

对于 loader,实质是一个「转换器」,将A文件进行编译形成B文件,操作的是文件,比如将A.scssA.less转变为B.css「单纯的文件转换」过程。

Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过Webpack提供的 API改变输出结果。

Webpack 生命周期

Webpack 工作流程中最核心的两个模块

  1. Compiler
  2. Compilation

它们都扩展自 Tapable 类,用于实现工作流程中的生命周期划分,以便在不同的生命周期节点上注册和调用插件,其中所暴露出来的生命周期节点称为Hook(俗称钩子)。

Compiler Hooks

「构建器实例」的生命周期可以分为 3 个阶段

  1. 初始化阶段
  2. 构建过程阶段
  3. 产物生成阶段

初始化阶段

  1. environment
  • 在创建完 compiler 实例且执行了配置内定义的插件的 apply 方法后触发
  1. afterEnvironment
  • 在创建完 compiler 实例且执行了配置内定义的插件的 apply 方法后触发
  1. entryOption
  • 执行 EntryOptions 插件
  1. afterPlugins
  2. afterResolvers
  • 解析了 resolver 配置后触发

构建过程阶段

  1. normalModuleFactory
  • 在两类模块工厂创建后触发
  1. contextModuleFactory
  • 在两类模块工厂创建后触发
  1. beforeRun
  2. run
  3. beforeCompile
  4. compile
  5. thisCompilation
  6. make
  • 「最耗时」
  • 会执行模块编译到优化的完整过程

产物生成阶段

  1. shouldEmit、emit、assetEmitted、afterEmit
  • 在构建完成后,处理产物的过程中触发
  1. failed、done
  • 在达到最终结果状态时触发

Compilation Hooks

「构建过程实例」的生命周期分为两个阶段:

  1. 构建阶段
  2. 优化阶段

Webpack编译阶段提效

❝真正影响整个构建效率的是 Compilation 实例的处理过程
  1. 「编译模块」
  2. 「优化处理」

「提升编译阶段的构建效率」,大致可以分为三个方向

  1. 「减少」执行编译的模块
  2. 提升「单个模块」构建的速度
  3. 「并行构建」以提升总体效率

优化前的准备工作

准备基于时间的分析工具 - SMP

需要一类插件,来帮助我们统计项目构建过程中在编译阶段的耗时情况

speed-measure-webpack-plugin

const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");

const smp = new SpeedMeasurePlugin();

const webpackConfig = smp.wrap({
  plugins: [new MyPlugin(), new MyOtherPlugin()],
});

准备基于产物内容的分析工具 - WBA

找出对产物包体积影响最大的包的构成,从而找到那些冗余的、可以被优化的依赖项。不仅能减小最后的包体积大小,也能提升构建模块时的效率webpack-bundle-analyzer

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}
❝编译模块阶段所耗的时间是从单个入口点开始,编译每个模块的时间的总和 ❞

减少执行编译的模块(4个)

  1. IgnorePlugin (国际化包)
  2. 按需引入类库模块 (工具类库)
  3. DllPlugin
  4. Externals

IgnorePlugin

有的依赖包,除了项目所需的模块内容外,还会附带一些多余的模块

img

典型的例子是 moment 这个包,一般情况下在构建时会自动引入其 locale 目录下的多国语言包

Webpack 提供的 IgnorePlugin ,即可在「构建模块时」直接剔除那些需要被排除的模块,从而提升构建模块的速度,并减少产物体积。

new webpack.IgnorePlugin({
  resourceRegExp: /^\.\/locale$/,
  contextRegExp: /moment$/,
});
  1. resourceRegExp
  • 指定需要剔除的文件(夹)
  1. contextRegExp (可选)
  • 特定目录
  • 任何以 'moment' 结尾的目录中匹配 './locale' 的任何 require 语句都将被忽略

除了 moment 包以外,其他一些带有「国际化模块」的依赖包,都可以应用这一优化方式。

按需引入类库模块

「减少执行模块的方式是按需引入」,一般适用于「工具类库」性质的依赖包的优化

img

典型例子是 lodash 依赖包

优化处理

  1. 定向引入
  • 效果最佳的方式是在「导入声明时只导入依赖包内的特定模块」
  1. 使用插件
  • 适用于antd,antd-mobil,lodash
  • babel-plugin-lodash
  • babel-plugin-import
{
  "plugins": [["import",{
    "libraryName": "lodash",
    "libraryDirectory": "",
    "camel2DashComponentName": false,  // default: true
    }]]
}

注意点

Tree Shaking,这一特性也能减少产物包的体积,但是 Tree Shaking 需要相应导入的依赖包使用 ES6 模块化,而 lodash 还是基于 CommonJS ,需要替换为 lodash-es 才能生效

Tree Shaking 是在优化阶段生效,Tree Shaking 并不能减少模块编译阶段的构建时间。

DllPlugin

它的核心思想是将项目依赖的框架等模块「单独构建打包」,与普通构建流程区分开。

事先把常用但又构建时间长的代码提前打包好(例如 reactreact-dom),取个名字叫 dll。后面再打包的时候就跳过原来的未打包代码,直接用 dll。这样一来,构建时间就会缩短,提高 webpack 打包速度。

两个配置文件

webpack.dll.config.js

module.exports = {
  entry: {
    vendor: ['react', 'react-dom'],
  },
  output: {
    filename: '[name].dll.js',
    path: path.join(__dirname, 'dll'),
    publicPath: '/dll',
    library: '[name]_dll',
  },
  plugins: [
    new webpack.DllPlugin({
      context: __dirname,
      name: '[name]_dll',
      path: path.join(__dirname, 'dll' + '/[name]_manifest.json'),
    }),
  ],
}

new webpack.DllPlugin- 生成manifest.json文件,供DllReferencePlugin 指向依赖模块位置 - 将公共模块 react/react-dom 抽离到项目中dll文件下

webpack.app.config.js

  plugins: [
    new webpack.DllReferencePlugin({
      context: __dirname,
      manifest: require('./dll/vendor_manifest.json'),
    }),
  ],

new webpack.DllReferencePlugin

  • 引用 manifest.json文件,寻找依赖模块
❝webpack 4 有着比 dll 更好的打包性能,所以在最新版的cra中已经将dll剔除。 ❞

Externals

Webpack 配置中的 externalsDllPlugin 解决的是同一类问题。「将依赖的框架等模块从构建过程中移除」

ExternalsDllPlugin 区别

  1. 配置方面
  • externals 更简单
  • DllPlugin 需要独立的配置文件
  1. DllPlugin 包含了依赖包的独立构建流程,而 externals 配置中不包含依赖框架的生成方式,通常使用已传入 CDN 的依赖包
  2. externals 配置的依赖包需要单独指定依赖模块的加载方式:全局对象、CommonJSAMD
  3. 在引用依赖包的子模块时,DllPlugin 无须更改,而 externals 则会将子模块打入项目包中

使用范例

module.exports = {
  //... 
  externals: [
    {
      // String 
      react: 'react',
      // Object 
      lodash: {
        commonjs: 'lodash',
        amd: 'lodash',
        root: '_', // indicates global variable
      },
      // [string] 
      subtract: ['./math', 'subtract'],
    },
    // Function 
    function ({ context, request }, callback) {
      if (/^yourregex$/.test(request)) {
        return callback(null, 'commonjs ' + request);
      }
      callback();
    },
    // Regex 
    /^(jquery|\$)$/i,
  ],
};

提升单个模块构建的速度

在保持构建模块数量不变的情况下,提升单个模块构建的速度。

常用的方式有

  1. include/exclude
  2. noParse
  3. Source Map
  4. TypeScript 编译优化
  5. Resolve

通过减少构建「单个模块」时的一些处理逻辑来提升速度

include/exclude

Webpack -loader配置中的 include/exclude,是常用的优化特定模块构建速度的方式之一

  • include 的用途是只对符合条件的模块使用指定 Loader 进行转换处理
  • exclude 则相反,不对特定条件的模块使用该 Loader

例如不使用 babel-loader 处理 node_modules 中的模块 使用范例

module.exports = {
   ......
   module: {
    rules: [
      {
        test: /\.js$/,
        include: /src/ 
        exclude: /node_modules/,
        use: ['babel-loader'],
      },
    ],
  },
}

「注意点」

  • 通过 include/exclude 排除的模块,并非不进行编译,而是使用 Webpack 「默认的 js 模块编译器进行编译」
  • 在一个 loader 中的 includeexclude 配置存在冲突的情况下,优先使用 exclude 的配置,而忽略冲突的 include 部分的配置

noParse

Webpack 配置中的 module.noParse 则是在 include/exclude 的基础上,进一步省略了使用默认 js 模块编译器进行编译的时间

使用范例

module.exports = {
   ......
   module: {
    noParse: /jquery|lodash/,
    rules: [
      {
        test: /\.js$/,
        use: ['babel-loader'],
      },
    ],
  },
}

Source Map

对于生产环境的代码构建而言,会根据项目实际情况判断是否开启 Source Map

在开启 Source Map 的情况下,「优先选择与源文件分离的类型」 --例如 "source-map"

TypeScript 编译优化

Webpack 中编译 TS 有两种方式

  1. 使用 ts-loader
  2. 使用 babel-loader

在使用 ts-loader 时,由于 ts-loader 默认在「编译前进行类型检查,因此编译时间往往比较慢」

通过加上配置项 transpileOnly: true,可以在编译时忽略类型检查

module.exports = {
   ...... 
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: {
          loader: 'ts-loader',
          options: {
            transpileOnly: true,
          },
        },
      },
    ],
  },
}

babel-loader 则需要单独安装 @babel/preset-typescript 来支持编译 TS,配合 ForkTsCheckerWebpackPlugin 使用类型检查功能

module.exports = {
   ...... 
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: ['babel-loader'],
      },
    ],
  },
  plugins: [
    new TSCheckerPlugin({
      typescript: {
        diagnosticOptions: {
          semantic: true,
          syntactic: true,
        },
      },
    }),
  ],
}

Resolve

Webpack 中的 resolve 配置制定的是在「构建时指定查找模块文件的规则」

  1. resolve.modules
  • 指定查找模块的目录范围
  1. resolve.extensions
  • 指定查找模块的文件类型范围
  1. resolve.mainFields
  • 指定查找模块的 package.json 中主文件的属性名
  1. resolve.symlinks
  • 指定在查找模块时是否处理软连接

这些规则「在处理每个模块时都会有所应用」,因此尽管对小型项目的构建速度来说影响不大,对于大型的模块众多的项目而言,「使用默认配置和增加了大量无效范围后,构建时长的变化」

并行构建以提升总体效率

「并行构建」的方案早在 Webpack 2 时代已经出现,适用于大项目。使用方式

  1. HappyPack
  2. thread-loader
  3. parallel-webpack

HappyPack 与 thread-loader

两种工具的本质作用相同,都作用于模块编译的 Loader 上,「用于在特定 Loader 的编译过程中」

开启多进程的方式加速编译

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve('src'),
        use: [
          'thread-loader',
           ’babel-loader‘
        ],
      },
    ],
  },
};

parallel-webpack

并发构建的第二种场景是「针对与多配置构建」

Webpack 的配置文件可以是「一个包含多个子配置对象的数组」,在执行这类多配置构建时,默认「串行执行」

var path = require('path');
module.exports = [
{
    entry: './pageA.js',
    output: {
        path: path.resolve(__dirname, './dist'),
        filename: 'pageA.bundle.js'
    }
}, 
{
    entry: './pageB.js',
    output: {
        path: path.resolve(__dirname, './dist'),
        filename: 'pageB.bundle.js'
    }
}];

通过 parallel-webpack,就能「实现相关配置的并行处理」

 "build:parallel": "parallel-webpack --config webpack.parallel.config.js"

Webpack打包阶段提效

Webpack 构建流程中的第二个阶段,也就是从代码优化到生成产物阶段的效率提升问题

优化阶段可以分为两个不同的方向

  1. 针对「某些任务」
  • 使用效率更高的工具或配置项
  • 从而「提升当前任务的工作效率」
  1. 提升「特定任务」的优化效果
  • 「减少传递给下一任务的数据量」
  • 从而提升后续环节的工作效率

以提升当前任务工作效率为目标的方案

一般在项目的优化阶段,主要耗时的任务有两个

  1. 生成 ChunkAssets
  • 优化手段较少
  • 主要集中在「利用缓存」方面
  • 即根据 Chunk 信息生成 Chunk 的产物代码
  • 主要在 Webpack 引擎内部的模块中处理
  1. 优化 Assets
  • 「压缩」 Chunk 产物代码

面向 JS 的压缩工具

Webpack 4 中内置了 TerserWebpackPlugin 作为默认的 JS 压缩工具--基于 Terser。之前的版本,需要单独引入,早期主要使用的是 UglifyJSWebpackPlugin-- 基于 UglifyJS 。两者在压缩效率与质量方面差别不大,但 Terser 整体上略胜一筹

Terser 和 UglifyJS 插件中的效率优化

Terser 原本是 Forkuglify-es 的项目,其绝大部分的 API 和参数都与 uglify-es 和 uglify-js@3 兼容。

以 Terser 为例来分析其中的优化方向

npm install terser-webpack-plugin --save-dev

TerserWebpackPlugin 中,对执行效率产生影响的配置主要分为 3 个方面

  1. Cache 选项
  • 默认开启
  • 使用缓存能够极大程度上提升再次构建时的工作效率
  1. Parallel 选项
  • 默认开启
  • 并发选项在大多数情况下能够提升该插件的工作效率
  • 适用大项目
  1. terserOptions 选项
  • 对源代码中的变量与函数名称进行压缩
  • 执行特定的压缩策略
  • 例如省略变量赋值的语句,从而将变量的值直接替换到引入变量的位置上,减小代码体积
  • 在需要对压缩阶段的效率进行优化的情况下,可以优先选择设置该参数
  • Terser 工具中的 minify 选项集合
  • 主要看其中的compressmangle选项
  • compress 参数的作用
  • mangle 参数的作用
  • 当compress参数为 false 时,压缩阶段的效率有明显提升,同时对压缩的质量影响较小

案例使用

module.exports = {
 optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        cache: false,
        terserOptions: {
          compress: false,
          mangle: false,
        },
      }),
    ],
  },
};

压缩代码是在 optimizeChunkAssets 阶段

面向 CSS 的压缩工具

CSS 同样有3种压缩工具可供选择

  1. OptimizeCSSAssetsPlugin
  • CRA中使用
  1. OptimizeCSSNanoPlugin
  • vue-cli
  1. CssMinimizerWebpackPlugin
  • 2020 年 Webpack 社区新发布的 CSS 压缩插件

它们都基于 cssnano 实现,「压缩质量方面」没有什么差别。

在压缩效率方面,最新发布的 MiniCssExtractPlugin,它支持缓存和多进程,「默认开启多进程」。这是另外两个工具不具备的。

MiniCssExtractPlugin

对于 CSS 文件的打包,一般我们会使用 style-loader 进行处理,这种处理方式最终的打包结果就是 CSS 代码会内嵌到 JS 代码中

MiniCssExtractPlugin是一个可以将 CSS 代码从打包结果中提取出来的插件。

// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          // 'style-loader', // 将样式通过 style 标签注入 
          MiniCssExtractPlugin.loader,
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin()
  ]
}

将这个插件添加到配置对象的 plugins 数组中,使用 MiniCssExtractPlugin 中提供的 loader 去替换掉 style-loader,以此来「捕获到所有的样式」。打包过后,样式就会存放在独立的文件中,直接通过 link 标签引入页面

CssMinimizerWebpackPlugin (webpack 5)

使用了 MiniCssExtractPlugin 过后,样式就被提取到单独的 CSS 文件中了,「样式文件并没有被压缩」Webpack 「内置的压缩插件仅仅是针对 JS 文件的压缩,其他资源文件的压缩都需要额外的插件」

// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
module.exports = {
  optimization: {
    minimizer: [
      new CssMinimizerPlugin()
    ]
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin()
  ]
}

文档中的这个插件并不是配置在 plugins 数组中的,而是添加到了 optimization 对象中的 minimizer 属性中。

「如果我们配置到 plugins 属性中,那么这个插件在任何情况下都会工作,而配置到 minimizer 中,就只会在 minimize 特性开启时才工作」 --- optimization.minimize: true

原本可以自动压缩的 JS,现在却不能压缩了,因为设置了 minimizerWebpack 认为我们需要使用自定义压缩器插件,那内部的 JS 压缩器就会被覆盖掉。「必须手动再添加回来」

内置的 JS 压缩插件叫作 terser-webpack-plugin,手动添加这个模块到 minimizer 配置当中。

// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const TerserWebpackPlugin = require('terser-webpack-plugin')
module.exports = {
  optimization: {
    minimize: true, 
    minimizer: [
      new TerserWebpackPlugin(),
      new CssMinimizerPlugin()
    ]
  },

  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader'
        ]
      }
    ]
  },

  plugins: [
    new MiniCssExtractPlugin()
  ]

}

以提升后续环节工作效率为目标的方案

❝通过对本环节的处理减少后续环节处理内容,以便提升后续环节的工作效率 ❞
  1. Code Splitting
  2. Tree Shaking
  3. Scope Hoisting (作用域提升)
  4. sideEffects

Code Splitting(分块打包)

Code Splitting--通过把项目中的资源模块按照我们设计的规则打包到不同的 bundle

  • 降低应用的启动成本
  • 提高响应速度

Webpack 实现分包的方式主要有两种

  1. 根据「业务不同配置多个打包入口」,输出多个打包结果
  2. 结合 ES Modules 的动态导入(Dynamic Imports)特性,「按需加载模块」

多入口打包

多入口打包一般适用于传统的多页应用程序,最常见的划分规则就是:一个页面对应一个打包入口,对于不同页面间公用的部分,再提取到公共的结果中

├── dist

├── src

│   ├── common

│   │   ├── fetch.js

│   │   └── global.css

│   ├── album.css

│   ├── album.html

│   ├── album.js

│   ├── index.css

│   ├── index.html

│   └── index.js

├── package.json

└── webpack.config.js

有两个页面,分别是 indexalbum

  • index.js 负责实现 index 页面功能逻辑
  • album.js 负责实现 album 页面功能逻辑
  • global.css 是公用的样式文件
  • fetch.js 是一个公用的模块,负责请求 API
配置文件
// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
  entry: {
    index: './src/index.js',
    album: './src/album.js'
  },
  output: {
    filename: '[name].bundle.js' // [name] 是入口名称
  },

  // ... 其他配置

  plugins: [
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/index.html',
      filename: 'index.html'
    }),
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/album.html',
      filename: 'album.html'
    })

  ]

}
  • 一般 entry 属性中只会配置一个打包入口。
  • 如果需要配置多个入口,可以把 entry 「定义成一个对象」
  • entry 是定义为对象而不是数组,如果是数组的话就是把多个文件打包到一起,还是一个入口。
  • 这个对象中一个属性就是一个入口,「属性名称就是这个入口的名称,值就是这个入口对应的文件路径」
  • 输出文件名 - 使用 [name] 这种占位符来输出动态的文件名 - [name] 最终会被替换为入口的名称
  • 通过 html-webpack-plugin- 分别为 indexalbum 页面生成了对应的 HTML 文件
分包加载

输出 HTML 的插件,默认这个插件会自动注入所有的打包结果。如果需要指定所使用的 bundle,通过 HtmlWebpackPluginchunks 属性来设置

「每个打包入口都会形成一个独立的 chunk(块)」

// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
  entry: {
    index: './src/index.js',
    album: './src/album.js'
  },
  output: {
    filename: '[name].bundle.js' // [name] 是入口名称
  },

  // ... 其他配置

  plugins: [
    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/index.html',
      filename: 'index.html',
      chunks: ['index'] // 指定使用 index.bundle.js
    }),

    new HtmlWebpackPlugin({
      title: 'Multi Entry',
      template: './src/album.html',
      filename: 'album.html',
      chunks: ['album'] // 指定使用 album.bundle.js
    })

  ]

}

提取公共模块

需要把这些公共的模块提取到一个单独的 bundle 中

优化配置中开启 splitChunks 功能

// ./webpack.config.js
module.exports = {
  entry: {
    index: './src/index.js',
    album: './src/album.js'
  },
  output: {
    filename: '[name].bundle.js' // [name] 是入口名称
  },
  optimization: {
    splitChunks: {
      // 自动提取所有公共模块到单独 bundle
      chunks: 'all'
    }
  }

  // ... 其他配置

}

将它设置为 all,表示所有公共模块都可以被提取

动态导入

Code Splitting 更常见的实现方式还是「结合 ES Modules 的动态导入特性,从而实现按需加载」

一般我们常说的按需加载指的是加载数据或者加载图片,这里所说的按需加载,指的是「在应用运行过程中,需要某个资源模块时,才去加载这个模块」

  • 极大地「降低了应用启动时需要加载的资源体积」
  • 提高了应用的「响应速度」
  • 节省了「带宽和流量」

Webpack 中支持使用动态导入的方式实现模块的按需加载,而且「所有动态导入的模块都会被自动提取到单独的 bundle 中,从而实现分包」

├── src

│   ├── album

│   │   ├── album.css

│   │   └── album.js

│   ├── common

│   │   ├── fetch.js

│   │   └── global.css

│   ├── posts

│   │   ├── posts.css

│   │   └── posts.js

│   ├── index.html

│   └── index.js

├── package.json

└── webpack.config.js

文章列表对应的是这里的 posts 组件,而相册列表对应的是 album 组件

在打包入口(index.js)中同时导入了这两个模块,然后根据页面锚点的变化决定显示哪个组件

// ./src/index.js

// import posts from './posts/posts'

// import album from './album/album'

const update = () => {

  const hash = window.location.hash || '#posts'

  const mainElement = document.querySelector('.main')

  mainElement.innerHTML = ''

  if (hash === '#posts') {

    // mainElement.appendChild(posts())

    import('./posts/posts').then(({ default: posts }) => {

      mainElement.appendChild(posts())

    })

  } else if (hash === '#album') {

    // mainElement.appendChild(album())

    import('./album/album').then(({ default: album }) => {

      mainElement.appendChild(album())

    })

  }

}

window.addEventListener('hashchange', update)

update()

为了动态导入模块,可以将 import 关键字作为函数调用。当以这种方式使用时,import 函数返回一个 Promise 对象.

  • 在需要使用组件的地方通过 import 函数导入指定路径
  • 方法返回的是一个 Promise
  • Promisethen 方法中能够拿到模块对象

由于这里的 posts 和 album 模块是「以默认成员导出,需要解构模块对象中的 default」,先拿到导出成员,然后再正常使用这个导出成员。

import('./album/album').then(({ default: album }) => {
      mainElement.appendChild(album())
})
魔法注释

默认通过动态导入产生的 bundle 文件,它的 name 就是一个序号。如果需要给这些 bundle 命名的话,就可以使用 Webpack 所特有的魔法注释去实现

import(/* webpackChunkName: 'posts' */'./posts/posts')
  .then(({ default: posts }) => {
    mainElement.appendChild(posts())
  })

所谓魔法注释,就是在 import 函数的「形式参数位置,添加一个行内注释」,注释有一个特定的格式---webpackChunkName:’xxx‘,就可以给分包的 chunk 起名字

如果 chunkName 相同的话,那相同的 chunkName 最终就会被打包到一起,借助这个特点,就可以根据自己的实际情况,灵活组织动态加载的模块了。

Tree Shaking

Tree-shaking 最早是 Rollup 中推出的一个特性,Webpack 从 2.0 过后开始支持这个特性。使用 Webpack 生产模式打包的优化过程中,自动开启这个功能 --- npx webpack --mode=production

其他模式开启 Tree Shaking

配置对象中添加一个 optimization 属性,该属性用来「集中配置 Webpack 内置优化功能」,它的值也是一个对象,在 optimization 对象中先开启一个 usedExports 选项,表示在输出结果中只导出外部使用了的成员

module.exports = {
  // ... 其他配置项
  optimization: {
    // 模块只导出被使用的成员
    usedExports: true
  }
}

对于未引用代码,如果我们开启压缩代码功能,就可以自动压缩掉这些没有用到的代码.

module.exports = {
  // ... 其他配置项
  optimization: {
    // 模块只导出被使用的成员
    usedExports: true,
    // 压缩输出结果
    minimize: true
  }
}

Tree-shaking 的实现,整个过程用到了 Webpack 的两个优化功能

  1. usedExports
  • 打包结果中只导出外部用到的成员
  1. minimize
  • 压缩打包结果

把代码看成一棵大树

  • usedExports的作用就是标记树上哪些是枯树枝、枯树叶
  • minimize 的作用就是负责把枯树枝、枯树叶摇下来

结合 babel-loader 的问题

Tree-shaking 实现的前提是 ES Modules,最终交给 Webpack 打包的代码,「必须是使用 ES Modules 的方式来组织的模块化」

Webpack 在打包所有的模块代码之前

  • 先是将模块根据配置交给不同的 Loader 处理
  • 最后再将 Loader 处理的结果打包到一起

为了更好的兼容性,会选择使用 babel-loader 去转换我们源代码中的一些 ECMAScript 的新特性,Babel 在转换 JS 代码时,很有可能处理掉代码中的 ES Modules 部分,把它们转换成 CommonJS 的方式。

babel-loader (低版本)

我们为 Babel 配置的都是一个 preset(预设插件集合),而不是某些具体的插件。

目前市面上使用最多的 @babel/preset-env,这个预设里面就有转换 ES Modules 的插件。使用这个预设时,代码中的 ES Modules 部分就会被转换成 CommonJS 方式。Webpack 再去打包时,拿到的就是以 CommonJS 方式组织的代码了,所以 Tree-shaking 不能生效

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env']
            ]
          }
        }
      }
    ]
  },
  optimization: {
    usedExports: true
  }

}

最新版本(8.x)的 babel-loader

「自动帮我们关闭了对 ES Modules 转换的插件」,经过 babel-loader 处理后的代码默认仍然是 ES Modules。那 Webpack 最终打包得到的还是 ES Modules 代码。Tree-shaking 自然也就可以正常工作了

最新版本的 babel-loader 并不会导致 Tree-shaking 失效,确保babel-loader能使用Tree-shaking。最简单的办法就是在配置中将 @babel/preset-envmodules 属性设置为 false。确保不会转换 ES Modules,也就确保了 Tree-shaking 的前提

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', { modules: 'false' }]
            ]
          }
        }
      }
    ]
  },
  optimization: {
    usedExports: true
  }

}

Scope Hoisting (作用域提升)

Webpack 3.0 中添加的一个特性,使用 concatenateModules 选项继续优化输出

普通打包只是将一个模块最终放入一个单独的函数中,如果模块很多,就意味着在输出结果中会有很多的模块函数。concatenateModules 配置的作用,尽可能将所有模块合并到一起输出到一个函数中,既提升了运行效率,又减少了代码的体积。

module.exports = {
  // ... 其他配置项
  optimization: {
    // 模块只导出被使用的成员
    usedExports: true,
    // 尽可能合并每一个模块到一个函数中
    concatenateModules: true,
  }

}

bundle.js 中就不再是一个模块对应一个函数了,而是把所有的模块都放到了一个函数中

sideEffects

Webpack 4 中新增了一个 sideEffects 特性,允许「通过配置标识我们的代码是否有副作用,从而提供更大的压缩空间」

模块的副作用指的就是模块执行的时候除了导出成员,是否还做了其他的事情,特性一般只有去开发一个 npm 模块时才会用到。

Tree-shaking 只能移除没有用到的代码成员,而想要「完整移除没有用到的模块」,那就需要开启 sideEffects 特性了,在 optimization 中开启 sideEffects 特性

// ./webpack.config.js

module.exports = {
  mode: 'none',
  optimization: {
    sideEffects: true
  }
}

这个特性在 production 模式下同样会自动开启

Webpack 缓存优化

利用缓存数据来「加速构建过程」的处理。

「初次构建」的压缩代码过程中,就将这一阶段的结果写入了缓存目录(node_modules/.cache/插件名/)中有缓存。

「再次构建」进行到压缩代码阶段时,即可对比读取已有缓存。

  1. 编译阶段的缓存优化
  2. 优化打包阶段的缓存优化

编译阶段的缓存优化

「编译过程的耗时点主要在使用不同加载器(Loader)来编译模块的过程」

Babel-loader

Babel-loader 是绝大部分项目中会使用到的 JS/JSX/TS 编译器

与缓存相关的设置主要有

  1. cacheDirectory
  • ./node_modules/.cache/babel-loader/
  • 「默认为 false,即不开启缓存」
  • 当值为 true 时开启缓存并使用默认缓存目录
  • 也可以指定其他路径值作为缓存目录
  1. cacheIdentifier
  • Babel 相关依赖包的版本
  • babelrc 配置文件的内容
  • 环境变量
  • 与模块内容
  • 用于计算缓存标识符
  • 「默认使用」
  • 一起参与计算缓存标识符
  1. cacheCompression
  • 「默认为 true」
  • 将缓存内容压缩为 gz 包以减小缓存目录的体积
  • 在设为 false 的情况下将跳过压缩和解压的过程,从而提升这一阶段的速度

Cache-loader

在编译过程中利用缓存的第二种方式是使用 --- Cache-loader

在使用时,需要「将 cache-loader 添加到对构建效率影响较大的 Loader(如 babel-loader 等)之前」

module: {
  rules: [
    {
      test: /\.js$/,
      use: ['cache-loader', 'babel-loader'],
    },
  ],
}

「使用 cache-loader 后,比使用 babel-loader 的开启缓存选项后的构建时间更短」

主要原因是 babel-loader 中的缓存信息较少,而 cache-loader 中存储的 Buffer 形式的数据处理效率更高。

优化打包阶段的缓存优化

生成 ChunkAsset 时的缓存优化

在 Webpack 4 中,生成 ChunkAsset 过程中的缓存优化是受限制的:

  • 只有在 watch 模式下
  • 且配置中开启 cache 时(development 模式下自动开启),才能在这一阶段执行缓存的逻辑

在 Webpack 4 中,缓存插件是「基于内存」的,只有在 watch 模式下才能在内存中获取到相应的缓存数据对象

代码压缩时的缓存优化

对于 JS 的压缩TerserWebpackPlugin/UglifyJSPlugin都是支持缓存设置的。

对于 CSS 的压缩,目前最新发布的 CSSMinimizerWebpackPlugin 支持且「默认开启缓存」,其他的插件如 OptimizeCSSAssetsPluginOptimizeCSSNanoPlugin 目前还不支持使用缓存

使用缓存注意点

「如何最大程度地让缓存命中,成为我们选择缓存方案后首先要考虑的」

缓存标识符发生变化导致的缓存失效,支持缓存的 Loader 和插件中,会根据一些「固定字段的值加上所处理的模块或 Chunk 的数据 hash 值来生成对应缓存的标识符」。一旦其中的值发生变化,对应缓存标识符就会发生改变,意味着对应工具中,所有之前的缓存都将失效。需要尽可能少地变更会影响到缓存标识符生成的字段

优化打包阶段的缓存失效,尽管在模块编译阶段每个模块是单独执行编译的。但是当进入到代码压缩环节时,各模块已经被组织到了相关联的 Chunk 中,「N个模块最后只生成了一个 Chunk」。任何一个模块发生变化都会导致整个 Chunk 的内容发生变化,而使之前保存的缓存失效。

优化方案

尽可能地「把那些不变的处理成本高昂的模块打入单独的 Chunk 中」Webpack 中的分包配置——splitChunks。使用 splitChunks 「优化缓存利用率」

好处

  1. 合并通用依赖
  2. 提升构建缓存利用率
  3. 提升资源访问的缓存利用率
  4. 资源懒加载

CI/CD 中的缓存目录问题

自动化集成的系统中,项目的构建空间会在每次构建执行完毕后,立即回收清理。在这种情况下,「默认的项目构建缓存目录(node_mo dules/.cache)将无法留存」。导致即使项目中开启了缓存设置,也无法享受缓存的便利性,反而因为需要写入缓存文件而浪费额外的时间

如果需要使用缓存,则需要根据对应平台的规范,「将缓存设置到公共缓存目录下」

❝缓存的便利性本质在于用磁盘空间换取构建时间,需要考虑对缓存区域的定期清理 ❞

后记

「分享是一种态度」

「全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。」