本文适合对调试工具及 git stashgit reset 命令有一定了解的同学,如果不了解,请出门左转 Google 之。

源起

事情源于一个风和日丽的早晨,我们组的小妹妹把她的分支代码合并到 dev 分支后,我便收到了连环夺命 call。
image.png
于是我急急忙忙打开了页面,Duang!Duang!Duang! 白屏了!!
image.png
其表现就是本应该挂载到 root 下的代码神秘消失。(意识到这是一个磨人的小妖精)

既然是合并代码导致的问题,第一想法就是 切回特性分支,无果😂,服务正常显示
image.png
这里可以看出 vender.js 和 index.js** 文件神秘失踪**。
于是我开始了漫漫“寻人”之路。

**锦囊1** : 为什么单独的特性分支服务没有问题,合并代码以后就会有问题(并且没有冲突)

错判

暂时没有思路,那就代码回退进行 “问题区域“ 定位。

  1. git log

image.png

  1. git checkout e8d132baa4ad169fafe92b46c7c4ddb8a04e88b4

服务正常,那问题就出在最后一次 commit 无疑了。我们将代码放出来看看都改了些什么

  1. git reset --soft e8d132baa4ad169fafe92b46c7c4ddb8a04e88b4

image.pngimage.png

改的东西还不少,十多个文件,该怎么办呢,总不能一个一个去试吧?
emmm…凭借我多年驰骋疆场,灵机一动,掐指一算,红框里的三个文件长的最像凶手了😁,于是乎我把这三个元凶先传唤到 stash 中。
服务神奇的恢复了,oh mygod(脑补李嘉琪),就是这么神奇,我难以置信,无法相信自己的眼睛,居然这么快就定位到了….见证奇迹的时刻到了,我们把作案凶手从 stash 里面放出来。

  1. git stash pop

是它!是它!就是它!服务挂了!!!
来看看都改了些什么:
image.png
image.png
image.png
amazing!!!
(大家不需要知道具体代码都是什么,只要知道这些代码的写法是没有问题的,非常常规简单的几行代码。)

激动的心,经验告诉我必须得缩小范围,于是我尝试着一个一个文件从 stash 中放出来。
我发现不管单独放出谁,还是单独留下谁在 stash 中,服务都是起不来的,这意味着什么呢,意味着没有人能洗脱嫌疑,没有谁是拥有不在场证明的,是团体作案的典型案例!

可是这三个文件不仅仅在dev分支,在特性分支中也是存在的,一副良民的模样。疑点重重。

锦囊2: 为什么删除掉 3 个看似很正常的代码文件后服务可以恢复。

转机

貌似线索断了,”事出反常必有妖”,必须得归零从头再来看这个问题。

看看案发现场的表现是 vender.js 和 index.js** 文件神秘失踪,**导致本应该挂载到 root的代码没有了。
如果不是代码的问题,那就只可能是运行环境、打包构建有问题。

基于webpack4 零配置的基本宗旨(内置常规配置),把 webpack.dev.js 中的非常规配置项 module.optimization 注释掉。

  1. optimization: {
  2. runtimeChunk: {
  3. // ...
  4. },
  5. splitChunks: {
  6. // ...
  7. },
  8. usedExports: true // 使得 tree shaking 能够生效,将 css 从代码中拆分出来
  9. },

果不其然,服务跑起来了。这个貌似更像真凶,因为打包构建导致文件没拿到,路径不对什么的也是常有的事。
更重要的是 vender.js 和 index.js 跟这个属性可是血亲关系,这两就是他们亲生的。而且那个没有失踪的 runtime.js 也是他们的孩子。

默认的 webpack4module.optimization只会生成一个index.js文件,而没有 runtime.jsvendor.js.

线索

我们来重点分析一下这段 webpack 配置的 optimization 到底都干了些什么。看一看那两家伙的出生过程。
首先 module.optimizationwebpack4 新增的属性,顾名思义,就是用来配置优化相关的配置属性。
webpack4 相对于 webpack3 而言用 splitChunks 替代了 CommonsChunkPlugin,其内部是有默认配置的:

  • chunks设为async:因此如果不做覆盖配置的话,splitChunks 仅影响按需块,即同步代码不会再被单独拆分出来
  • minSize 生产环境下设为 30000: 可以共享新块,新的块将大于30kb(在min + gz之前)
  • maxAsyncRequests 生产环境下是6: 按需加载块时并行请求的最大数量将小于或等于6
  • maxInitialRequests 生产环境下是4: 初始页面加载时并行请求的最大数量将小于或等于4
  • cacheGroups设为defaultvendors:可以基于 NODE_MODULES 打包共享块

通常情况下,默认的配置就够用了。不过基于某种最佳实践的指导思想,通常我们会将chunks 改成 all,把 minSize 改成 100000,根据项目所需更改cacheGroups

我们今天不分析 webpack 的性能优化问题,如果对 module.optimization 下的配置不是很了解的同学,可以先看去我的另一篇文章webpack 性能优化之 splitChunks

