#Webpack,FE

前言

Hot Module Replacement(以下简称 HMR)是 Webpack 发展至今一大特性 ,当你对代码进行修改并保存后,Webpack 将对代码重新打包,并将新的模块发送到浏览器端,浏览器通过新的模块替换老的模块,这样在不刷新浏览器的前提下就能够对应用进行更新。

例如,在开发 Web 页面过程中,当你点击按钮,出现一个弹窗的时候,发现弹窗标题没有对齐,这时候你修改 CSS 样式,然后保存,在浏览器没有刷新的前提下,标题样式发生了改变。感觉就像在 Chrome 的开发者工具中直接修改元素样式一样。

模块热替换(hot module replacement)

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

  • 保留在完全重新加载页面期间丢失的应用程序状态。

  • 只更新变更内容,以节省宝贵的开发时间。

  • 在源代码中 CSS/JS 产生修改时,会立刻在浏览器中进行更新,这几乎相当于在浏览器 devtools 直接更改样式。

为什么需要 HMR

在 webpack HMR 功能之前,已经有很多 live reload 的工具或库,比如 live-server,这些库监控文件的变化,然后通知浏览器端刷新页面,那么我们为什么还需要 HMR 呢?答案其实在上文中已经提及一些。

  • live reload 工具并不能够保存应用的状态(states),当刷新页面后,应用之前状态丢失,还是上文中的例子,点击按钮出现弹窗,当浏览器刷新后,弹窗也随即消失,要恢复到之前状态,还需再次点击按钮。而 webapck HMR 则不会刷新浏览器,而是运行时对模块进行热替换,保证了应用状态不会丢失,提升了开发效率。

  • 在古老的开发流程中,我们可能需要手动运行命令对代码进行打包,并且打包后再手动刷新浏览器页面,而这一系列重复的工作都可以通过 HMR 工作流来自动化完成,让更多的精力投入到业务中,而不是把时间浪费在重复的工作上。

  • HMR 兼容市面上大多前端框架或库,比如 React Hot LoaderVue-loader,能够监听 React 或者 Vue 组件的变化,实时将最新的组件更新到浏览器端。Elm Hot Loader 支持通过 webpack 对 Elm 语言代码进行转译并打包,当然它也实现了 HMR 功能。

HMR 的工作原理图解

初识 HMR 的时候觉得其很神奇,一直有一些疑问萦绕在脑海。

  • webpack 可以将不同的模块打包成 bundle 文件或者几个 chunk 文件,但是当我通过 webpack HMR 进行开发的过程中,我并没有在我的 dist 目录中找到 webpack 打包好的文件,它们去哪呢?

  • 通过查看 webpack-dev-server 的 package.json 文件,我们知道其依赖于 webpack-dev-middleware 库,那么 webpack-dev-middleware 在 HMR 过程中扮演什么角色?

  • 使用 HMR 的过程中,通过 Chrome 开发者工具我知道浏览器是通过 websocket 和 webpack-dev-server 进行通信的,但是 websocket 的 message 中并没有发现新模块代码。打包后的新模块又是通过什么方式发送到浏览器端的呢?为什么新的模块不通过 websocket 随消息一起发送到浏览器端呢?

  • 浏览器拿到最新的模块代码,HMR 又是怎么将老的模块替换成新的模块,在替换的过程中怎样处理模块之间的依赖关系?

  • 当模块的热替换过程中,如果替换模块失败,有什么回退机制吗?

带着上面的问题,于是决定深入到 webpack 源码,寻找 HMR 底层的奥秘。

webpack-optimization

图一:HMR 工作流程图解

上图是webpack 配合 webpack-dev-server 进行应用开发的模块热更新流程图。

上图底部红色框内是服务端,而上面的橙色框是浏览器端。

绿色的方框是 webpack 代码控制的区域。蓝色方框是 webpack-dev-server 代码控制的区域,洋红色的方框是文件系统,文件修改后的变化就发生在这,而青色的方框是应用本身。

上图显示了我们修改代码到模块热更新完成的一个周期,通过深绿色的阿拉伯数字符号已经将 HMR 的整个过程标识了出来。

  • 第一步,在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,并将打包后的代码通过简单的 JavaScript 对象保存在内存中。

  • 第二步是 webpack-dev-server 和 webpack 之间的接口交互,而在这一步,主要是 dev-server 的中间件 webpack-dev-middleware 和 webpack 之间的交互,webpack-dev-middleware 调用 webpack 暴露的 API对代码变化进行监控,并且告诉 webpack,将代码打包到内存中。

  • 第三步是 webpack-dev-server 对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。当我们在配置文件中配置了devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。注意,这儿是浏览器刷新,和 HMR 是两个概念。

  • 第四步也是 webpack-dev-server 代码的工作,该步骤主要是通过 sockjs(webpack-dev-server 的依赖)在浏览器端和服务端之间建立一个 websocket 长连接,将 webpack 编译打包的各个阶段的状态信息告知浏览器端,同时也包括第三步中 Server 监听静态文件变化的信息。浏览器端根据这些 socket 消息进行不同的操作。当然服务端传递的最主要信息还是新模块的 hash 值,后面的步骤根据这一 hash 值来进行模块热替换。

webpack-dev-server/client 端并不能够请求更新的代码,也不会执行热更模块操作,而把这些工作又交回给了 webpackwebpack/hot/dev-server 的工作就是根据
webpack-dev-server/client 传给它的信息以及 dev-server 的配置决定是刷新浏览器呢还是进行模块热更新。当然如果仅仅是刷新浏览器,也就没有后面那些步骤了。

