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 参数,所以用它解决了问题。

Gitee 移动端项目分离总结

与上次的移动端项目拆分方案相比,做了以下改动:

  • 分离了部分移动端 css 文件
  • 改用 git+https 方式从 git 代码库中安装移动端的构建产物,省去了发版、上传 npm 包和刷新淘宝源的麻烦

分离后,主仓库在生产环境下的全量构建耗时减少约 11.65%,具体测试结果如下:

版本 切换版本后第一次 第二次 第三次 第四次 后三次平均
分离后 178.40s 78.47s 77.44s 76.57s 77.49s
分离前 233.72s 89.76s 84.73s 88.64s 87.71s

现在的移动端项目代码并未完全与主仓库分离,主仓库中还存在一些移动端的代码,主要原因如下:

  • css 文件依赖关系复杂,拆分难度大
  • 部分页面的 js 和 css 文件是由 Rails 的 Asset Pipeline 管理的,它们只能在主仓库中

对于这些问题,在开发初期如果有良好的模块化意识和开发规范约束话都是可以避免的,例如:

  • 统一使用 Webpack 打包 css 和 js 文件,尽量不依赖 Rails 环境的工具和资源
  • js 和 css 代码应该根据功能、作用范围划分文件和目录,以便区分和查找

Gitee 人节活动动画开发日志

需求

红薯在愚人节前一天突发奇想,想在 Gitee 上也整一个活动,活动内容就是让仓库页面有个破碎的动画然后显示“愚人节快乐!”。

实现

第一次搞动画有点慌,没用过 Canvas API,不知道一天内能不能搞出来。一开始是想用类似于 Google 在灭霸搜索结果页面加的彩蛋,点击无限手套就会触发灰飞烟灭效果,但仔细想了一下,直接照搬网上现成的动画的话会拉低用户对 Gitee 的评价,于是决定用其它效果。

碎片化

页面变成碎片的效果比较容易实现,大致思路是根据屏幕宽高计算 x 和 y 轴上的碎片数量,然后计算每个碎片的坐标,伪代码如下:

1
2
3
4
5
6
7
8
const size = 32;

for (let x = 0; x < Math.ceil(canvas.width / size); ++x) {
for (let y = 0; x < Math.ceil(canvas.height / size); ++y) {
// 四个参数对应 x, y, width, height
new Piece(x * size, y * size, size, size)
}
}

随机分布

碎片该在什么时候掉落?短时间内让全部碎片掉落的话,对渲染性能要求较高,动画会卡顿。按顺序逐个开始掉落的话,观感过于普通,得搞点不一样的效果。

最终决定采用如下方式:

  1. 在生成碎片列表后,用随机算法将它打乱,参考:如何将一个 JavaScript 数组打乱顺序? - 知乎
  2. 在渲染时分批激活掉落的碎片,并计时
  3. 在碎片激活后的一段时间内,只给碎片渲染边框,表示这个碎片将要掉落
  4. 开始掉落

掉落

碎片要有个掉落动画,每个碎片的掉落路线应该随机,掉到底下后会弹起。掉落路线的计算方法需要花时间查资料和写代码,就目前的情况而言,可没那么多时间能够静下心来自己动手实现,那么,只能找现成的代码来用了。

花了点时间,找到了这个动画:HTML5 Canvas实现会跳舞的时间动画,数字变化后会变成一堆小球掉落,挺符合需求的,但下载源码包后发现要关注这个网站的微信公众号才给解压密码,这种拿别人的开源项目不注明出处还加上解压密码的行为挺让人反感的。

这个问题本质是如何实现自由落体动画,用相关关键词可以搜索到这个动画:HTML5/Canvas 物理学自由落体动画 - 踏得网,代码比较简单,可以直接拿来用。

颜色渐变

动画内容只有碎片掉落还不够,可以给碎片加上颜色渐变效果,在碎片动画播放一段时间后开始加入颜色渐变效果,大致流程是:

  1. 创建碎片,随机分配一个颜色
  2. 在碎片激活后一段时间,开始渲染渐变色

填充渐变色可以用 fillRect(),在 Canvas API 首页就有它的示例代码。

调整渲染顺序