锦囊3: 因为minSize的存在,配合 cacheGroups可以动态生成 chunks**(敲黑板)**,一方面控制单个 chunks 的大小,一方面控制chunks的个数不至于导致浏览器的请求数过多,受限于 http1 的请求个数导致的性能瓶颈。

再回过来看看案发现场,正常情况下三个文件会被注入到 html 里面去。
image.png
稍微了解过 webpack 的同学应该都能立马想起一个叫html-webpack-plugin 的插件,这个插件的作用一方面可以自动生成创建 html,另一方面为 html 文件引入外部资源如 scriptlink 动态添加每次 compile 后的 hash等。
我们深入一下源码,来看看常见参数的默认配置:

  1. const defaultOptions = {
  2. template: 'auto', // html模板所在的文件路径
  3. filename: 'index.html', // 输出的html的文件名称
  4. hash: false, // 是否给生成的 js 文件一个独特的 hash 值,该 hash 值是该次 webpack 编译的 hash 值
  5. inject: userOptions.scriptLoading !== 'defer' ? 'body' : 'head', // script标签位于html文件的 body 底部
  6. favicon: false, // 给生成的 html 文件生成一个 favicon
  7. minify: 'auto', // 对 html 文件进行压缩
  8. cache: true, // 表示内容变化的时候生成一个新的文件
  9. showErrors: true, // 如果 webpack 编译出现错误,webpack会将错误信息包裹在一个 pre 标签内
  10. chunks: 'all', // 主要用于多入口文件,当你有多个入口文件,那就回编译后生成多个打包后的文件,那么chunks 就能选择你要使用那些js文件
  11. excludeChunks: [], // 排除掉一些js
  12. title: 'Webpack App', // 生成html文件的标题
  13. };

这个里面有一个非常关键的属性 chunks,这个 chunks 就是基于 splitChunks 分割出来的共享块的引入。在前端 SPA 单页面架构的情况下这个属性是可以用默认值all的,因为不管你怎么分割,都是该应用下的代码。然而在多页面架构下,对于指定页面才需要使用的共享块,是需要单独配置的。

什么意思呢,我们举个简单的栗子:项目中有3个页面,a.htmlb.htmlc.html,其中 b 和 c 页面才会用到 D 这个库,看了一下,乖乖 ,989KB!!还挺大的,既然 a 页面没用,那就没必要让 a 页面也引用该 chunk

so, 你的配置会变成这样:

  1. cacheGroups: { // 把 Echarts 单独抽离出来
  2. D: {
  3. test: /[\\/]node_modules[\\/]D/,
  4. priority: 40, // 权重越大,打包优先级越高
  5. name: 'D'
  6. },
  7. // ...
  8. }
  1. plugins: [
  2. new HtmlWebpackPlugin({
  3. template: './template/a.html',
  4. inject: true,
  5. chunks: ['a', 'runtime'],
  6. }),
  7. new HtmlWebpackPlugin({
  8. template: './template/b.html',
  9. inject: true,
  10. chunks: ['b', 'runtime', 'D'],
  11. }),
  12. new HtmlWebpackPlugin({
  13. template: './template/c.html',
  14. inject: true,
  15. chunks: ['c', 'runtime', 'D',],
  16. }),
  17. ]

完美,a 页面跟 D 永远没有关系了。基于这样的应用场景的考虑,多页面架构一般 chunks 就是通过配置的方式写死引入的(有更好的方式欢迎留言探讨),这样就会带来一个风险。
锦囊4: 如果某个页面没有引入本该需要的模块,这个代码的表现就是无报错的白屏状态。

所以有没有可能是某个动态生成的 chunks 没有被 html 引入导致的白屏呢❓好像有戏…

破案

回到我们遇到的问题中来看看项目的配置:

  1. splitChunks: {
  2. chunks: 'all',
  3. minSize: 100000, // 当超过指定大小时做代码分割
  4. minChunks: 1, // 生成块的最小大小(以字节为单位)
  5. maxAsyncRequests: 5, // 按需加载时最大并行请求数
  6. maxInitialRequests: 3, // 入口点的最大并行请求数
  7. automaticNameDelimiter: '_', // 指定用于生成名称的定界符。
  8. name: true, // true 将基于块和缓存组密钥自动生成一个名称
  9. cacheGroups: { // 缓存组:如果满足 vendors 的条件,就按 vender 打包,否则按 default 打包
  10. vendors: {
  11. test: /[\\/]node_modules[\\/]/, // 可以配置正则和写入function作为打包规则
  12. priority: -10, // 权重越大,打包优先级越高
  13. name: 'vender' // 将代码打包成名为 vender.js 的文件
  14. },
  15. default: {
  16. minChunks: 2,
  17. priority: -20,
  18. name: 'common',
  19. reuseExistingChunk: true // 是否复用已经打包过的代码
  20. },
  21. }
  22. }

