AboutFE icon indicating copy to clipboard operation
AboutFE copied to clipboard

46、Webpack、Vite

Open CodingMeUp opened this issue 4 years ago • 8 comments

什么是Webpack

可以看做一个模块化打包机,分析项目结构,处理模块化依赖,转换成为浏览器可运行的代码。

  • 代码转换: TypeScript 编译成 JavaScript、SCSS,LESS 编译成 CSS.
  • 文件优化:压缩 JavaScript、CSS、HTML 代码,压缩合并图片。
  • 代码分割:提取多个页面的公共代码、提取首屏不需要执行部分的代码让其异步加载。
  • 模块合并:在采用模块化的项目里会有很多个模块和文件,需要构建功能把模块分类合并成一个文件。
  • 自动刷新:监听本地源代码的变化,自动重新构建、刷新浏览器。

构建把一系列前端代码自动化去处理复杂的流程,解放生产力。

Webpack事件流机制

Webpack是基于事件流的插件集合,它的工作流程就是将各个插件串联起来,而实现这一切的核心就是Tapable,Tapable是一个类似Node.js的EventEmitter的库,主要是控制钩子函数的发布与订阅,控制着webpack的插件系统。Webpack中最核心的负责编译的Compiler负责创建的捆绑包的Compilation都是Tapable实例。

Tapable库暴露很多Hook类,为插件提供挂载的钩子

const {
	SyncHook,  // 同步钩子
	SyncBailHook, // 同步熔断钩子
	SyncWaterfallHook, // 同步流水钩子
	SyncLoopHook, // 同步循环钩子
	AsyncParallelHook, // 异步并发钩子
	AsyncParallelBailHook, // 异步并发熔断钩子
	AsyncSeriesHook, // 异步串行钩子
	AsyncSeriesBailHook, // 异步串行熔断钩子
	AsyncSeriesWaterfallHook // 异步串行流水钩子
 } = require("tapable");
const hook = new SyncHook(["arg1", "arg2", "arg3"]);

Async* 绑定: tapAsync/tapPromise/tap 执行: callAsync/promise Sync* 绑定: tap 执行: call

Tapable使用实际例子:

const { SyncHook } = require("tapable");
let queue = new SyncHook(['name']); //所有的构造函数都接收一个可选的参数,这个参数是一个字符串的数组。

// 订阅
queue.tap('1', function (name, name2) {// tap 的第一个参数是用来标识订阅的函数的
    console.log(name, name2, 1);
    return'1'
});
queue.tap('2', function (name) {
    console.log(name, 2);
});
queue.tap('3', function (name) {
    console.log(name, 3);
});

// 发布
queue.call('webpack', 'webpack-cli');// 发布的时候触发订阅的函数 同时传入参数

// 执行结果:
/*
webpack undefined 1 // 传入的参数需要和new实例的时候保持一致,否则获取不到多传的参数
webpack 2
webpack 3
*/

Webpack中Tapable的应用

if (Array.isArray(options)) {
		compiler = new MultiCompiler(
			Array.from(options).map(options => webpack(options))
		);
	} elseif (typeof options === "object") {
		// 1 做初始的操作
		options = new WebpackOptionsDefaulter().process(options);

		compiler = new Compiler(options.context);
		compiler.options = options;
		// 2  必须插件有apply接受compiler 参数
		new NodeEnvironmentPlugin({
			infrastructureLogging: options.infrastructureLogging
		}).apply(compiler);
		// 插件接收compiler对象上的hooks,议案事件触发,插件也会执行操作
		if (options.plugins && Array.isArray(options.plugins)) {
			for (const plugin of options.plugins) {
				if (typeof plugin === "function") {
					plugin.call(compiler, compiler);
				} else {
					plugin.apply(compiler);
				}
			}
		}
		compiler.hooks.environment.call();
		compiler.hooks.afterEnvironment.call();
		compiler.options = new WebpackOptionsApply().process(options, compiler);
	}