HotModuleReplacement.runtime 是客户端 HMR 的中枢,它接收到上一步传递给他的新模块的 hash 值,它通过 JsonpMainTemplate.runtime 向 server 端发送 Ajax 请求,服务端返回一个 json,该 json 包含了所有要更新的模块的 hash 值,获取到更新列表后,该模块再次通过 jsonp 请求,获取到最新的模块代码。这就是上图中 7、8、9 步骤。

而第 10 步是决定 HMR 成功与否的关键步骤,在该步骤中,HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。

最后一步,当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。

运用 HMR 的简单例子

在上一个部分,通过一张 HMR 流程图,简要的说明了 HMR 进行模块热更新的过程。当然你可能感觉还是很迷糊,对上面出现的一些英文名词也可能比较陌生(上面这些英文名词代表着代码仓库或者仓库中的文件模块),没关系,在这一部分,我将通过一个最简单最纯粹的例子,通过分析 wepack及 webpack-dev-server 源码详细说明各个库在 HMR 过程中的具体职责。

这边我通过一个简单的vue例子示例一下,这边贴一个仓库的连接github.com/ikkkp/webpack-vue-demo

在开始这个例子之前简单对这个仓库文件进行下说明,仓库中包含文件如下:

webpack-optimization

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const {
    VueLoaderPlugin
} = require('vue-loader');
const webpack = require('webpack'); // 引入 webpack
const AutoImport = require('unplugin-auto-import/webpack')
const Components = require('unplugin-vue-components/webpack')
const {
    ElementPlusResolver
} = require('unplugin-vue-components/resolvers')

/**
* @description 
* @version 1.0
* @author Huangzl
* @fileName webpack.base.config.js
* @date 2023/11/10 11:00:59
*/

module.exports = {
    entry: {
        main: './src/main',
        //单页应用开发模式禁用多入口
    },
    resolveLoader: {
        modules: [
            'node_modules',
            path.resolve(__dirname, './src/loader')
        ]
    },
    output: {
        filename: '[id].[fullhash].js', // 使用 [fullhash] 替代 [hash],这是新版本 webpack 的写法
        path: path.join(__dirname, 'dist'),
        publicPath: './'
    },
    module: {
        rules: [{
            test: /\.vue$/,
            loader: 'vue-loader'
        },
        {
            test: /\.css$/,
            use: [
                'style-loader',
                {
                    loader: 'css-loader',
                    options: {
                        importLoaders: 1
                    }
                },
                'postcss-loader'
            ]
        }, {
            test: /\.js$/,
            use: ['babel-loader', {
                loader: 'company-loader',
                options: {
                    sign: 'we-doctor@2021',
                },
            },],
            exclude: /node_modules/,
        },
        {
            test: /\.(ico|png|jpg|gif|svg|eot|woff|woff2|ttf)$/,
            loader: 'file-loader',
            options: {
                name: '[name].[ext]?[hash]'
            }
        },

        ]
    },

    plugins: [
        new HtmlWebpackPlugin({
            template: './public/index.html'
        }),
        new VueLoaderPlugin(),
        new webpack.DefinePlugin({
            BASE_URL: JSON.stringify('./') // 这里定义了 BASE_URL 为根路径 '/'
        }),
        AutoImport({
            resolvers: [ElementPlusResolver()],
        }),
        Components({
            resolvers: [ElementPlusResolver()],
        }),
    ],
    optimization: {
        splitChunks: {
            chunks: 'all', // 只处理异步模块
            maxSize: 20000000, // 设置最大的chunk大小为2MB
        },
    },
};

值得一提的是,在上面的配置中并没有配置 HotModuleReplacementPlugin,原因在于当我们设置 devServer.hot 为 true 后,并且在package.json 文件中添加如下的 script 脚本:

“start”: “webpack-dev-server –hot –open”

添加 —hot 配置项后,devServer 会告诉 webpack 自动引入 HotModuleReplacementPlugin 插件,而不用我们再手动引入了。

上面给的是webpack.base.config.js的内容,我们下面通过修改App.vue的内容进行:

- <div>hello</div> // 将 hello 字符串修改为 hello world
+ <div>hello world</div>

第一步:webpack 对文件系统进行 watch 打包到内存中

webpack-dev-middleware 调用 webpack 的 api 对文件系统 watch,当 hello.js 文件发生改变后,webpack 重新对文件进行编译打包,然后保存到内存中。

// webpack-dev-middleware/lib/Shared.js
if(!options.lazy) {
    var watching = compiler.watch(options.watchOptions, share.handleCompilerCallback);
    context.watching = watching;
}

你可能会疑问了,为什么 webpack 没有将文件直接打包到 output.path 目录下呢?文件又去了哪儿?原来 webpack 将 bundle.js 文件打包到了内存中,不生成文件的原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销,这一切都归功于memory-fs,memory-fs 是 webpack-dev-middleware 的一个依赖库,webpack-dev-middleware 将 webpack 原本的 outputFileSystem 替换成了MemoryFileSystem 实例,这样代码就将输出到内存中。webpack-dev-middleware 中该部分源码如下:

// webpack-dev-middleware/lib/Shared.js
var isMemoryFs = !compiler.compilers && compiler.outputFileSystem instanceof MemoryFileSystem;
if(isMemoryFs) {
    fs = compiler.outputFileSystem;
} else {
    fs = compiler.outputFileSystem = new MemoryFileSystem();
}

首先判断当前 fileSystem 是否已经是 MemoryFileSystem 的实例,如果不是,用 MemoryFileSystem 的实例替换 compiler 之前的 outputFileSystem。这样 bundle.js 文件代码就作为一个简单 javascript 对象保存在了内存中,当浏览器请求 bundle.js 文件时,devServer就直接去内存中找到上面保存的 javascript 对象返回给浏览器端。

第二步:devServer 通知浏览器端文件发生改变

