Webpack DLLPlugin 问题处理记录

最近在给企业版页面引入 gitee-vendor-dll 包中的 echarts 库后出了点问题,部分未使用 echarts 的页面会报错未找到 echarts 依赖库,初步推测问题原因出在 DllPlugin 和 DllReferencePlugin 上。

webpack 文档上对 DllReferencePlugin 插件的工作模式介绍是这样的:

dll 中的内容被映射到了当前目录下。如果一个被 require 的文件符合 dll 中的某个文件(解析之后),那么这个dll中的这个文件就会被使用。

在使用 DllReferencePlugin 插件时,自然会习惯性认为它只会给主动引入了 echarts 的模块添加 echarts 依赖,然而从实际效果来看,它会给 entry 配置中的所有入口文件加上 echarts 依赖,假设 DllReferencePlugin 确实能够正常工作,那么问题原因可能在于输出的 dll 包含了除 echarts 外全局共用的模块。

先看看 webpack 为 echarts 输出的 manifest.json 内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"name": "echarts_lib",
"content": {
"../node_modules/echarts/dist/echarts.common.js": {
"id": "./node_modules/echarts/dist/echarts.common.js",
"buildMeta": {
"providedExports": true
}
},
"../node_modules/webpack/buildin/global.js": {
"id": "./node_modules/webpack/buildin/global.js",
"buildMeta": {
"providedExports": true
}
}
}
}

内容很简单,可以看到多了个 webpack/buildin/global.js 文件,经测试,删掉这项记录可以解决问题,那么该如何让 manifest.json 不包含这个文件?有两个选择:

  1. 向 webpack 反馈问题,让官方给出标准解决方案
  2. 手写 JavaScript 代码,在 webpack 执行完后更新 manifest.json 文件

stats-webpack-plugin 插件的用法

stats-webpack-plugin 插件能够生成资源清单文件,被用于 Gitee 的一些前端项目中,这个清单文件有两个用途:

  1. 让服务器端能够根据 Webpack 配置的入口名称找到资源文件路径
  2. 在线上环境出现问题需要回滚时,可以通过回退清单文件来快速将前端资源切换回上个版本,无需花时间重新打包构建前端资源

由于最近在处理资源清单文件合并问题时折腾了 stats-webpack-plugin 插件,所以顺便记录一些关于这个插件的用法。

基本用法:

1
2
3
4
5
6
7
8
9
10
const StatsPlugin = require('stats-webpack-plugin')

module.exports = {
plugins: [
new StatsPlugin('stats.json', {
chunkModules: true,
exclude: [/node_modules[\\\/]react/]
})
]
};

使用 cache 参数让多个 StatsPlugin() 插件实例将构建统计信息写入到同一 stats.json 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const options = {
chunkModules: true,
exclude: [/node_modules[\\/]/]
}

const cache = {}

module.exports = [
{
plugins: [
new StatsPlugin('stats.json', options, cache)
]
},
{
plugins: [
new StatsPlugin('stats.json', options, cache)
]
}
]

gitee-vendor-dll 项目的 Webpack 配置是一个数组,按照原有的配置,Webpack 输出的 manifest.json 只会包含第一个配置的资源信息,正好之前在看 stats-webpack-plugin 插件的测试程序时发现了 cache 参数,所以用它解决了问题。

Webpack 配置改进方案

需求

此前的《Webpack 资源拆分方案》已经正式应用到线上生产环境,企业版、手机版和 API 文档的代码已移入各自的仓库中,但不久后很快出现了一些问题:

  • 企业版有多个进行中的开发任务,是混在一个 PR 中一起测试,还是为他们分配互不影响的版本号分别测试?
  • 多个与企业版相关的 PR 测试通过后,需要在企业版仓库中合并这些 PR,然后发布新版本供测试和上线,这流程太麻烦
  • npmjs.org 服务器出现故障,在发布新版本后,服务器端未更新版本信息,导致相关开发任务无法继续
  • 发版前的全量构建时间较长,上传压缩包需要时间,等待 taobao 源同步新版本包也需要时间,拖慢开发效率

核心问题是发版、上传和镜像同步都需要手动操作,既耗时又麻烦,用 CI 服务代替人工操作是个可行的办法,但在 Gitee 上接入 CI 服务也很麻烦。

解决方案

撤销之前的方案,将拆分出去的代码移入主仓库内。

实现

移动子项目至主仓库

原先的 app/assets/javascripts/webpack 目录太深,可以乘此次改动顺便调整目录结构,把子仓库代码都放根级 webpack 目录中,结构如下:

1
2
3
4
webpack/
api-doc/
mobile/
enterprise/

调整子项目的 Webpack 配置

在 webpack 目录中新建 webpack.projects.config.js,用于引入子项目的配置文件。

1
2
3
4
5
6
7
8
webpack/
api-doc/
webpack.config.js
mobile/
webpack.config.js
enterprise/
webpack.config.js
webpack.projects.config.js

构建性能优化

每次线上更新时都会构建全部资源,耗时很长,像手机版、API 文档这类更新频率低的项目没必要也一起构建。为此,可以实现按需构建的功能,只构建有改动的前端代码,未改动的则用上次的资源。

一开始的方案是动态生成 entry 配置,过滤掉未改动的打包项,但这会使生成的 manifest.json 文件不会包含未打包的资源信息。

现在的方案是:

  • 给每个子项目输出 manifest.json 文件
  • webpack/webpack.config.js 作为主配置,用于将子项目的 manifest.json 文件合并成一个,供服务器读取
  • 构建生产版本资源时,先构建子项目的资源,webpack.projects.config.js 会先读取 build-state.json 文件,对比子项目版本,然后返回有改动的子项目的 Webpack 配置列表给 Webpack 使用
  • 构建完生产版本资源后,运行一个 js 脚本,将各个子项目目录最后的 commit id 作为它们的版本号,然后保存至 build-state.json 文件中

相关脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"scripts": {
"webpack:projects": "NODE_OPTIONS=--max_old_space_size=4096 webpack --config webpack/webpack.projects.config.js",
"webpack:main": "webpack --config webpack/webpack.config.js",
"dev:projects": "npm run webpack:projects -- --progress --watch",
"dev:mobile": "WEBPACK_BUILD_TARGET=mobile npm run dev:projects",
"dev:enterprise": "WEBPACK_BUILD_TARGET=enterprise npm run dev:projects",
"dev:main": "npm run webpack:main -- --progress --watch",
"build:projects": "NODE_ENV=production npm run webpack:projects -- --bail",
"build:main": "NODE_ENV=production npm run webpack:main -- --bail",
"build": "npm run build:projects && npm run build:main && node ./webpack/save-build-state.js",
}
}

开发的时候,可以选择只构建相关项目的资源,节省时间。以企业版为例,先运行:

1
npm run dev:enterprise

然后再开个终端运行以下命令更新 manifest.json:

1
npm run dev:main

Webpack 资源拆分方案

需求

线上构建前端资源耗时太长,影响更新速度。

解决方案

新建一个仓库,用于预构建和存储一部分资源。

实现

使用主仓库中的 webpack 配置

  • output.publicPath 和主仓库中的一致,确保输出的 js 都是引用都是同一位置下的资源。
  • 添加 DllReferencePlugin 插件,将 context 和 manifest 的路径设置为和主仓库中的一致路径。

生成 manifest.json 文件

添加 StatsPlugin 插件,让 webpack 在构建完后输出 manifest.json 文件,方便主仓库的 webpack 使用。

传输已构建的资源

构建后的资源总共有 38MB 大小,其中包含 .gz 文件和 MonacoWebpackPlugin 插件输出的 *.worker.js 文件,排除这些文件后,总大小为 16MB,经 npm pack 打包输出的包文件的大小为 4.1MB。这大小看上去可以上传到 npm 服务器上,线上更新时用 npm install 下载即可。

使用已构建的资源

修改主仓库的 webpack 配置:

  • 添加 CopyWebpackPlugin 插件,让 webpack 将资源包中的资源赋值到 public 目录下。
  • 在 StatsPlugin 插件配置中的 otherPath 追加资源包中的 manifest.json 文件路径。

开发流程

由于需要构建的资源都来自主仓库,开发流程会变成这样:

  1. 主仓库
    1. 新建分支
    2. 进行相关改动
    3. 新建 Pull Request,等待每晚 6:30 的一波合并更新
    4. 拉取主干分支最新代码
  2. 资源仓库
    1. 重新构建资源
    2. 发布新版本的资源包
  3. 主仓库
    1. 新建一个用于更新资源包的分支
    2. 新建 Pull Request,等待测试
    3. 如果测试不通过,则重复上述流程
    4. 上线后,如果有问题需要修复,则重复上述流程

这个流程存在以下问题:

  • 资源包的更新频率受到主仓库开发流程的限制,每次改动后要等主仓库每晚 6.30 的一波更新后才能构建新的资源包
  • 测试效率地下,在新版本的资源包发布之前,还不能交给测试人员测试
  • 开发流程繁琐,如果线上更新后出现 bug 需要快速修复,开发人员至少要创建两个 Pull Request,然后等资源包构建完成

如果把相关文件从主仓库移动到资源仓库的话,开发流程就会简单很多:

  1. 资源仓库
    1. 新建分支
    2. 进行相关改动
    3. 新建 Pull Request,等待合并
    4. 管理员更新版本号,然后发布新版本资源包
  2. 主仓库
    1. 新建分支
    2. 安装新版本的资源包
    3. 新建 Pull Request,等待测试
    4. 测试不通过时,重复上述流程
    5. 上线后,如果有问题需要修复,则重复上述流程

总结

让资源仓库只负责预构建和存储一部分资源会影响开发效率,让它成为组件库是个可行的办法,但主仓库的前端代码依赖关系较为复杂,不易于拆分,组件库搭建难度较高。

Webpack 4.x 升级记录

主要改动

  • 升级 webpack
  • 升级 babel-loader
  • 升级 vue-router
  • 移除 vendors_lib 中的 lodash 依赖
  • 从 webpack.base.config.js 中拆分出 webpack.babel.config.js,专用于打包的 js 文件
  • 调整一些不能被 Tree Shaking 功能优化的模块的引入方式

问题记录

  1. Uncaught ReferenceError: vendors_lib is not defined

    仓库新建、组织新建、企业开通等页面的 js 代码是用 webpack 打包的,只是为了用 ES6 语法,并不依赖 vendors_lib 里的代码,但 DllReferencePlugin 插件却判定为依赖 vendors_lib。

    对比 js 文件依赖信息和 vendors_lib 的 manifest.json 文件后发现有个共同依赖项: webpack/buildin/global.js,本打算用 scoped 模式引入依赖,但试了后一直报错 Cannot resolve ...,所以就放弃了。

    解决方法: 添加 webpack.babel.config.js 配置文件,不使用 DllReferencePlugin 插件。

  2. export ‘xxxx’ was not found in ‘xxxx’

    在 es 模块中用引入 commonjs 模块会出现这个问题,webpack 未能识别出模块导出的对象。用 @babel/plugin-transform-modules-commonjs 插件可以解决这个问题,但后来发现会影响 webpack 的 tree shaking 功能,所以就放弃用了。

    解决方法: 将 commonjs 模块改成 es 模块,也就是将 module.exports = {} 语句改成 export {}

  3. Uncaught TypeError: Cannot assign to read only property ‘exports’ of object ‘#<Object>‘

    和上一个问题出自同一模块,这个模块引入了 app/assets/javascripts/lib 目录中的模块,而这个目录中的模块都是 ES5 兼容的,无需使用 babel-loader 转换也能用,转换后反而影响 webpack 编译。

    解决方法: 将该 app/assets/javascripts/lib 目录添加至 babel-loader 的 exclude 配置中。

  4. Failed to mount component: template or render function not defined

    import() 加载组件返回的是 Module 类型的对象,module.default 里才是组件的描述对象。一开始怀疑是 Webpack 的问题,但看了打包输出的代码后感觉也没什么问题,后来找到了相关文档,里面有这么一段:

    The reason we need default is that since webpack 4, when importing a CommonJS module, the import will no longer resolve to the value of module.exports, it will instead create an artificial namespace object for the CommonJS module. For more information on the reason behind this, read webpack 4: import() and CommonJs

    所以只能是 Vue Router 的问题了,原因应该是现在用的 2.3.1 版本没有对 Module 类型对象做处理。Vue Router 在 GitHub 上的发行记录中的最新版本是 3.1.3,翻了历史更新日志后发现这个问题已经在 2.8.0 版本中得到解决,相关描述如下:

    Properly resolve async components imported from native ES modules (8a28426)

    解决方法: 升级 Vue Router 到最新版。

  5. Uncaught ReferenceError: _MessageBox is not defined

    问题代码:

    1
    2
    3
    4
    5
    6
    Vue.prototype.$msgbox = _messageBox2.default;
    Vue.prototype.$alert = _MessageBox.alert;
    Vue.prototype.$confirm = _MessageBox.confirm;
    Vue.prototype.$prompt = _MessageBox.prompt;
    Vue.prototype.$message = _message2.default;
    Vue.prototype.$notify = _notification2.default;

    源代码是这样的:

    1
    2
    3
    4
    5
    6
    Vue.prototype.$msgbox = MessageBox
    Vue.prototype.$alert = MessageBox.alert
    Vue.prototype.$confirm = MessageBox.confirm
    Vue.prototype.$prompt = MessageBox.prompt
    Vue.prototype.$message = Message
    Vue.prototype.$notify = Notification

    这也能出问题?第一次用时能解析为 _messageBox2.default,后面用时却全解析为 _MessageBox。经测试发现是 babel-plugin-component 的问题,这个插件很久没更新了,就算反馈问题也不一定会被处理,不过这个插件是 fork 自 babel-plugin-import 的,可以改用它。

    解决方法: 改用 babel-plugin-import,重命名 .babelrc 为 .babelrc.js,然后将插件配置改为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    [
    "import",
    {
    "libraryName": "osc-element-ui",
    "style": (name) => {
    return `osc-element-ui/lib/theme-chalk/${name.substr(name.lastIndexOf('/') + 1)}.css`
    }
    },
    "osc-element-ui"
    ],
  6. missing param for named route “project#tree”: Expected “0” to be defined

    Vue Router 在 3.0.2 版本中已经将 * 通配符的参数名从 0 改为 pathMatch。

    Give * routes a param name (instead of 0) (#1994) #1995 @posva

    解决方法: 将路由参数中的 0 改成 pathMatch

  7. Uncaught (in promise) Navigating to current location (“/xxxx”) is not allowed

    菜单栏点击链接时调用了一次 $router.push(),目标页面在初始化后会调用 $router.replace() 更新 query 参数,这时路由没有变化,所以会报错。

    解决方法:$router.replace() 添加异常捕获,也就是改成:

    this.$router.replace({ query }).catch(err => err)`
Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×