Webpack流程概览

Webpack首先会把配置参数和命令行的参数及默认参数合并,并初始化需要使用的插件和配置插件等执行环境所需要的参数;初始化完成后会调用Compiler的run来真正启动webpack编译构建过程,webpack的构建流程包括compile、make、build、seal、emit阶段,执行完这些阶段就完成了构建过程。

  1. 初始化参数: 从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。
  2. 开始编译: 根据我们的webpack配置注册好对应的插件调用 compile.run 进入编译阶段,在编译的第一阶段是 compilation,他会注册好不同类型的module对应的 factory,不然后面碰到了就不知道如何处理了。
  3. 编译模块: 进入 make 阶段,会从 entry 开始进行两步操作:第一步是调用 loaders (loader精讲loader相关QA)对模块的原始代码进行编译,转换成标准的JS代码, 第二步是调用 acorn 对JS代码进行语法分析,使用acorn生成AST,并遍历AST收集依赖。每个模块都会记录自己的依赖关系,从而形成一颗关系树。代码解析
  4. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会。
  5. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

WEBPACK打包示意图

Webpack流程详解及源码解析

分为三个阶段: 初始化阶段,编译阶段,输出文件(chunk)

初始化阶段

  • 初始化参数: 从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。这个过程中还会执行配置文件中的插件实例化语句 new Plugin()。
  • 初始化默认参数配置: new WebpackOptionsDefaulter().process(options)
  • 实例化Compiler对象:用上一步得到的参数初始化Compiler实例,Compiler负责文件监听和启动编译。Compiler实例中包含了完整的Webpack配置,全局只有一个Compiler实例。
  • 加载插件: 依次调用插件的apply方法,让插件可以监听后续的所有事件节点。同时给插件传入compiler实例的引用,以方便插件通过compiler调用Webpack提供的API。
  • 处理入口: 读取配置的Entrys,为每个Entry实例化一个对应的EntryPlugin,为后面该Entry的递归解析工作做准备。
new EntryOptionPlugin().apply(compiler)
new SingleEntryPlugin(context, item, name)
compiler.hooks.make.tapAsync

编译阶段

  • run阶段:启动一次新的编译。this.hooks.run.callAsync。
  • compile: 该事件是为了告诉插件一次新的编译将要启动,同时会给插件带上compiler对象。
  • compilation: 当Webpack以开发模式运行时,每当检测到文件变化,一次新的Compilation将被创建。一个Compilation对象包含了当前的模块资源、编译生成资源、变化的文件等。Compilation对象也提供了很多事件回调供插件做扩展。
  • make:一个新的 Compilation 创建完毕主开始编译 完毕主开始编译 this.hooks.make.callAsync。
  • addEntry: 即将从 Entry 开始读取文件。
  • _addModuleChain: 根据依赖查找对应的工厂函数,并调用工厂函数的create来生成一个空的MultModule对象,并且把MultModule对象存入compilation的modules中后执行MultModule.build。
  • buildModules: 使用对应的Loader去转换一个模块。开始编译模块,
this.buildModule(module)
buildModule(module, optional, origin,dependencies, thisCallback)
  • build: 开始真正编译模块。
  • doBuild: 开始真正编译入口模块。
  • normal-module-loader: 在用Loader对一个模块转换完后,使用acorn解析转换后的内容,输出对应的抽象语法树(AST),以方便Webpack后面对代码的分析。
  • program: 从配置的入口模块开始,分析其AST,当遇到require等导入其它模块语句时,便将其加入到依赖的模块列表,同时对新找出的依赖模块递归分析,最终搞清所有模块的依赖关系。

输出阶段

  • seal: 封装 compilation.seal seal(callback)。
  • addChunk: 生成资源 addChunk(name)。
  • createChunkAssets: 创建资源 this.createChunkAssets()。
  • getRenderManifest: 获得要渲染的描述文件 getRenderManifest(options)。
  • render: 渲染源码 source = fileManifest.render()。
  • afterCompile: 编译结束 this.hooks.afterCompile。
  • shouldEmit: 所有需要输出的文件已经生成好,询问插件哪些文件需要输出,哪些不需要。this.hooks.shouldEmit。
  • emit: 确定好要输出哪些文件后,执行文件输出,可以在这里获取和修改输出内容。
this.emitAssets(compilation)  
this.hooks.emit.callAsync 
const emitFiles = err
this.outputFileSystem.writeFile
  • done: 全部完成 this.hooks.done.callAsync。

CodingMeUp avatar May 20 '20 08:05 CodingMeUp

使用webpack4提升180%编译速度

  1. 缩小编译范围,减少不必要的编译工作,即 modules、mainFields、noParse、includes、exclude、alias全部用起来。

  2. 想要进一步提升编译速度,就要知道瓶颈在哪?通过测试,发现有两个阶段较慢:① babel 等 loaders 解析阶段;② js 压缩阶段。loader 解析稍后会讨论,而 js 压缩是发布编译的最后阶段,通常webpack需要卡好一会,这是因为压缩 JS 需要先将代码解析成 AST 语法树,然后需要根据复杂的规则去分析和处理 AST,最后将 AST 还原成 JS,这个过程涉及到大量计算,因此比较耗时。

实际上,搭载 webpack-parallel-uglify-plugin 插件,这个过程可以倍速提升。我们都知道 node 是单线程的,但node能够fork子进程,基于此,webpack-parallel-uglify-plugin 能够把任务分解给多个子进程去并发的执行,子进程处理完后再把结果发送给主进程,从而实现并发编译,进而大幅提升js压缩速度

  1. 现在我们来看看,loader 解析速度如何提升。同 webpack-parallel-uglify-plugin 插件一样,HappyPack 也能实现并发编译,从而可以大幅提升 loader 的解析速度

  2. 我们都知道,webpack打包时,有一些框架代码是基本不变的,比如说 babel-polyfill、vue、vue-router、vuex、axios、element-ui、fastclick 等,这些模块也有不小的 size,每次编译都要加载一遍,比较费时费力。使用 DLLPlugin 和 DLLReferencePlugin 插件,便可以将这些模块提前打包

Webpack优化——将你的构建效率提速翻倍

  1. 构建打点 测量构建效率, speed-measure-webpack-plugin,它能够测量出在你的构建过程中,每一个 Loader 和 Plugin 的执行时长,官方给出的效果图是下面这样:

可以断言的是,大部分的执行时长应该都是消耗在编译 JS、CSS 的 Loader 以及对这两类代码执行压缩操作的 Plugin 上

为什么会这样呢?因为在对我们的代码进行编译或者压缩的过程中,都需要执行这样的一个流程:编译器(这里可以指 webpack)需要将我们写下的字符串代码转化成 AST(语法分析树),就是如下图所示的一个树形对象:

显而易见,编译器肯定不能用正则去显式替换字符串来实现这样一个复杂的编译流程,而编译器需要做的就是遍历这棵树,找到正确的节点并替换成编译后的值,过程就像下图这样:

之所以构建时长会集中消耗在代码的编译或压缩过程中,正是因为它们需要去遍历树以替换字符或者说转换语法,因此都需要经历"转化 AST -> 遍历树 -> 转化回代码"这样一个过程

  1. 优化策略
  • 缓存 们每次的项目变更,肯定不会把所有文件都重写一遍,但是每次执行构建却会把所有的文件都重复编译一遍,这样的重复工作是否可以被缓存下来呢,就像浏览器加载资源一样?答案肯定是可以的,其实大部分 Loader 都提供了 cache 配置项,比如在 babel-loader 中,可以通过设置 cacheDirectory 来开启缓存,这样,babel-loader 就会将每次的编译结果写进硬盘文件(默认是在项目根目录下的node_modules/.cache/babel-loader目录内,当然你也可以自定义)。 但如果 loader 不支持缓存呢?我们也有方法。接下来介绍一款神器:cache-loader ,它所做的事情很简单,就是 babel-loader 开启 cache 后做的事情,将 loader 的编译结果写入硬盘缓存,再次构建如果文件没有发生变化则会直接拉取缓存。而使用它的方法很简单,正如官方 demo 所示,只需要把它卸载在代价高昂的 loader 的最前面即可:
module.exports = {
  module: {
    rules: [
      {
        test: /\.ext$/,
        use: ['cache-loader', ...loaders],
        include: path.resolve('src'),
      },
    ],
  },
};

不建议将缓存逻辑集成到 CI 流程中,因为目前还仍会出现更新依赖后依旧命中缓存的情况,这显然是个 BUG,在开发机上我们可以手动删除缓存解决问题,但在编译机上过程就要麻烦的多。为了保证每次 CI 结果的纯净度,这里建议在 CI 过程中还是不要开启缓存功能

  • 多核

这里的优化手段大家肯定已经想到了,自然是我们的 happypack。这似乎已经是一个老生常谈的话题了,从3时代开始,happypack 就已经成为了众多 webpack 工程项目接入多核编译的不二选择,几乎所有的人,在提到 webpack 效率优化时,怎么样也会说出 happypack 这个词语。所以,在前端社区繁荣的今天,从 happypack 出现的那时候起,就有许多优秀的质量文如雨后春笋般层出不穷。所以今天在这里,对于 happypack 我就不做过多细节上的介绍了,想必大家对它也再熟悉不过了,我就带着大家简单回顾一下它的使用方法吧

const HappyPack = require('happypack')
const os = require('os')
// 开辟一个线程池
// 拿到系统CPU的最大核数,happypack 将编译工作灌满所有线程
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length })

module.exports = {
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: 'happypack/loader?id=js',
      },
    ],
  },
  plugins: [
    new HappyPack({
      id: 'js',
      threadPool: happyThreadPool,
      loaders: [
        {
          loader: 'babel-loader',
        },
      ],
    }),
  ],
}

所以配置起来逻辑其实很简单,就是用 happypack 提供的 Plugin 为你的 Loaders 做一层包装就好了,向外暴露一个id ,而在你的 module.rules 里,就不需要写loader了,直接引用这个 id 即可,所以赶紧用 happypack 对那些你测出来的代价比较昂贵的 loaders 们做一层多核编译的包装吧。

而对于一些编译代价昂贵的 webpack 插件,一般都会提供 parallel 这样的配置项供你开启多核编译,因此,只要你善于去它的官网发现,一定会有意想不到的收获噢~

  • 抽离

对于一些不常变更的静态依赖,比如我们项目中常见的 React 全家桶,亦或是用到的一些工具库,比如 lodash 等等,我们不希望这些依赖被集成进每一次构建逻辑中,因为它们真的太少时候会被变更了,所以每次的构建的输入输出都应该是相同的。因此,我们会设法将这些静态依赖从每一次的构建逻辑中抽离出去,以提升我们每次构建的构建效率。

常见的方案有两种,一种是使用 webpack-dll-plugin 的方式,在首次构建时候就将这些静态依赖单独打包,后续只需要引用这个早就被打好的静态依赖包即可,有点类似“预编译”的概念;另一种,也是业内常见的 Externals的方式,我们将这些不需要打包的静态资源从构建逻辑中剔除出去,而使用 CDN 的方式,去引用它们。

webpack-dll-plugin 与 Externals 的抉择

webpack-dll-plugin 的三宗原罪:

需要配置在每次构建时都不参与编译的静态依赖,并在首次构建时为它们预编译出一份 JS 文件(后文将称其为 lib 文件),每次更新依赖需要手动进行维护,一旦增删依赖或者变更资源版本忘记更新,就会出现 Error 或者版本错误。