在这一阶段,sockjs 是服务端和浏览器端之间的桥梁,在启动 devServer 的时候,sockjs 在服务端和浏览器端建立了一个 webSocket 长连接,以便将 webpack 编译和打包的各个阶段状态告知浏览器,最关键的步骤还是 webpack-dev-server 调用 webpack api 监听 compile的 done 事件,当compile 完成后,webpack-dev-server通过 _sendStatus 方法将编译打包后的新模块 hash 值发送到浏览器端。

// webpack-dev-server/lib/Server.js
compiler.plugin('done', (stats) => {
  // stats.hash 是最新打包文件的 hash 值
  this._sendStats(this.sockets, stats.toJson(clientStats));
  this._stats = stats;
});
...
Server.prototype._sendStats = function (sockets, stats, force) {
  if (!force && stats &&
  (!stats.errors || stats.errors.length === 0) && stats.assets &&
  stats.assets.every(asset => !asset.emitted)
  ) { return this.sockWrite(sockets, 'still-ok'); }
  // 调用 sockWrite 方法将 hash 值通过 websocket 发送到浏览器端
  this.sockWrite(sockets, 'hash', stats.hash);
  if (stats.errors.length > 0) { this.sockWrite(sockets, 'errors', stats.errors); } 
  else if (stats.warnings.length > 0) { this.sockWrite(sockets, 'warnings', stats.warnings); }      else { this.sockWrite(sockets, 'ok'); }
};

第三步:webpack-dev-server/client 接收到服务端消息做出响应

可能你又会有疑问,我并没有在业务代码里面添加接收 websocket 消息的代码,也没有在 webpack.config.js 中的 entry 属性中添加新的入口文件,那么 bundle.js 中接收 websocket 消息的代码从哪来的呢?原来是 webpack-dev-server 修改了webpack 配置中的 entry 属性,在里面添加了 webpack-dev-client 的代码,这样在最后的 bundle.js 文件中就会有接收 websocket 消息的代码了。

webpack-dev-server/client 当接收到 type 为 hash 消息后会将 hash 值暂存起来,当接收到 type 为 ok 的消息后对应用执行 reload 操作,如下图所示,hash 消息是在 ok 消息之前。

webpack-optimization

在 reload 操作中,webpack-dev-server/client 会根据 hot 配置决定是刷新浏览器还是对代码进行热更新(HMR)。

首先将 hash 值暂存到 currentHash 变量,当接收到 ok 消息后,对 App 进行 reload。如果配置了模块热更新,就调用 webpack/hot/emitter 将最新 hash 值发送给 webpack,然后将控制权交给 webpack 客户端代码。如果没有配置模块热更新,就直接调用 location.reload 方法刷新页面。

第四步:webpack 接收到最新 hash 值验证并请求模块代码

在这一步,其实是 webpack 中三个模块(三个文件,后面英文名对应文件路径)之间配合的结果,首先是 webpack/hot/dev-server(以下简称 dev-server) 监听第三步 webpack-dev-server/client 发送的 webpackHotUpdate 消息,调用 webpack/lib/HotModuleReplacement.runtime(简称 HMR runtime)中的 check 方法,检测是否有新的更新,在 check 过程中会利用 webpack/lib/JsonpMainTemplate.runtime(简称 jsonp runtime)中的两个方法 hotDownloadUpdateChunkhotDownloadManifest , 第二个方法是调用 AJAX 向服务端请求是否有更新的文件,如果有将发更新的文件列表返回浏览器端,而第一个方法是通过 jsonp 请求最新的模块代码,然后将代码返回给 HMR runtimeHMR runtime 会根据返回的新模块代码做进一步处理,可能是刷新页面,也可能是对模块进行热更新。

webpack-optimization

值得注意的是,两次请求的都是使用上一次的 hash 值拼接的请求文件名,hotDownloadManifest 方法返回的是最新的 hash 值,hotDownloadUpdateChunk 方法返回的就是最新 hash 值对应的代码块。然后将新的代码块返回给 HMR runtime,进行模块热更新

第五步:HotModuleReplacement.runtime 对模块进行热更新

这一步是整个模块热更新(HMR)的关键步骤,而且模块热更新都是发生在HMR runtime 中的 hotApply 方法中

// webpack/lib/HotModuleReplacement.runtime
function hotApply() {
    // ...
    var idx;
    var queue = outdatedModules.slice();
    while(queue.length > 0) {
        moduleId = queue.pop();
        module = installedModules[moduleId];
        // ...
        // remove module from cache
        delete installedModules[moduleId];
        // when disposing there is no need to call dispose handler
        delete outdatedDependencies[moduleId];
        // remove "parents" references from all children
        for(j = 0; j < module.children.length; j++) {
            var child = installedModules[module.children[j]];
            if(!child) continue;
            idx = child.parents.indexOf(moduleId);
            if(idx >= 0) {
                child.parents.splice(idx, 1);
            }
        }
    }
    // ...
    // insert new code
    for(moduleId in appliedUpdate) {
        if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
            modules[moduleId] = appliedUpdate[moduleId];
        }
    }
    // ...
}

从上面 hotApply 方法可以看出,模块热替换主要分三个阶段,第一个阶段是找出 outdatedModulesoutdatedDependencies,这儿我没有贴这部分代码,有兴趣可以自己阅读源码。第二个阶段从缓存中删除过期的模块和依赖,如下:

delete installedModules[moduleId];
delete outdatedDependencies[moduleId];

第三个阶段是将新的模块添加到 modules 中,当下次调用 webpack_require (webpack 重写的 require 方法)方法的时候,就是获取到了新的模块代码了。

模块热更新的错误处理,如果在热更新过程中出现错误,热更新将回退到刷新浏览器,这部分代码在 dev-server 代码中,简要代码如下:

module.hot.check(true).then(function(updatedModules) {
    if(!updatedModules) {
        return window.location.reload();
    }
    // ...
}).catch(function(err) {
    var status = module.hot.status();
    if(["abort", "fail"].indexOf(status) >= 0) {
        window.location.reload();
    }
});

dev-server 先验证是否有更新,没有代码更新的话,重载浏览器。如果在 hotApply 的过程中出现 abort 或者 fail 错误,也进行重载浏览器。

webpack-optimization

引言

loader 是导出为一个函数的 node 模块。该函数在 loader 转换资源的时候调用。给定的函数将调用 Loader API,并通过 this 上下文访问。

这边贴一个官网链接loader的用法和例子,以及自定义loader本地开发测试

Webpack Loader的简单使用

当一个 loader 在资源中使用,这个 loader 只能传入一个参数 - 一个包含资源文件内容的字符串。

同步 loader 可以 return 一个代表已转换模块(transformed module)的单一值。

loader 会返回一个或者两个值。第一个值的类型是 JavaScript 代码的字符串或者 buffer。第二个可选值是 SourceMap,它是个 JavaScript 对象。

下面是一个简单的loader的用法,他将匹配所有的js文件,并使用loader.js处理

//webpack.config.js
const path = require('path');
module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: path.resolve('path/to/loader.js'),
            options: {
              /* ... */
            },
          },
        ],
      },
    ],
  },
};

由上面我们可以知道loader的使用方法,但对loader仅停留在使用,那具体的一个loader长什么样呢?

比如说一个简单的loader是这样的:

module.exports = function (content) {
	// content 就是传入的源内容字符串
  return content
}

一个 loader 就是一个node模块,其中暴露了一个函数,并只可以接收一个入参,这个参数是一个包含包含资源文件内容的字符串,而函数的返回值就是处理后的内容。

自定义webpack loader

自定义loader的用法准则

编写 loader 时应该遵循以下准则。它们按重要程度排序,有些仅适用于某些场景,请阅读下面详细的章节以获得更多信息。

  • 保持 简单 。
  • 使用 链式 传递。
  • 模块化 的输出。
  • 确保 无状态 。
  • 使用 loader utilities
  • 记录 loader 的依赖 。
  • 解析 模块依赖关系 。
  • 提取 通用代码 。
  • 避免 绝对路径 。
  • 使用 peer dependencies

步骤1:创建项目目录和文件

首先,在一个webpack项目目录中的文件夹中创建以下文件:

  • src/loader/custom-loader.js:自定义Loader的源文件。
  • src/index.js:JavaScript入口文件,用于测试自定义Loader。

步骤2:编写自定义Loader

custom-loader.js 文件中,编写你的自定义loader代码。这个Loader的作用是将在每个加载的JavaScript文件的顶部添加一个注释。

// src/loader/custom-loader.js
module.exports = function(source) {
    // 在源代码的顶部添加自定义注释
    const updatedSource = `/** Custom Comment added by Custom Loader */\n${source}`;
    return updatedSource;
};

步骤3:配置Webpack

在项目根目录下创建Webpack配置文件 webpack.config.js。在配置文件中,使用刚刚编写的自定义Loader。

// webpack.config.js
const path = require('path');

module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist'),
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                use: ['custom-loader'], // 使用自定义Loader处理.js文件
                exclude: /node_modules/,
            },
        ],
    },
};

功能就简单的进行了一下实现,这里我们主要说一下如何测试调用我们的本地的 loader,方式有两种,一种是通过 Npm link 的方式进行测试,这边贴一个Npm link的链接,大家可以去创建一个软连接进行本地测试,还是挺方便的npm-link。 另外一种就是直接在项目里面进行路径配置:

单loader配置方法

//webpack.config.js
{
  test: /\.js$/
  use: [
    {
      loader: path.resolve('path/to/custom-loader.js'),
      options: {/* ... */}
    }
  ]
}

多loader配置方法

当然也可以通过数组的方式进行配置

//webpack.config.js
resolveLoader: {
  // 这里就是说先去找 node_modules 目录中,如果没有的话再去 loaders 目录查找
  modules: [
    'node_modules',
    path.resolve(__dirname, 'custom-loader')
  ]
}

步骤4:测试自定义Loader

index.js 文件中,编写一些JavaScript代码,例如:

// src/index.js
console.log('Hello, Webpack Loader!');

步骤5:运行Webpack构建

运行以下命令来构建你的项目:

npx webpack --config webpack.config.js

构建完成后,你将在 dist 文件夹中找到生成的 bundle.js 文件。在这个文件里面可以看到在顶部添加了自定义注释的JavaScript代码。


Webpack plugin的简单使用

插件向第三方开发者提供了 webpack 引擎中完整的能力。使用阶段式的构建回调,开发者可以在 webpack 构建流程中引入自定义的行为。

比如说最简单的一个例子:

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: __dirname + '/dist',
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: './src/index.html', // 指定HTML模板文件
            filename: 'index.html', // 生成的HTML文件名
        }),
        // 可以添加更多的插件
    ],
};

在上面这个例子里面使用了HtmlWebpackPlugin插件,根据指定的HTML模板生成一个新的HTML文件,并将打包后的JavaScript文件自动添加到生成的HTML文件中。

一个基本的webpack 插件由以下组成:

  • 一个 JavaScript 命名函数或 JavaScript 类。

  • 在插件函数的 prototype 上定义一个 apply 方法,apply 方法在 webpack 装载这个插件的时候被调用,并且会传入 compiler 对象。。

  • 指定一个绑定到 webpack 自身的事件钩子。

  • 处理 webpack 内部实例的特定数据。

  • 功能完成后调用 webpack 提供的回调。

一个插件结构如下:

class HelloWorldPlugin {
  apply(compiler) {
    compiler.hooks.done.tap(
      'Hello World Plugin',
      (
        stats /* 绑定 done 钩子后,stats 会作为参数传入。 */
      ) => {
        console.log('Hello World!');
      }
    );
  }
}