当掉落的碎片增加到一定量时,未掉落的碎片会遮住掉落中的碎片,碎片看上去像是向屏幕后面掉落,观感不太好。由于掉落的碎片是在未掉落的碎片之前绘制的,先绘制的内容会被后绘制的内容覆盖,为解决这问题,需要再加一个数组,按碎片的生成顺序倒序,然后在渲染时遍历该数组即可。

处理图表和表格的打印样式问题

新开发的统计页面需要有个打印功能,但在预览打印内容时出现了以下问题:

  1. 样式缺失,内容排版混乱
  2. ECharts 图表宽度溢出,导致文档中有滚动条
  3. Element UI 的表格组件宽度未 100% 拉伸

对于第一个问题,原因是 page_specific_style_bundle_tag 方法输出的 link 标签的 media 属性默认值是 screen,这使得 link 标签引入的 css 代码仅在 screen 上有效。解决方法是手动将 media 属性设为 all,然后添加 @media print 查询,隐藏无关内容。

第二个问题,原因是在媒介切换为 print 时 ECharts 的图表尺寸仍然是用 screen 媒介下的尺寸,虽然有尝试过在 window 触发 beforeprint 事件时主动更新图表尺寸,但并没有什么用,后来根据搜索结果得知,可在打印前将图表转换为图片,并让 img 元素的宽度为 100% 以使图表宽度自适应。核心代码如下:

1
2
3
4
5
6
7
8
9
function print() {
const img = new Image()

img.onload = () => {
window.print()
}
img.src = this.chart.getDataURL()
this.imgSrc = img.src
}

第三个问题,Element UI 的表格组件分别用三个 table 元素实现表头、表身和表尾,并使用 colgroup 元素来控制每一列的宽度,这些宽度是由 JavaScript 计算的,显然,在媒介切换为 print 时它并不会重新计算。解决方法是将表格组件中的三个 table 元素合并为一个,然后去除 colgroup 元素,让浏览器自动调整 table 布局。核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default {
mounted() {
$(window).on('beforeprint', () => {
const $box = $(this.$el)
const $parent = $box.parent()
const $table = $($parent.find('.el-table__body-wrapper table')[0].outerHTML)

$table.prepend($parent.find('.el-table__header-wrapper thead')[0].outerHTML)
$table.append(`<tfoot>${$parent.find('.el-table__footer-wrapper tr')[0].outerHTML}</tfoot>`)
$table.find('colgroup').remove()
$box.empty().append($table)
})
}
}

前端代码目录结构调整方案

红薯提了一些关于 Gitee 前后端分离的问题,暗示 Gitee 现有前端代码需要改进,但以 Gitee 现有情况看来,前后端代码都混在同一个仓库里,共用同一开发流程,而且组件库中的部分组件依赖后端在页面中生成的 window.gon 对象,直接搞前后端分离的话难度非常大,因此,只能将这项工作拆分成多个步骤来进行,大致如下:

  1. 调整目录结构
  2. 整理 JavaScript 和 CSS 代码,使其不再依赖主仓库的老代码
  3. 移动前端代码至新仓库中
  4. 对企业版的页面逐一重构,实现前后端分离
  5. 重构组件库,移除对 window.gon 对象的依赖

先从调整目录结构开始,让前端代码更容易从主仓库中分离出去,顺便处理掉老代码。

在上次的《Webpack 配置改进方案》中,部分前端代码已经整理进了 webpack 目录,这次对目录结构做进一步的调整,并把所有经过 webpack 打包的JavaScript 代码和 Vue 组件都移入 webpack 目录中。

调整后的目录里结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
webpack/
packages/
gitee-axios/
gitee-framework/
gitee-gantt-chart/
gitee-locales/
...
projects/
api-doc/
babel/
community/
enterprise/
components/
directives/
lib/
mixins/
pages/
dashboard/
events/
issues/
members/
milestones/
programs/
projects/
pulls/
wikis/
...
mobile/
shared/
components/
directives/
lib/
mixins/
webpack.config.js
webpack.projects.config.js

子项目移动到了 projects 目录,packages 和 shared 目录都包含被多个子项目依赖的代码,但 shared 包含的都是单个模块,而 packages 包含的是一些可作为依赖库/包的代码。

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

×