这些配置意味着来自node_modulesmodule 将会被打包成一个 vender.js文件,如果超出了 100K 就会打包到 common.js 中去。
再看看 index.html 里面的引入情况:

  1. plugins: [
  2. new HtmlWebpackPlugin({
  3. // ...
  4. chunks: ['index', 'vender', 'runtime'],
  5. }),
  6. ]

是不是发现了什么,随着我们代码量的增大,我们的module引入的增多,势必会导致vender.js超出 100K,就会多出一个 common.js,这里我们添加一下 common.js 服务就恢复了。
小朋友你看到这里是否有很多问号❓为什么明明是是来寻vender.jsindex.js的,怎么跑出来个 common.js 就找到了。😊很明显嘛,这个 common.js 才是真凶, 把那两拐跑了。

那有没有别的解决方案呢?
只要让 common.js 不出现,服务就可以恢复,也就是不让 vender.js大于100K。还可以将 chunks 改为 async,让 vender.js 抽出来的代码不管同步的代码,那就减少了无数的代码量了,common.js 就可能不出现,事实证明是可以的。当然将 minSize 的 100K 改成 200K,也可以解决问题。

回顾一下前面留下来的锦囊:
锦囊1 : 为什么单独的特性分支服务没有问题,合并代码以后就会有问题
锦囊2: 为什么删除掉 3 个看似很正常的代码文件后服务可以恢复。
锦囊3: 因为minSize的存在,配合 cacheGroups可以动态生成 chunks**(敲黑板),一方面控制单个 chunks 的大小,一方面控制chunks的个数不至于导致浏览器的请求数过多,受限于 http1 的请求个数导致的性能瓶颈。
锦囊4**: 如果某个页面没有引入本该需要的模块,这个代码的表现就是无报错的白屏状态。

现在是不是逻辑清晰了,只要减少引入的 module 就会减少代码量,服务自然就恢复了。

我们来复盘一下我们的流程,看在流程上是否能够规避类似问题的再次发生。
首先问题是发生在测服,只是自己没发现,是调用方调用我们的服务才发现的。
那很多人就会有疑问了,难道 CI集成不会有问题吗?是的!整个构建编译都是没有问题的,甚至打开页面控制台也是没有任何的报错信息的。
又有人会说开发合并特性分支到 dev 分支的时候不会发现问题吗?我们的合并走的是 **gitlab** 的 PR,没有冲突,一般不会有问题,就不用在本地跑了,只要确保自己的特性分支没有问题就是 OK 的,即开发者没有特殊情况不会在本地 run dev 分支。
又有人说了,你们如果不走 merge,走 rebase 就可以避免开发者把有问题的代码发到测服。这个嘛就是仁者见仁智者见智啦,取舍一念之间。
大家怎么看呢?欢迎在评论区一起探讨。

写在最后

今天通过“捉妖”分享了我们在遇到“很难定位的问题”的时候的分析思路,同时也了解了前端多页面架构的动态引入问题。做技术的人都知道往往比较清晰的有套路的目标,例如性能优化,只要按照套路去做一般也不会太差。反而是容易忽视的细节,容易因小失大。所以问题得到解决之后,通常会觉得如此而而,不将细节放到全局去复盘考量问题出现的必然性,那很有可能会在一个点上摔多次,只是以一种不一样的姿势。
我经常跟我的团队说,你要相信“挫也会挫的风情万种”的😊。

王国维在人间词话中提到人生三大境界:

  • 第一境 昨夜西风凋碧树,独上高楼,望尽天涯路
  • 第二境 衣带渐宽终不悔,为伊消得人憔悴。
  • 第三境 蓦然回首,那人却在灯火阑珊处。

写代码也一样:

  • 第一境 解决问题,独上高楼
  • 第二境 穿透本质,人憔悴
  • 第三境 举一反三,灯火阑珊

彩蛋一波~,附赠一个我们项目分割包的最佳实践

  • 基本框架的包 react | react-dom | react-dom-router | babel-polyfill | redux| antd等都走公司内部 CDN,同时配置 external
  • 指定页面需要的异步加载的特别大的 module 单独抽离,如 Echarts
  • 指定页面需要加载的剩余的 module 抽离成一个 async-commons
  • 剩下的所有的同步加载的 module 抽离成一个 vendors
  • 所有业务代码的共享块抽离成一个 commons
  • 一般我们保持最大的包在 100K 左右(webpack 默认值是30KB),因为浏览器 http1 的最大请求数的限制,也不建议分割成太多的文件。

这样是不是就够了呢!当然不是,如果你们的项目还引入了大量的内部封装的组件又或者你们的项目上了微前端,又该怎么去做性能优化呢?欲知其中实操细节,且听下回分解。

参考资料
webpack 在线教程:https://survivejs.com/webpack/
webpack 官方文档:https://webpack.js.org/plugins/split-chunks-plugin/
webpack 性能优化之 splitChunks:https://www.yuque.com/lulu27753/lulu/fslz5w