module.exports = HelloWorldPlugin;

Compiler and Compilation

在插件开发中最重要的两个资源就是 compiler 和 compilation 对象。可以说Webpack plugin的开发就是围绕着这两个对象的 hook 进行操作

compiler 对象可以理解为一个和 webpack 环境整体绑定的一个对象,它包含了所有的环境配置,包括 options,loader 和 plugin,当 webpack 启动时,这个对象会被实例化,并且他是全局唯一的,上面我们说到的 apply 方法传入的参数就是它。

compilation 在每次构建资源的过程中都会被创建出来,一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。它同样也提供了很多的 hook 。

自定义Webpack plugin

步骤1:创建项目目录和文件

首先,还是需要一个webpack项目。我们在这个文件夹中创建以下文件:

  • src/plugins/CustomPlugin.js:自定义插件的源文件。

步骤2:编写自定义插件

CustomPlugin.js 文件中,我们编写了一个插件,并将在Webpack构建结束时输出一条信息。

// src/plugins/CustomPlugin.js
class CustomPlugin {
    apply(compiler) {
        compiler.hooks.done.tap('CustomPlugin', () => {
            console.log('CustomPlugin: Webpack build process is done!');
        });
    }
}

module.exports = CustomPlugin;

步骤3:配置Webpack

在配置文件中,使用上面我们的自定义插件。

// webpack.config.js
const CustomPlugin = require('./src/plugins/CustomPlugin');

module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: __dirname + '/dist',
    },
    plugins: [
        new CustomPlugin(),
        // 可以添加更多的插件
    ],
};

步骤4:运行Webpack构建

现在进行Webpack构建:

npx webpack --config webpack.config.js

webpack v4 开始新增了一个 sideEffects 特性,通过给 package.json 加入 sideEffects 声明该 包/模块 是否包含 sideEffects(副作用),从而可以为 tree-shaking 提供更大的优化空间。

基于我们对 side effect 的常规理解,我们可以认为,只要我们确定当前包里的模块不包含副作用,然后将发布到 npm 里的包标注为 sideEffects: false ,我们就能为使用方提供更好的打包体验。原理是 webpack 能将标记为 side-effects-free 的包由 import {a} from xx 转换为 import {a} from 'xx/a',从而自动修剪掉不必要的 import,作用同 babel-plugin-import

Tree Shaking 与副作用

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

Tree-Shaking的原理

ES6的模块引入是静态分析的,故而可以在编译时正确判断到底加载了什么代码。

分析程序流,判断哪些变量未被使用、引用,进而删除此代码。

很好,原理非常完美,那为什么有时候我们项目里面多余的的代码又删不掉呢?

先说原因:都是副作用的锅!

副作用

了解过函数式编程的同学对副作用这词肯定不陌生。它大致可以理解成:一个函数会、或者可能会对函数外部变量产生影响的行为。

举个例子,比如这个函数:

function go (url) {
  window.location.href = url
}

这个函数修改了全局变量location,甚至还让浏览器发生了跳转,这就是一个有副作用的函数。

// componetns.js
export class Person {
  constructor ({ name }) {
    this.className = 'Person'
    this.name = name
  }
  getName () {
    return this.name
  }
}
export class Apple {
  constructor ({ model }) {
    this.className = 'Apple'
    this.model = model
  }
  getModel () {
    return this.model
  }
}
// main.js
import { Apple } from './components'

const appleModel = new Apple({
  model: 'IphoneX'
}).getModel()

console.log(appleModel)

很显然这个Person类是无用的代码

而为什么有时候别的工具,比如rollup在线repl尝试了下tree-shaking,也确实删掉了无用的代码

而使用webpack打包工具却不能进行有效的代码消除呢?

答案是:babel编译 + webpack打包

在这边贴一个链接,是有关于详细介绍babel编译 + webpack打包是怎么让你无效的代码消除不掉的。你的Tree-Shaking并没什么卵用

如果不想看文章的话,这边直接简单说一下原理:babel编译会使得Person类被封装成了一个IIFE(立即执行函数),然后返回一个构造函数,在这边就产生了一个副作用。

这边有个Issues,IIFE 中的类声明被视为副作用

当我在 IIFE 中声明一个类,但没有使用类时,它不会被 UglifyJS 剥离,因为它被认为是副作用。

var V6Engine = (function () {
    function V6Engine() {
    }
    V6Engine.prototype.toString = function () {
        return 'V6';
    };
    return V6Engine;
}());

编译时收到这样的警告:WARN: Side effects in initialization of unused variable V6Engine [./dist/car.bundle.js:74,4]

下面给出的回复:Uglify 没做执行程序流分析。它并不会因为你注意到的副作用而删除代码。你要是想弄个完善一点的摇树,去隔壁rollup呗!

issue中总结下几点关键信息:

函数的参数若是引用类型,对于它属性的操作,都是有可能会产生副作用的。因为首先它是引用类型,对它属性的任何修改其实都是改变了函数外部的数据。其次获取或修改它的属性,会触发getter或者setter,而gettersetter是不透明的,有可能会产生副作用。

uglify没有完善的程序流分析。它可以简单的判断变量后续是否被引用、修改,但是不能判断一个变量完整的修改过程,不知道它是否已经指向了外部变量,所以很多有可能会产生副作用的代码,都只能保守的不删除。

rollup有程序流分析的功能,可以更好的判断代码是否真正会产生副作用。

但这已经是很久之前的版本问题,现在的webpack tree shaking已经做了很多的优化,足够的程序流分析进行tree shaking

webpack 的 tree shaking 的作用是可以将未被使用的 exported member 标记为 unused 同时在将其 re-export 的模块中不再 export。说起来很拗口,看代码:

// a.js
export function a() {}
// b.js
export function b(){}
// package/index.js
import a from './a'
import b from './b'
export { a, b }
// app.js
import {a} from 'package'
console.log(a)

当我们以 app.js 为 entry 时,经过摇树后的代码会变成这样:

// a.js
export function a() {}
// b.js 不再导出 function b(){}
function b() {}
// package/index.js 不再导出 b 模块
import a from './a'
import './b'
export { a }
// app.js
import {a} from 'package'
console.log(a)

配合 webpack 的 scope hoistinguglify 之后,b 模块的痕迹会被完全抹杀掉。

但是如果 b 模块中添加了一些副作用,比如一个简单的 log:

// b.js
export function b(v) { reutrn v }
console.log(b(1))
webpack 之后会发现 b 模块内容变成了:

// b.js
console.log(function (v){return v}(1))

虽然 b 模块的导出是被忽略了,但是副作用代码被保留下来了。

由于目前 transformer 转换后可能引入的各种奇怪操作引发的副作用,很多时候我们会发现就算有了 tree shaking 我们的 bundle size 还是没有明显的减小。

而通常我们期望的是 b 模块既然不被使用了,其中所有的代码应该不被引入才对。

这个时候 sideEffects 的作用就显现出来了:如果我们引入的 包/模块 被标记为 sideEffects: false 了,那么不管它是否真的有副作用,只要它没有被引用到,整个 模块/包 都会被完整的移除。

以 mobx-react-devtool 为例,我们通常这样去用:

import DevTools from 'mobx-react-devtools';

class MyApp extends React.Component {
  render() {
    return (
      <div>
        ...
        { process.env.NODE_ENV === 'production' ? null : <DevTools /> }
      </div>
    );
  }
}

这是一个很常见的按需导入场景,然而在没有 sideEffects: false 配置时,即便 NODE_ENV 设为 production ,打包后的代码里依然会包含 mobx-react-devtools 包,虽然我们没使用过其导出成员,但是 mobx-react-devtools 还是会被 import,因为里面“可能”会有副作用。

但当我们加上 sideEffects false 之后,tree shaking 就能安全的把它从 bundle 里完整的移除掉了。

sideEffects 的使用场景

上面也说到,通常我们发布到 npm 上的包很难保证其是否包含副作用(可能是代码的锅可能是 transformer 的锅),但是我们基本能确保这个包是否会对包以外的对象产生影响,比如是否修改了 window 上的属性,是否复写了原生对象方法等。如果我们能保证这一点,其实我们就能知道整个包是否能设置 sideEffects: false了,至于是不是真的有副作用则并不重要,这对于 webpack 而言都是可以接受的。

这也就能解释为什么能给 vue 这个本身充满副作用的包加上 sideEffects: false 了。

所以其实 webpack 里的 sideEffects: false 的意思并不是我这个模块真的没有副作用,而只是为了在摇树时告诉 webpack:我这个包在设计的时候就是期望没有副作用的,即使他打完包后是有副作用的。

性能优化 - JS-CSS代码压缩

  • Terser是一个JavaScript的解释(Parser)、Mangler(绞肉机)/Compressor(压缩机)的工具集;
  • 早期我们会使用 uglify-js来压缩、丑化我们的JavaScript代码,但是目前已经不再维护,并且不支持ES6+的语法;
  • Terser是从 uglify-es fork 过来的,并且保留它原来的大部分API以及适配 uglify-es和uglify-js@3等;

webpack-terser

JavaScript 代码压缩

Webpack 提供了terser-webpack-plugin 插件进行代码优化和压缩。

在production模式下,默认就是使用TerserPlugin来处理代码。

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  // 配置其他Webpack选项...

  optimization: {
    minimizer: [new TerserPlugin()],
  },
};

CSS 代码压缩

除了JavaScript代码,CSS代码也可以通过Webpack进行压缩。使用css-minimizer-webpack-plugin 进行压缩CSS代码。

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  // 配置其他Webpack选项...

  optimization: {
    minimizer: [
      new CssMinimizerPlugin(),
      // 可以继续添加其他压缩插件...
    ],
  },
};

webpack实现Tree Shaking

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。

Webpack 实现 Tree Shaking

在现代的前端开发中,代码体积优化是一个关键的议题。Tree Shaking 是一种用于消除未引用代码的优化技术,它可以帮助我们剔除项目中未使用的 JavaScript 模块,从而减小打包后的文件体积。Webpack 提供了内置的支持,使得 Tree Shaking 在项目中变得非常容易实现。

开启 ES 模块化

首先,确保你的 JavaScript 代码采用了 ES 模块化的方式,因为Webpack 的 Tree Shaking 功能仅对 ES 模块有效。你可以在项目中使用 importexport 语法来定义模块。

// math.js
export function square(x) {
  return x * x;
}

export function cube(x) {
  return x * x * x;
}

Webpack 配置

在 Webpack 的配置文件中,确保以下几点设置,以启用 Tree Shaking:

mode 设置为 'production',Webpack 会自动启用相关的优化,包括 Tree Shaking。

JS实现Tree Shaking

webpack实现Tree Shaking采用了两种不同的方案:

  • usedExports:通过标记某些函数是否被使用,之后通过Terser来进行优化的;
  • sideEffects:跳过整个模块/文件,直接查看该文件是否有副作用;

使用usedExports实现Tree Sharking

配置模式为production

module.exports = {
  mode: 'production',
  // ...其他配置
};

配置optimization里面的usedExports

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
 mode: 'development',
 optimization: {
   usedExports: true,
 },
};

使用sideEffect实现Tree Sharking

在package.json中设置sideEffects的值:

  • 如果我们将sideEffects设置为false,就是告知webpack可以安全的删除未用到的exports;

  • 如果有一些我们希望保留,可以设置为数组;

{
  "name": "your-project",
  "sideEffects": ["./src/some-side-effectful-file.js"]
}

Webpack 中的 sideEffects

解释 tree shaking 和 sideEffects

sideEffectsusedExports(更多被认为是 tree shaking)是两种不同的优化方式。

sideEffects 更为有效 是因为它允许跳过整个模块/文件和整个文件子树。

usedExports 依赖于 terser 去检测语句中的副作用。它是一个 JavaScript 任务而且没有像 sideEffects 一样简单直接。而且它不能跳转子树/依赖由于细则中说副作用需要被评估。尽管导出函数能运作如常,但 React 框架的高阶函数(HOC)在这种情况下是会出问题的。

CSS实现TreeShaking

CSS的Tree Shaking需要借助于一些其他的插件;

在早期的时候,我们会使用PurifyCss插件来完成CSS的tree shaking,但是目前该库已经不再维护了(最新更新也是在4年前 了);

目前我们可以使用另外一个库来完成CSS的Tree Shaking:PurgeCSS,也是一个帮助我们删除未使用的CSS的工具;

Webpack对文件压缩

什么是HTTP压缩

HTTP压缩是一种内置在 服务器 和 客户端 之间的,以改进传输速度和带宽利用率的方式;
HTTP压缩的流程什么呢?
第一步:HTTP数据在服务器发送前就已经被压缩了;(可以在webpack中完成)
第二步:兼容的浏览器在向服务器发送请求时,会告知服务器自己支持哪些压缩格式;
第三步:服务器在浏览器支持的压缩格式下,直接返回对应的压缩后的文件,并且在响应头中告知浏览器;

目前的流行压缩格式

目前的压缩格式非常的多:
compress – UNIX的“compress”程序的方法(历史性原因,不推荐大多数应用使用,应该使用gzip或deflate);
deflate – 基于deflate算法(定义于RFC 1951)的压缩,使用zlib数据格式封装;
gzip – GNU zip格式(定义于RFC 1952),是目前使用比较广泛的压缩算法;
br – 一种新的开源压缩算法,专为HTTP内容的编码而设计;

Webpack配置文件压缩

webpack中相当于是实现了HTTP压缩的第一步操作,我们可以使用CompressionPlugin。

第一步,安装CompressionPlugin:

npm install compression-webpack-plugin -D

第二步,使用CompressionPlugin即可

module.exports = {
  plugins: [
    new CompressionPlugin({
      test: /\.js(\?.*)?$/i,
    }),
  ],
};

前言

先来说说为什么要优化?当然如果你的项目很小,构建很快,其实不需要特别关注性能方面的问题。

但是随着项目涉及到的页面越来越多,功能和业务代码也会越来越多,相应的 webpack 的构建时间也会越来越久,这个时候我们就不得不考虑性能优化的事情了。

webpack 的性能优化较多,我们考虑从两方面入手:
优化一:打包后的结果,上线时的性能优化。(比如分包处理、减小包体积、CDN服务器等)
优化二:优化打包速度,开发或者构建时优化打包速度。(比如 excludecache-loader等)

因为这个上线时的性能是直接影响到用户使用体验的,而构建时间与我们的日常开发是密切相关,当我们本地开发启动 devServer 或者 build 的时候,如果时间过长,会大大降低我们的工作效率。

性能优化 - 代码分离

代码分离(Code Splitting)是 webpack 一个非常重要的特性:

它主要的目的是将代码分离到不同的 bundle 中,之后我们可以按需加载,或者并行加载这些文件;
比如默认情况下,所有的 JavaScript 代码(业务代码、第三方依赖、暂时没有用到的模块)在首页全部都加载,就会影响首页的加载速度;
代码分离可以分出更小的 bundle ,以及控制资源加载优先级,提供代码的加载性能;

Webpack中常用的代码分离有三种:

  • 入口起点:使用entry配置手动分离代码
  • 防止重复:使用Entry Dependencies或者SplitChunksPlugin去重和分离代码;
  • 动态导入:通过模块的内联函数调用来分离代码;

入口起点优化-Entry Dependencies(入口依赖)

当项目拥有多个入口点(entry points)时,可能会遇到一些重复依赖的问题。某些模块可能在多个入口点中被引用,导致这些模块被重复打包,增加了最终输出文件的体积。
dependon-shared模块解决重复依赖

module.exports = {
  entry: {
    page1: {
      import: './src/page1.js',
      dependOn: 'shared',
    },
    page2: {
      import: './src/page2.js',
      dependOn: 'shared',
    },
    shared: './src/shared.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: __dirname + '/dist',
  },
}; 

动态导入(dynamic import)

动态导入是一种在Webpack中实现按需加载(Lazy Loading)的技术,允许在运行时异步加载模块,而不是在应用初始化时就把所有模块打包到一个大文件中。可以提高应用的初始加载速度,并且减小了初始包的体积。

const path = require('path');

