Author: 微笑向暖

前述

vue-cli3版本的发布距今已经过了大半年,前后迭代了50多个版本,终于趋于稳定;这里不得不得感叹vue开源团队对vue技术栈的倾力贡献,使得vue社区的前端工程化实践又向前迈了一大步。相比vue-cli2版本的'大锅混',三版本的插件系统卓识令人惊艳了一把,因此组内也在第一时间迁移了vue-cli3,本文算是对插件系统的一次探索与学习,也算是一次抛砖引玉,期待后面继续更新推出优秀的插件并将开发插件的经验总结开源出来。


插件开发背景

关于模块预编译,网上的教程及webpack配置攻略非常多,没有经验的读者可参考webpack dllPlugin。在前端项目迭代到中后期或者依赖第三方模块体积较大时,模块预编译可有效提升webpack构建速度,但不同项目需要预编译的模块不同,以及配置细节也不同,所以借助vue-cli3封装成vue-plugin-dll插件,将构建逻辑封装在插件内部,对外开放预编译的配置项,这样可以使前端开发更专注于业务。

注意:本文封装的vue-cli-plugin-dll未发布到npm中,仅提供了开发插件的思路和总结。

模块预编译原理

webpack.dllPlugin本质是将大量复用模块且不会频繁更新的库进行预编译,且只需要编译一次,编译完成后产出指定文件(可以称为动态链接库)。在之后的构建过程中不会再对这些模块进行编译,而是直接使用DllReferencePlugin来引用动态链接库的代码,因此可以提高构建速度。一般可以将第三方模块进行预编译,如 vue、vue-router、vuex等,只要这些依赖模块不更新,就不需要再重新编译。

项目对比

在封装vue-cli-plugin-dll插件之前,需要探索一下模块预编译对前端项目的影响有多大。 这里实验对比了两个项目:

  • vue-init:vue-cli3构建的初始化项目。
  • sellgoods:vue-cli3构建且依赖其他三方库的工程。

改造前现状

开发环境,未预先运行dll脚本进行预编译

构建次数第一次第二次第三次第四次平均用时
vue-init2997ms3561ms2867ms2935ms3078ms
sellgoods21449ms16601ms22480ms22600ms20782ms

生产环境,未预先运行dll脚本进行预编译

构建次数第一次第二次第三次第四次平均用时构建包大小
vue-init3736ms3713ms3647ms3800ms3724ms122.99 KB
sellgoods52.09s38.77s39.78s47.82s44.615s2.54 MB

改造后现状

其中files指定了需要提前预编译的模块list

// vue-init 预编译列表
files: [
    'vue/dist/vue.runtime.esm.js',
    'vue-router',
    'vuex'
]

// sellgoods 预编译列表
files: [
    'vue/dist/vue.runtime.esm.js',
    'vue-router',
    'vuex',
    'axios',
    'element-ui',
    'nprogress',
    'qs',
    'resize-observer-polyfill',
    'lodash'
]
复制代码

开发环境,运行dll脚本提前预编译

构建次数第一次第二次第三次第四次平均用时
vue-init2723ms2849ms2799ms2774ms2786ms
sellgoods16115ms16432ms16479ms15131ms16039ms

生产环境,运行dll脚本提前预编译

构建次数第一次第二次第三次第四次平均用时构建包大小
vue-init3057ms2936ms3708ms2877ms3144ms25.06 KB
sellgoods27.93s27.60s27.72s27.10s27.58s1.51 MB

结果分析

实际上,影响webpack构建速度的因素存在很多,比如硬件设施、webpack配置是否合理、代码分割策略等等。这里只针对预编译(创建动态软链)这一种情况的优化做了分析。

同时为了结果的可行性分析,这里剔除了异常数据,仅对优化与未优化两种结果的数据进行对比来进行讨论。

从生产环境的构建时间可以看到:

  • vue-init:开发环境下构建平均耗时 3078ms;优化后平均耗时2786ms,速度提升10%左右;
  • sellgoods:开发环境下构建平均耗时20782ms;优化后平均耗时16039ms,速度提升30%左右。

从生产环境的构建时间可以看到:

  • vue-init:生产环境下构建平均耗时3724ms,优化后平均耗时3144ms,构建产出包大小由122.99 KB缩减到25.06 KB,速度提升18%左右。
  • sellgoods:生产环境下构建平均耗时44.615s,优化后平均耗时27.58s,构建产出包大小由2.54 MB缩减到1.51 MB,速度提升60%左右。

注:构建产物减少不意味着浏览器加载资源变少,而是减少的部分被提前预编译,以script标签形式在index.html中引入。

结论:

针对同一工程的不同环境下而言,预编译对生产环境的构建提升速度明显

从vue-init和sellgoods二者的生产环境与开发环境进行对比可以看到,不考虑硬件设施和其它因素影响的情况下,生产环境下的效率提升要比开发环境提升效率高出一倍左右。

预编译的模块体积越大,构建提升效率越高

将sellgoods与vue-init进行横向比较,vue-init项目是脚手架的初始项目,只添加了vue、vue-router、vuex等依赖库;而sellgoods项目已进行到中后期,相对于vue-init而言,代码量及依赖的库要多很多,其中以element-ui最为明显。从结果可以看到,sellgoods无论是生产环境还是开发环境下,预编译对构建效率的提升都要比vue-init明显。

通用化方案

实际上,webpack.dllPlugin配置门槛很低,但没有必要在每个工程中配置一遍,或者将底层配置开放给业务人员。这里选择了封装vue-cli-plugin-dll插件并发布到内网npm源中,供其他项目自由引用,下面详细介绍如果一步步开放vue-cli3插件。