无法接入浏览器的新特性 script type="module",对于某些依赖库提供的原生 ES Modules 的引入方式(比如 vue 的新版引入方式)无法得到支持,没法更好地适配高版本浏览器提供的优良特性以实现更好地性能优化。

将所有资源预编译成一份文件,并将这份文件显式注入项目构建的 HTML 模板中,这样的做法,在 HTTP1 时代是被推崇的,因为那样能减少资源的请求数量,但在 HTTP2 时代如果拆成多个 CDN Link,就能够更充分地利用 HTTP2 的多路复用特性。口说无凭,直接上图验证结论:使用 webpack-dll-plugin 生成的 lib 文件,整体资源作为一个文件加载,需要 400 多毫秒 使用 Externals 配合 HTTP2,所有资源并行加载,整体时长不超过 100ms

如果你的公司没有成熟的 CDN 服务,但又想对项目中的静态依赖进行抽离该怎么办呢?那笔者的建议还是选择 webpack-dll-plugin 来优化你的构建效率。如果你还是觉得每次更新依赖都需要去维护一个 lib 文件特别麻烦,那我还是特别提醒你,在使用 Externals 时选择一个靠谱的 CDN 是一件特别重要的事,毕竟这些依赖比如 React 都是你网站的骨架,少了他们可是连站点都运行不起来了噢。

这个理解起来不费劲,操作起来很费劲。所幸,在Webpack5中已经不用它了,而是用HardSourceWebpackPlugin,一样的优化效果,但是使用却及其简单

  • 拆分 集群编译: 这里的集群当然不是指我们的真实物理机,而是我们的 docker。其原理就是将单个 entry 剥离出来维护一个独立的构建流程,并在一个容器内执行,待构建完成后,将生成文件打进指定目录。为什么能这么做呢?因为我们知道,webpack 会将一个 entry 视为一个 chunk,并在最后生成文件时,将 chunk 单独生成一个文件, 因为如今团队在实践前端微服务,因此每一个子模块都被拆分成了一个单独的repo,因此我们的项目与生俱来就继承了集群编译的基因,但是如果把这些子项目以 entry 的形式打在一个 repo 中,也是一个很常见的情况,这时候,就需要进行拆分,集群编译便能发挥它的优势。
  1. 提升体验 progress-bar-webpack-plugin 可以让你清晰的看见构建的执行进度

CodingMeUp avatar May 21 '20 07:05 CodingMeUp

webpack (HMR hot-reload Hot Module Replacement)

https://mp.weixin.qq.com/s/2L9Y0pdwTTmd8U2kXHFlPA https://github.com/879479119/879479119.github.io/issues/5

1.当修改了一个或多个文件; 2.文件系统接收更改并通知webpack; 3.webpack重新编译构建一个或多个模块,并通知HMR服务器进行更新; 4.HMR Server 使用webSocket通知HMR runtime 需要更新,HMR运行时通过HTTP请求更新jsonp; 5.HMR运行时替换更新中的模块,如果确定这些模块无法更新,则触发整个页面刷新。

CodingMeUp avatar May 21 '20 07:05 CodingMeUp

https://github.com/879479119/879479119.github.io/issues

CodingMeUp avatar May 21 '20 07:05 CodingMeUp

code split chunk 分包 splitchunks w4版本

CodingMeUp avatar May 21 '20 09:05 CodingMeUp

webpack tree shaking

Tree-Shaking这个名词,很多前端coder已经耳熟能详了,它代表的大意就是删除没用到的代码。这样的功能对于构建大型应用时是非常好的,因为日常开发经常需要引用各种库。但大多时候仅仅使用了这些库的某些部分,并非需要全部,此时Tree-Shaking如果能帮助我们删除掉没有使用的代码,将会大大缩减打包后的代码量。