module.exports = {
  entry: {
    main: './src/index.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
  module: {
    rules: [
      // 添加你的Loader规则
    ],
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
};

在上述配置中,通过 optimization.splitChunks 进行代码分割,它的 chunks: 'all' 选项表示对所有模块进行代码分割。

然后,在代码中使用 import() 函数进行动态导入:

// 在需要的地方使用动态导入
const loadModule = () => import('./Module');

loadModule().then(module => {
  // 使用加载的模块
});

Webpack会将使用 import() 函数引入的模块进行代码分割,生成单独的文件。
在运行时,这些文件会在需要的时候异步加载。

自定义分包-SplitChunks

分包(code splitting)是一种优化策略,它允许将代码分割成小块,使得应用在加载时能够更快地显示内容。

Webpack提供了多种分包的模式,其中一种是使用SplitChunksPlugin插件来实现的,这个模式叫做splitChunks

module.exports = {
  // ...其他配置
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 30000, // 模块的最小体积
      minChunks: 1, // 模块的最小被引用次数
      maxAsyncRequests: 5, // 按需加载时的最大并行请求数
      maxInitialRequests: 3, // 入口点的最大并行请求数
      automaticNameDelimiter: '~',
      name: true,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

webpack-split-chunks-plugin

性能优化-CDN

CDN称之为内容分发网络(Content Delivery Network或Content Distribution Network,缩写:CDN), 它是指通过相互连接的网络系统,利用最靠近每个用户的服务器; 更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户; 来提供高性能、可扩展性及低成本的网络内容传递给用户;

在开发中,我们使用CDN主要是两种方式:

  • 打包的所有静态资源,放到CDN服务器, 用户所有资源都是通过CDN服务器加载的;
  • 一些第三方资源放到CDN服务器上;

使用CDN(Content Delivery Network,内容分发网络)是一种非常有效的性能优化策略,特别是在Webpack中。CDN可以加速网站的加载速度,减轻服务器负担,并提高用户体验。以下是如何在Webpack中配置和使用CDN的方法:

将第三方库引入CDN

将你的项目中用到的第三方库(例如React、Vue、jQuery等)引入CDN。可以选择在HTML文件中直接引入CDN链接:

<script src="https://cdn.jsdelivr.net/npm/react@版本号/dist/react.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@版本号/dist/react-dom.min.js"></script>

在Webpack中配置externals

在Webpack的配置中使用externals字段,告诉Webpack哪些模块是外部引入的,不需要打包。

module.exports = {
  // ...其他配置
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
  },
};

然后在HTML文件中通过script标签引入CDN:

<script src="https://cdn.jsdelivr.net/npm/react@版本号/dist/react.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@版本号/dist/react-dom.min.js"></script>

配置CDN的publicPath

在Webpack的output字段中配置publicPath,指定在引入资源时使用的URL前缀,通常设置为CDN的地址:

module.exports = {
  // ...其他配置
  output: {
    // ...其他output配置
    publicPath: 'https://cdn.example.com/',
  },
};

这样在Webpack构建时,所有的资源引用路径都会加上CDN的地址前缀。

性能优化-提取css文件

将CSS文件从JavaScript打包文件中提取出来是一种常见的性能优化策略。这样做的好处是可以减小JavaScript文件的体积,加快页面加载速度,并且使浏览器能够并行下载CSS和JavaScript文件,提高加载性能。在Webpack中,你可以使用mini-css-extract-plugin插件来实现CSS文件的提取。

配置Webpack

在Webpack配置文件中引入mini-css-extract-plugin插件,然后配置module.rules来处理CSS文件。

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  // ...其他配置

  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          // 可以加入其他的CSS处理loader,比如postcss-loader和sass-loader
        ],
      },
    ],
  },

  plugins: [
    new MiniCssExtractPlugin({
      filename: 'styles.css', // 提取出的CSS文件的文件名
    }),
  ],
};

引入CSS文件

在JavaScript文件或者入口文件中引入CSS文件:

import './styles.css';

或者在HTML文件中使用link标签引入提取出来的CSS文件:

<link rel="stylesheet" href="styles.css">

性能优化-打包文件命名(Hash,ContentHash,ChunkHash)

在Webpack中,打包文件的命名是一个重要的性能优化策略。合适的命名方案可以确保浏览器能够正确地缓存文件,避免不必要的网络请求,提高应用的加载速度。以下是三种常见的打包文件命名方式:Hash、ContentHash 和 ChunkHash。

Hash(哈希)

Hash 是根据文件内容生成的哈希值,当文件内容发生改变时,其对应的 Hash 值也会改变。在Webpack中,可以使用 [hash] 占位符来表示 Hash 值。

output: {
  filename: 'bundle.[hash].js',
}

ContentHash(内容哈希)

ContentHash 是根据文件内容生成的哈希值,但是不同于 Hash 的是,ContentHash 只会受到文件内容的影响,不会受到文件名或路径等其他因素的影响。在Webpack中,可以使用 [contenthash] 占位符来表示 ContentHash 值。

output: {
  filename: 'bundle.[contenthash].js',
}

ChunkHash(块哈希)

ChunkHash 是根据模块内容生成的哈希值,不同模块的内容不同,它们的 ChunkHash 值也会不同。在Webpack中,可以使用 [chunkhash] 占位符来表示 ChunkHash 值。

output: {
  filename: '[name].[chunkhash].js',
}

性能优化-webpack实现Tree Shaking

JavaScript的Tree Shaking:

对JavaScript进行Tree Shaking是源自打包工具rollup(后面我们也会讲的构建工具);

这是因为Tree Shaking依赖于ES Module的静态语法分析(不执行任何的代码,可以明确知道模块的依赖关系);

webpack2正式内置支持了ES2015模块,和检测未使用模块的能力;

在webpack4正式扩展了这个能力,并且通过 package.json的 sideEffects属性作为标记,告知webpack在编译时,哪里文 件可以安全的删除掉;

webpack5中,也提供了对部分CommonJS的tree shaking的支持;

commonjs-tree-shaking

JS实现Tree Shaking

webpack实现Tree Shaking采用了两种不同的方案:

  • usedExports:通过标记某些函数是否被使用,之后通过Terser来进行优化的;
  • sideEffects:跳过整个模块/文件,直接查看该文件是否有副作用;

CSS进行Tree Shaking

CSS的Tree Shaking需要借助于一些其他的插件;

在早期的时候,我们使用PurifyCss插件来完成CSS的tree shaking,但是目前该库已经不再维护;

目前我们可以使用另外一个库来完成CSS的Tree Shaking:PurgeCSS,也是一个帮助我们删除未使用的CSS的工具: PurgeCss