插件开发文档可见:vue插件开发指南

1.构建插件目录
├── generator
├    └── index.js
├── service
├    ├── base.js
├    └── dll.js
├── index.js
└── package.json
复制代码
2.开发generator
const { red, green } = require('chalk');

module.exports = (api, options, rootOptions) => {
  api.extendPackage({
    scripts: {
      dll: 'vue-cli-service dll'
    },
    vue: {
      pluginOptions: {
        dll: {
          // 文件名
          entry: 'vendor',
          // 文件输出路径
          filePath: './public/vendor',
          // 预编译包
          files: ['vue/dist/vue.runtime.esm.js', 'vue-router', 'vuex'],
          // 是否保留历史编译记录
          noCache: true
        }
      }
    }
  });
};
复制代码

generator对外暴露一个函数,对内接受一个api工具类(GeneratorAPI)负责对工程做偏好设置。这里我们借助extendPackage方法向package.json文件注入dll指令,以及dll插件的初始化配置。如果建立项目的时候勾选了useConfigFiles,那么vue属性下的配置将会被注入到vue.config.js文件中。

3.开发service(index.js)
module.exports = (api, ops) => {
  require('./service/base')(api, ops);
  require('./service/dll')(api, ops);
};

module.exports.defaultModes = {
  dll: 'production'
};
复制代码

service也对外暴露一个函数,并接受api工具类(PluginAPI)负责对webpack作更新配置。 这里我们将webpack配置进行解耦,base配置公共webpack逻辑,创建动态软链;而dll负责预编译模块逻辑。

4.开发dll指令
const { red, green } = require('chalk');

module.exports = (api, ops = {}) => {
  api.registerCommand(
    'dll',
    {
      description: '第三方模块预编译',
      usage: 'vue-cli-service dll'
    },
    async args => {
      const Config = require('webpack-chain');
      const webpack = require('webpack');
      const fs = require('fs-extra');
      const path = require('path');
      const {
        log,
        done,
        logWithSpinner,
        stopSpinner
      } = require('@vue/cli-shared-utils');

      logWithSpinner(green('Building dll files to public vendor'));

      const config = new Config();
      const pluginOptions = ops.pluginOptions || {};
      const root = api.getCwd();
      const dllConfig = pluginOptions.dll;

      if (!dllConfig) {
        log();
        log(red('缺失dll文件配置'));
        log();
        process.exit(0);
      }

      function resolve(dir) {
        return path.resolve(root, dir);
      }
      function hasVendor(filePath) {
        return fs.existsSync(resolve(filePath));
      }

      // 默认打到public/vendor文件夹里
      const {
        entry = 'vendor',
        filePath = `./public/${entry}`,
        files,
        noCache = true
      } = dllConfig;

      if (files.length) {
        files.forEach(oneOf => config.entry(entry).add(oneOf));
      }

      config.output
        .path(resolve(filePath))
        .filename('[name].dll.[hash:8].js')
        .library('[name]_[hash]')
        .end();

      if (noCache) {
        // 清空vendor缓存
        config.when(hasVendor(filePath), () => {
          fs.removeSync(resolve(filePath));
        });
      }

      config
        .plugin('DllPlugin')
        .use(require('webpack/lib/DllPlugin'), [
          {
            name: '[name]_[hash]',
            path: path.join(root, filePath, '[name]-manifest.json'),
            context: root
          }
        ])
        .end();

      const result = config.toConfig();
      webpack(result, (err, stats) => {
        stopSpinner(false);
        if (err) {
          log();
          log(red(err));
          log();
          return false;
        }
        done(green('Build complete'));
      });
    }
  );
};

复制代码

这里借助registerCommand方法注册dll指令,与generator中扩展的脚本前后呼应,在dll方法中,核心使用webpack/lib/DllPlugin插件预编译模块,并产生缓存文件,供其他环境配置使用。

5.开发base.js
module.exports = (api, ops) => {
  const webpack = require('webpack');
  const path = require('path');
  const fs = require('fs');
  const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin');
  const root = api.getCwd();

  function resolve(dir) {
    return path.resolve(root, dir);
  }

  if (ops && ops.pluginOptions) {
    const { entry = 'vendor', filePath = `./public/${entry}` } =
      ops.pluginOptions.dll || {};
    const outputPath = path.basename(filePath) || entry;
    if (fs.existsSync(path.join(filePath, `${entry}-manifest.json`))) {
      api.configureWebpack(config => {
        config.plugins.push(
          new webpack.DllReferencePlugin({
            context: root,
            manifest: require(resolve(`${filePath}/${entry}-manifest.json`))
          }),
          new AddAssetHtmlPlugin({
            filepath: resolve(`${filePath}/*.js`),
            publicPath: `./${outputPath}`,
            outputPath: `./${outputPath}`
          })
        );
      });
    }
  }
};
复制代码

在插件安装完毕之后,运行yarn dll指令,即可将预编译的包及缓存打到public/vendor目录下,这时还需为其他环境(如开发和生产环境)配置动态软链,忽略预编译模块的构建。在base.js中借助configureWebpack方法将创建动态软链的配置更新到最终版的webpack配置中(也可使用chainWebpack)。

总结

至此,一个初步的vue-cli-plugin-dll插件开发完毕,具备了预编译模块的功能,但仍有很多的不足,比如未开放预编译模块的loader或者plugin定制功能等,这里仅是一次插件封装的尝试。

转载请注明出处,十分感谢!

Logo

前往低代码交流专区

更多推荐