Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

关于webpack打包优化的一点实验 #34

Open
mhfe123 opened this issue Sep 29, 2019 · 0 comments
Open

关于webpack打包优化的一点实验 #34

mhfe123 opened this issue Sep 29, 2019 · 0 comments

Comments

@mhfe123
Copy link
Contributor

mhfe123 commented Sep 29, 2019

我们都知道每次启动项目的时候重新打包都要花费很长时间,有时候电脑卡,性能不好可能时间会更久,那么提高我们打包的速度就是我们绕不开的话题。那么webpack打包为什么会慢,总结来说就是三点:

  • 文件检索

    webpack启动后会从配置的entry出发,解析文件的导入语句,在递归解析,需要经过大量的循环去匹配导入语句所对应的文件,当项目越来越庞大时,这会非常耗费时间。

  • 重复打包

    当有多个文件都引用同一个第三方模块的时候,每次引用都去解析这个模块的地址,造成重复的计算。

  • 配置中的loader对文件的处理与编译

    像我们前端代码,用的很多框架,新的语法等可能不能直接在浏览器中运行,需要将它转成浏览器可运行的js版本等,这部分过程也是非常耗时的。

实验一:缩小文件的搜索范围

当我们在写代码的时候,引用文件的语句尽量写完整,比如后缀补全,能确定位置的文件尽量使用绝对路径,当然这种做法有点本末倒置,毕竟有了很多插件帮我们实现这些东西,而且经实践表明这方面对webpack构建的提升貌似没有太大的影响,甚至还会负影响(因为随手写的demo比较小,可能结果不是很准确,需要后续在确认)。

那么回归正题,因为我们前端经常会引用第三方包,众所周知,node_modules文件夹里的海量文件,光看着就头晕,所以我们优化大部分都是对准第三方库的。

优化loader配置

由于loader对文件的转换操作很耗时,所以我们要让尽可能少的文件被处理,可以通过test、include、exclude三个配置项来命中loader所要的文件,尽可能减少需要处理的文件,以vue为例:

rules: [
     {
       test: /\.vue$/,
       loader: 'vue-loader',
       // exclude: /node_modules/,
       include: path.resolve(__dirname, '../src')
     }
]

优化resolve.modules配置

resolve.modules 的默认值是 ['node_modules'],含义是先去当前目录下的 ./node_modules 目录下去找想找的模块,如果没找到就去上一级目录 ../node_modules 中找,再没有就去 ../../node_modules 中找,以此类推,这和 Node.js 的模块寻找机制很相似。

当安装的第三方模块都放在项目根目录下的 ./node_modules 目录下时,没有必要按照默认的方式去一层层的寻找,可以指明存放第三方模块的绝对路径,以减少寻找,配置如下:

module.exports = {
  resolve: {
    // 使用绝对路径指明第三方模块存放的位置,以减少搜索步骤
    // 其中 __dirname 表示当前工作目录,也就是项目根目录
    modules: [path.resolve(__dirname, 'node_modules')]
  },
};

优化resolve.mainFields配置

安装的第三方模块中都会有一个 package.json 文件用于描述这个模块的属性,其中有些字段用于描述入口文件在哪里,resolve.mainFields 用于配置采用哪个字段作为入口文件的描述。

可以存在多个字段描述入口文件的原因是因为有些模块可以同时用在多个环境中,准对不同的运行环境需要使用不同的代码。有的用于浏览器环境,有的用于node.js环境,为了减少搜索步骤,在你明确第三方模块的入口文件描述字段时,你可以把它设置的尽量少。 由于大多数第三方模块都采用 main 字段去描述入口文件的位置,可以这样配置 Webpack:

module.exports = {
  resolve: {
    // 只采用 main 字段作为入口文件描述字段,以减少搜索步骤
    mainFields: ['main'],
  },
};

使用本方法,需要考虑所有运行时依赖的第三方模块入口文件的描述字段,有一个模块出错,都有可能造成构建的代码无法运行(个人不建议使用)。

优化resolve.alias配置

在项目中,我们使用的一些第三方包会比较大,以Vue为例,安装到node_modules目录下的Vue库包含很多个js文件,dist/vue.js用于开发环境,dist/vue.min.js 用于线上环境,默认情况下webpack会从入口文件./node_modules/vue/vue.js开始递归解析和处理依赖的几十个文件,这非常耗时,我们可以通过配置直接引用单独的、完整的vue.min.js文件,从而跳过耗时的递归与解析,配置如下:

resolve: {
    alias: {
      vue: path.resolve(__dirname, '../node_modules/vue/dist/vue.min.js')
    },
  },

但是,使用本方法以后可能会让我们代码中含有很多可能永远不会被执行的代码,来自第三方库的,会影响到Tree-Sharking去删除无效代码的优化,所以不建议在线上使用。

优化resolve.extensions配置

用于配置在尝试过程中用到的后缀列表,默认是:

extensions: ['.js', '.json']

也就是说当遇到 require('./data') 这样的导入语句时,Webpack 会先去寻找 ./data.js 文件,如果该文件不存在就去寻找 ./data.json 文件,如果还是找不到就报错。

如果这个列表越长,或者正确的后缀在越后面,就会造成尝试的次数越多,所以 resolve.extensions 的配置也会影响到构建的性能。 在配置 resolve.extensions 时你需要遵守以下几点,以做到尽可能的优化构建性能:

  • 后缀尝试列表要尽可能的小,不要把项目中不可能存在的情况写到后缀尝试列表中。
  • 频率出现最高的文件后缀要优先放在最前面,以做到尽快的退出寻找过程。
  • 在源码中写导入语句时,要尽可能的带上后缀,从而可以避免寻找过程。例如在你确定的情况下把 require('./data') 写成 require('./data.json')

相关 Webpack 配置如下:

module.exports = {
  resolve: {
    // 尽可能的减少后缀尝试的可能性
    extensions: ['js'],
  },
};

可能是demo原因,测试用例比较少,得出结果并没有什么卵用,甚至还有负作用,需要后续在实验。

优化 module.noParse 配置

module.noParse 配置项可以让 Webpack 忽略对部分没采用模块化的文件的递归解析处理,这样做的好处是能提高构建性能。 原因是一些库,例如 jQuery 、ChartJS, 它们庞大又没有采用模块化标准,让 Webpack 去解析这些文件耗时又没有意义。

module: {
    noParse: [/vue\.min\.js$/]
}

注意被忽略的文件里不要包含importrequiredefine等模块化语句。

实验二:使用DllPlugin

在介绍 DllPlugin 前先给大家介绍下 DLL。 用过 Windows 系统的人应该会经常看到以 .dll 为后缀的文件,这些文件称为动态链接库,在一个动态链接库中可以包含给其他模块调用的函数和数据。

要给 Web 项目构建接入动态链接库的思想,需要完成以下事情:

  • 把网页依赖的基础模块抽离出来,打包到一个个单独的动态链接库中去。一个动态链接库中可以包含多个模块。
  • 当需要导入的模块存在于某个动态链接库中时,这个模块不能被再次被打包,而是去动态链接库中获取。
  • 页面依赖的所有动态链接库需要被加载。

为什么给 Web 项目构建接入动态链接库的思想后,会大大提升构建速度呢? 原因在于包含大量复用模块的动态链接库只需要编译一次,在之后的构建过程中被动态链接库包含的模块将不会在重新编译,而是直接使用动态链接库中的代码。 由于动态链接库中大多数包含的是常用的第三方模块,例如 react、react-dom,只要不升级这些模块的版本,动态链接库就不用重新编译。

实验三: 使用HappyPack

我们都知道webpack的打包跟计算机性能有很大关系,但是我们通常使用webpack的时候就跟我们js执行一样,都是单线程的,所以如果可以重新利用计算机多核的设计,也可以提高我们的打包速度。

使用 HappyPack

分解任务和管理线程的事情 HappyPack 都会帮你做好,你所需要做的只是接入 HappyPack。 接入 HappyPack 的相关代码如下:

const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const HappyPack = require('happypack');

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        // 把对 .js 文件的处理转交给 id 为 babel 的 HappyPack 实例
        use: ['happypack/loader?id=babel'],
        // 排除 node_modules 目录下的文件,node_modules 目录下的文件都是采用的 ES5 语法,没必要再通过 Babel 去转换
        exclude: path.resolve(__dirname, 'node_modules'),
      },
      {
        // 把对 .css 文件的处理转交给 id 为 css 的 HappyPack 实例
        test: /\.css$/,
        use: ExtractTextPlugin.extract({
          use: ['happypack/loader?id=css'],
        }),
      },
    ]
  },
  plugins: [
    new HappyPack({
      // 用唯一的标识符 id 来代表当前的 HappyPack 是用来处理一类特定的文件
      id: 'babel',
      // 如何处理 .js 文件,用法和 Loader 配置中一样
      loaders: ['babel-loader?cacheDirectory'],
      // ... 其它配置项
    }),
    new HappyPack({
      id: 'css',
      // 如何处理 .css 文件,用法和 Loader 配置中一样
      loaders: ['css-loader'],
    }),
    new ExtractTextPlugin({
      filename: `[name].css`,
    }),
  ],
};

