lerna项目、monorepo分包白屏问题解决经验分享

87 阅读14分钟

想知道最后如何解决的可以直接拖到后面看解决方案~~~

案例说明

问题背景: 某次版本升级后有用户反馈APP中的H5模块会白屏,分布在不特定用户、机型、模块。开发、测试使用多台手机都没法复现,无法定位到故障原因,即使多次尝试优化,都未能彻底解决此问题。直到对lerna分包管理机制、babel插件配置方式的充分了解,才找到此类白屏问题的解决方案。

问题定位:

【问题排查阶段】

背景:报障之后问题始终无法复现,只能根据经验初步处理。

  1. 排查问题前后代码改动:排查版本前后框架较大改动(H5、原生端)。

H5构建配置变动较大:脚手架从webpack 4升级到 webpack 5

原生端升级了底层交互通用库:运维反馈大多数白屏用户清除APP缓存后页面恢复正常(当时有很大精力误导放在原生交互的调整上。)

  1. 排查原生交互问题:针对报障最多的页面进行原生交互排查、改造

由于被用户APP清除缓存,页面不再白屏等现象误导,对用户报障过的模块进行原生交互、url携带参数较长可能截取的可能性进行排查,优化改造后反馈仍然没有效果。

  1. 参考过往案例解决方案:排查H5文件是否下载完整

过去发生过原生端H5热更新机制存在异常,导致H5资源下载不完整,从而页面打开空白,经排查无问题。

【问题定位阶段】

  1. 找到复现手机进行定位:发现可能和babel编译的JS兼容性有关。

后面多找了几台手机试了下,有台OPPO手机也出现了白屏现象,因此使用该手机进行定位。找到控制台堆栈报错信息指向的构建后文件,把该文件代码放到babel官网在线工具进行默认编译后替换,发现不再白屏,因此确定是babel编译问题。

  1. 定位未转义代码的源码 搜索dists文件中发现还是存在ES6的语法(【...】扩展运算符、【?.】链判断运算符)

【问题解决阶段】

背景:找到问题根源,开发尝试解决

  1. 研究项目构建机制:找出项目中的babel配置失效的原因。
  2. 优化项目构建配置:构建工具babel配置调优,提高项目构建生成的代码兼容性。

案例经验

  1. 要熟练掌握项目构建机制(不同分包管理框架差异)和构建插件(如 babel-loader)的配置技巧,否则在生产问题定位、各类组件/框架开发、项目性能优化等工作中容易缺乏解决能力和使用不当。
  2. 要和运维同事商量建立记录、归类生产问题(特别是复杂、难以复现的问题)报障信息的规范机制(如用户身份、影响业务范围、报障次数、手机型号、可能恢复正常的操作行为等),比如后面收集数据发现这些用户基本上都使用的是oppo和华为旧版本的手机,于是我们尝试用oppo测试机复现,第一部oppo手机可能是版本不够旧没法复现问题,直到后面找到了一部“82年”的oppo终于成功复现了白屏现象。
  3. 可以考虑借助Sentry针对无法复现问题进行主动埋点设计,尝试收集用户侧有效数据。

此问题和babel相关,先简单介绍下babel

babel是什么?

Babel 是一个工具链,主要用于将采用 ECMAScript 2015+ 语法编写的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。

@babel/preset-env

@babel/preset-env 是一个“聪明”的预设(preset),它是一个能将最新的 JavaScript 语法转化为目标环境所支持的语法的语法转换插件。(也就是默认帮你配置好了转换规则,让你可以开箱即用babel)
@babel/preset-env 还与Browserslist集成,你可以.browserslistrc 文件来指定目标环境,指定目标环境有什么用?就比如如果你的用户群体的手机浏览器版本都比较低,这时候你就可以在.browserslistrc文件里面配置较低的浏览器环境来支持目标环境。@babel/preset-env预设默认的配置是defaults他等价于> 0.5%, last 2 versions, Firefox ESR, not dead,这个默认的配置可以满足大部分用户的使用

  • 0.5% 就是包含浏览器市场份额大于0.5%的浏览器 。
  • last 2 versions 。就是包含浏览器的最新两个版本,这样子配置是因为最新的两个版本可能不会被包含在市场份额> 0.5% 之中,毕竟刚更新可能没那么多人用,所以这个配置很重要,就算你想自定义browserslist最好也要包含这个配置
  • last 2 versions 。就是包含浏览器的最新两个版本,这样子配置是因为最新的两个版本可能不会被包含在市场份额> 0.5% 之中,毕竟刚更新可能没那么多人用,所以这个配置很重要,就算你想自定义browserslist最好也要包含这个配置
  • not dead 就是不包含不再安全更新的浏览器,比如ie浏览器。这个配置可以减小打包体积,要知道兼容越多浏览器引入的兼容代码越多包也会越大

需要特别注意的是,Browserslist配置是有优先级的,默认情况下,@babel/preset-env 将使用 browserslist 配置文件, 除非 在@babel/preset-env配置中设置了 targets 或 ignoreBrowserslistConfig 参数。Browserslist也可以在package.json文件中配置,不过优先级比.browserslist要低。

推荐一个查询浏览器支持率的一个网址:
browsersl.ist/#q=%3E0.3%2…

@babel/preset-env的一些关键配置

示例图

  • target 用来配置支持的目标环境,由上文可知优先级比Browserslist高。
  • modules 设置 ES 模块语法到另一个模块类型的转换。(一开始不理解modules的作用,其实这个选项只是决定了在 Babel 处理过程中模块语法的转换方式,最终的输出格式还是由打包工具决定,我们最终打包一般会转为umd格式)。具体来说,modules 选项有以下几种可能的取值:
    • auto: 默认值。自动转换,如果环境支持原生 ES6 模块,"auto" 会将模块转换设置为 false,即不进行模块转换。否则,会将模块转换设置为 "commonjs"。这样做可以确保在支持 ES6 模块的环境中不会进行不必要的模块转换,而在不支持 ES6 模块的环境中进行适当的转换以确保代码能够正确运行。
    • false:表示不进行模块转换。这适用于当你的代码运行在支持 ES6 模块的环境中,比如现代的浏览器或者 Node.js 的一些版本。
    • "commonjs"/"cjs":表示将 ES6 模块转换为 CommonJS 模块,适用于在 Node.js 等支持 CommonJS 模块的环境中使用。
    • "amd":表示将 ES6 模块转换为 AMD (Asynchronous Module Definition) 格式的模块,适用于一些遗留的浏览器环境或者一些特定的前端工具。
    • "umd":表示将 ES6 模块转换为 UMD (Universal Module Definition) 格式的模块,它可以在 AMD、CommonJS 和全局变量引入的环境中运行。
    • "systemjs":表示将 ES6 模块转换为 SystemJS 格式的模块。
  • include 选项用于指定哪些文件需要被 @babel/preset-env 进行转译。通常,你可以通过 include 选项指定一个匹配文件路径的正则表达式或者一个文件路径的数组,以确保只有特定的文件或者文件夹中的文件会被转译。
  • exclude 选项用于指定哪些文件不需要被 @babel/preset-env 进行转译。例如,如果你想要排除项目中的 node_modules 文件夹中的 JavaScript 文件不被转译,你可以这样配置:
  • useBuiltIns 用于配置是否使用内置的 polyfill 来按需加载 ECMAScript 新特性的兼容性代码。
    为什么要用polyfill? 因为babel-prest-env仅仅只会转化最新的es语法,并不会转化对应的Api和实例方法,比如说ES 6中的Array.from静态方法。babel是不会转译这个方法的,如果想在低版本浏览器中识别并且运行Array.from方法达到我们的预期就需要额外引入polyfill进行在Array上添加实现这个方法。语法层面的转化preset-env完全可以胜任。但是一些内置方法模块,仅仅通过preset-env的语法转化是无法进行识别转化的,所以就需要一系列类似”垫片“的工具进行补充实现这部分内容的低版本代码实现。这就是所谓的polyfill的作用。polyfill在以前我们一般都是使用@babel/polyfill来实现。

@babel/polyfill 与 @babel/plugin-transform-runtime

@babel/polyfill 库包含 core-js 和一个自定义的 regenerator runtime 来模拟完整的 ES2015+ 环境,使用这个库可以增加兼容性代码。

但是从 Babel 7.4.0 版本开始,@babel/polyfill包已经不建议使用了,因为他会造成全局污染,我们一般使用 transform runtime (@babel/plugin-transform-runtime)插件代替。

useBuiltIns 被设置为 false 时,Babel 将不会自动引入任何 polyfill。这意味着如果你的代码中使用了某些 ECMAScript 新特性,而目标环境不支持这些特性,那么你需要手动引入对应的 polyfill,以确保代码能够在目标环境中正确运行。

useBuiltIns 被设置为 "entry" 时,Babel 会根据你的入口文件(entry file)来确定需要引入的 polyfill。这意味着 Babel 将根据入口文件中使用的 ECMAScript 特性来决定需要引入哪些 polyfill。
由于@babel/polyfill在babel7.4.0被弃用了,因此要想在在入口全部引入的话,可以通过npm install core-js@3 --save然后在入口文件引入import 'core-js/stable'import "regenerator-runtime/runtime",
如果你旧版babel,而且是core-js@2,则需要import "@babel/polyfill";


此外,useBuiltIns 还可以设置为 'usage'。这个选项告诉 Babel 根据实际的代码使用情况来确定需要引入的 polyfill。这样可以进一步减小构建后代码的体积,因为只会引入实际使用到的特性的 polyfill,当使用usage时,我们不需要额外在项目入口中引入polyfill了,它会根据我们项目中使用到的进行按需引入。

corejs他只在useBuiltIns: usage 或者useBuiltIns: entry的时候才有效,其实它就是提供了不同版本的polyfill代码,他的默认值是2,你也可以设置3.x等版本,不过这需要安装对应的core-js版本。

core-js 2.0版本是跟随@babel/preset-env一起安装的,不需要单独安装

@babel/plugin-syntax-dynamic-import 是 Babel 的一个插件,用于解析和转换 JavaScript 中的动态 import 语法。如果你的项目中使用了动态 import 语法,那么你需要在 Babel 的配置文件中添加 @babel/plugin-syntax-dynamic-import 插件,以确保 Babel 能够正确解析和处理这些语法。

我们目前项目中的配置为什么是这样子上面已经都做了解释:

解决方案

lerna公共分包的ES6编译问题

为什么lerna项目中的babel配置不生效?

为什么就我们项目中的babel配置不生效?不生效是因为我们的项目采用lerna这种monorepo分包管理,它的babel配置与非monorepo项目有所不同。在babel7.x之后babel增加了一个root根目录的概念,babel会尝试在根目录下找babel.config.js及其它扩展名文件来决定如何编译文件。

优点

  • 配置文件与需要处理的实践文件是分离的,所以使用范围非常广
  • 通过项目级配置文件可以轻松处理node_modules下或者软链的文件

缺点

  • 它依赖于工作目录,如果工作目录不是 monorepo 根目录,在 monorepos 中使用会更痛苦。

babel提供了两种配置文件的方式

  • 项目级配置文件
    • babel.config.json files, with the different extensions (.js.cjs.mjs)
  • 相对于文件路径的配置文件
    • .babelrc.json files, with the different extensions (.babelrc.js.cjs.mjs)
    • package.json files with a "babel" key

对于monorepo项目,应该要在整个项目的根目录(root)配置babel.config.json文件,在各个子包中配置.babelrc.json文件,需要注意这种配置需要在babel.config.json中配置babelrcRoots选项子包中的.babelrc.json文件才会生效,下图是相关的解释。

babel-loader仍然无法正常处理公共分包

按照文档说的方式配置完后,发现公共分包的文件还是没有被babel处理到,只能编译当前分包的文件。后面经过一系列尝试,发现在子包的.babelrc中如果不配置rootMode:upward将无法处理子包外层的目录,rootMode:upward可以让子包向上级root目录查找 babel.config.js 全局配置,也就能编译上级的目录。

这里还需要特别注意babel配置是有优先级之分的,如果是webpack项目,一般我们都会使用babel-loader对代码进行babel处理,但是我们项目一般会在.babelrc或者babel.config.json文件中配置@babel/preset-env,如果你在babel-loader中配置了只编译当前子包目录,而在@babel/preset-env中配置了包含子包目录和外层目录,这时候外层目录是否会生效呢? 后面经过尝试发现babel-loader的优先级会更高,外层目录将不会生效,它的流程应该是webpack -> babel-loader -> preset-env-> babel-core。因此只能修改babel-loader的配置,而babel-loader我们是集成在脚手架里面的,所以只能修改脚手架的配置了,下面是webpack配置链式写法,有需要可以自行转为常规写法。


通过上面的修改之后就可以正常编译公共分包了。
贴一下整体配置


www.babeljs.cn/docs/config…

node_modules的ES6编译问题

如何解决项目依赖中代码未转义

本来到这以为大功告成了,打包发布后发现页面还是白屏,于是去找了打包后的dist包看了下,发现公共分包的ES6语法已经被转义了,但是其他文件中还是有...扩展运算符,它指向了mtapp-cache库(一个我们自己封装的内部库),于是开始研究起mtapp-cache,发现mtapp-cache库中已经配置了babel-loader,已经配置了babel-loader为什么还会出现扩展运算符呢?后来在mtapp-cache库生成的min.js文件中发现它引用的一个依赖库object-scan中包含扩展运算符,object-scan库没有对代码进行babel处理,要知道babel6是会默认处理node_modules里面的内容的,而在babel7是不是处理node_modules里面的内容的,如果你需要处理node_modules里面的库,你需要像这样单独引入

这个故事告诉我们,如果我们自己开发库,要注意将代码babel处理下,这也是库开发者默认的会做的事情 !!

项目中cjs和esm语法混用可能导致白屏问题

处理完object-scan后,生成的代码也没有扩展运算符了,再次提交代码发包发现还是白屏。控制台报Uncaught TypeError: Cannot assign to read only property 'exports' of object '#<Object>',经过一番研究发现是mtapp-cache库中存在import和modules.exports也就是esm和cjs混用的情况。


在a文件中使用module.exports导出,在b文件中import a文件,这种操作就会导致上面的报错,解决方法要么就将a文件的module.exports改为export defaults导出,要么就通过配置babel的overrides,给混入的文件配置sourceType:"unambiguous",,让babel自己去决定是否转换import语法,该方法通常需要和override配置配合使用,以避免潜在的问题.

babeljs.io/docs/option…


babeljs.io/docs/option…

配置完后,项目终于不再白屏。

参考文章

juejin.cn/post/685681…

juejin.cn/post/720215…

juejin.cn/column/7031…