Tree-Shaking在前端界由rollup首先提出并实现,后续webpack在2.x版本也借助于UglifyJS实现了。自那以后,在各类讨论优化打包的文章中,都能看到Tree-Shaking的身影。

  • ES6的模块引入是静态分析的,故而可以在编译时正确判断到底加载了什么代码。
  • 分析程序流,判断哪些变量未被使用、引用,进而删除此代码。
  • 很好,原理非常完美,那为什么我们的代码又删不掉呢?

先说原因:都是副作用的锅, 也是由于babel编译 + webpack打包的编译,一些我们原本看似没有副作用的代码,便转化为了(可能)有副作用的。

总结

上面讲了这么多,我最后再总结下,在当下阶段,在tree-shaking上能够尽力的事。

  1. 尽量不写带有副作用的代码。诸如编写了立即执行函数,在函数里又使用了外部变量等。
  2. 如果对ES6语义特性要求不是特别严格,可以开启babel的loose模式,这个要根据自身项目判断,如:是否真的要不可枚举class的属性。
  3. 如果是开发JavaScript库,请使用rollup。并且提供ES6 module的版本,入口文件地址设置到package.json的module字段。
  4. 如果JavaScript库开发中,难以避免的产生各种副作用代码,可以将功能函数或者组件,打包成单独的文件或目录,以便于用户可以通过目录去加载。如有条件,也可为自己的库开发单独的webpack-loader,便于用户按需加载。
  5. 如果是工程项目开发,对于依赖的组件,只能看组件提供者是否有对应上述3、4点的优化。对于自身的代码,除1、2两点外,对于项目有极致要求的话,可以先进行打包,最终再进行编译。
  6. 如果对项目非常有把握,可以通过uglify的一些编译配置,如:pure_getters: true,删除一些强制认为不会产生副作用的代码。

故而,在当下阶段,依旧没有比较简单好用的方法,便于我们完整的进行tree-shaking。所以说,想做好一件事真难啊。不仅需要靠个人的努力,还需要考虑到历史的进程。

https://juejin.im/post/5a4dc842518825698e7279a9 https://blog.csdn.net/weixin_33966365/article/details/88007124

CodingMeUp avatar May 21 '20 13:05 CodingMeUp

Vite 原理

  • 极速的服务启动(其实 Vite 也是这样的,它只是启动了一个 node 服务器而已,只不过在第一次启动之前会有一个预编译的过程)

  • 轻量快速的热重载 Vite 实现了一套基于 ESM 模块的 HMR ,通过 websocket 来实现。 它会将你的所有文件添加一个 watcher ,来监听你的文件变动,实现热重载。 快速的热重载如何体现?类似 Webpack 进行热更新时,会将你的所有文件重新打包一次,来实现热更新,而 vite 是只重载你更改的那个文件,通过 HTTP 来重新发送请求即可实现,所以是快速的。不需要将你的其他代码进行打包

CodingMeUp avatar Aug 31 '21 08:08 CodingMeUp

Vite 和 webpack 区别

  • webpack

Webpack 的方式是将你的所有的代码统一进行编译,包括所有的 router 以及下面的模块,模块下还有各个组件。打包成浏览器可以识别的代码,都打包完成之后,启动服务器,统一给浏览器使用。

  • vite

Vite 的方式是直接先启动服务器,其实图上少了一个步骤,在启动服务器之前会先读取你的 package.json 文件,识别出需要进行预编译的包,先进行预编译之后,再去启动服务器。 启动服务器之后会通过发送 HTTP 请求的模式访问入口文件,入口文件访问当前页面路由所需要的模块,以及模块下的组件,当你通过路由导航到另一个路由下,如果这个路由下的模块与上个模块有重合部分,这时 Vite 将会采用缓存的内容,不会发送请求,如果没有则继续发送对应的文件请求。高效的利用了 tree shaking 特性。这也是为什么不管你的项目多大, HMR 都会保持在非常高速的进行的原因。

CodingMeUp avatar Aug 31 '21 09:08 CodingMeUp