以上代码有两点重要的修改:

  • 在 Loader 配置中,所有文件的处理都交给了 happypack/loader 去处理,使用紧跟其后的 querystring ?id=babel 去告诉 happypack/loader 去选择哪个 HappyPack 实例去处理文件。
  • 在 Plugin 配置中,新增了两个 HappyPack 实例分别用于告诉 happypack/loader 去如何处理 .js 和 .css 文件。选项中的 id 属性的值和上面 querystring 中的 ?id=babel 相对应,选项中的 loaders属性和 Loader 配置中一样。

在实例化 HappyPack 插件的时候,除了可以传入 idloaders 两个参数外,HappyPack 还支持如下参数:

  • threads 代表开启几个子进程去处理这一类型的文件,默认是3个,类型必须是整数。
  • verbose 是否允许 HappyPack 输出日志,默认是 true
  • threadPool 代表共享进程池,即多个 HappyPack 实例都使用同一个共享进程池中的子进程去处理任务,以防止资源占用过多,相关代码如下:
    虽然参数是thread但是实际上实现还是通过进程来做的,只是起名混淆了。
const HappyPack = require('happypack');
// 构造出共享进程池,进程池中包含5个子进程
const happyThreadPool = HappyPack.ThreadPool({ size: 5 });

module.exports = {
  plugins: [
    new HappyPack({
      // 用唯一的标识符 id 来代表当前的 HappyPack 是用来处理一类特定的文件
      id: 'babel',
      // 如何处理 .js 文件,用法和 Loader 配置中一样
      loaders: ['babel-loader?cacheDirectory'],
      // 使用共享进程池中的子进程去处理任务
      threadPool: happyThreadPool,
    }),
    new HappyPack({
      id: 'css',
      // 如何处理 .css 文件,用法和 Loader 配置中一样
      loaders: ['css-loader'],
      // 使用共享进程池中的子进程去处理任务
      threadPool: happyThreadPool,
    }),
    new ExtractTextPlugin({
      filename: `[name].css`,
    }),
  ],
};

接入 HappyPack 后,你需要给项目安装新的依赖:npm i -D happypack

安装成功后重新执行构建你就会看到以下由 HappyPack 输出的日志:

Happy[babel]: Version: 4.0.0-beta.5. Threads: 3
Happy[babel]: All set; signaling webpack to proceed.
Happy[css]: Version: 4.0.0-beta.5. Threads: 3
Happy[css]: All set; signaling webpack to proceed.

说明你的 HappyPack 配置生效了,并且可以得知 HappyPack 分别启动了3个子进程去并行的处理任务。

HappyPack 原理

在整个 Webpack 构建流程中,最耗时的流程可能就是 Loader 对文件的转换操作了,因为要转换的文件数据巨多,而且这些转换操作都只能一个个挨着处理。 HappyPack 的核心原理就是把这部分任务分解到多个进程去并行处理,从而减少了总的构建时间。

从前面的使用中可以看出所有需要通过 Loader 处理的文件都先交给了 happypack/loader 去处理,收集到了这些文件的处理权后 HappyPack 就好统一分配了。

每通过 new HappyPack() 实例化一个 HappyPack 其实就是告诉 HappyPack 核心调度器如何通过一系列 Loader 去转换一类文件,并且可以指定如何给这类转换操作分配子进程。

核心调度器的逻辑代码在主进程中,也就是运行着 Webpack 的进程中,核心调度器会把一个个任务分配给当前空闲的子进程,子进程处理完毕后把结果发送给核心调度器,它们之间的数据交换是通过进程间通信 API 实现的。

核心调度器收到来自子进程处理完毕的结果后会通知 Webpack 该文件处理完毕。

同样可能因为demo原因,效果不明显,还有负作用,需要后续继续实验。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant