凌晨十二点负责上线的同事在群里发了一张截图线上页面白屏控制台里躺着一行Uncaught TypeError: l.a.browse is not a function。我第一反应是浏览器缓存让他强刷、清缓存、换设备全都没用。第二天拉下发布包本地一看开发环境跑得好好的npm run build之后一部署就挂。这种报错在网上能搜到不少但大部分帖子都在猜因为l.a.browse这种报错信息是压缩改名后的结果直接读它等于让一个讲方言的人给你指路。这篇文章我把自己完整的排查链路写出来怎么把这行乱码翻译回源码、最常见的三类根因是什么、以及最终怎么修的。1. 白屏现场还原压缩产物里的 l.a.browse 是怎么来的1.1 生产构建为什么非要把变量名改成单字母先别急着打开源码搜索我是不是哪里写错了。这一段报错信息根本不是你的源码它是 Webpack 生产构建产物的一部分。Webpack 在mode: production下会做几件默认事开压缩、开 tree shaking、开作用域提升scope hoisting。其中压缩这一步用的是 Terser。Terser 干的活里有一项叫改名mangle会把局部变量、函数参数、模块内部的命名全部缩成最短形式。你源码里写了一个fileUtils打包后它可能就变成l你再写一个previewHelper它可能变成a。这个过程对开发者来说相当于用一种最短编码重新写了一边代码目的是让产物体积更小、更难被直接抄走。看一下最简单的对照// 源码长这样 function initFilePanel(sourceList) { const fileUtils loadFileUtils(sourceList) return fileUtils.browse() }打包压缩后大致长成这样function initFilePanel(n){const lloadFileUtils(n);return l.browse()}所以l不是一个高深的概念它只是原来的某个变量被压缩后的新名字。同理报错信息里的l.a.browse对应到源码里大概率是这么一段访问链先拿某个模块对象再取里面的某个局部对象最后在这个对象上调用browse方法。真正值得注意的一点是browse这个名字没有被压缩。这里有个 Terser 的关键特性默认情况下Terser 不会去改对象属性名更不会改动模块对外导出的名称。因为如果它把公开接口的方法名也改了调用方全部会失效整个生态就崩了。所以browse能原样出现在报错里本身就说明问题出在某个对象上缺少名为 browse 的方法或属性而不是出在一个叫 browse 的局部变量上。这给我们留下了一个非常可靠的搜索锚点。1.2 报错信息里哪些线索值得抓住压缩后的报错看着一团乱麻但其实包含三个关键线索线索含义排查方向browse is not a function代码尝试调用 browse但运行时的对象上没有这个函数重点查 browse 的来源模块、导出方式l.a.前缀访问链经过了模块对象和嵌套对象原代码大概率涉及 import / export不是单纯局部逻辑报错所在文件app.xxx.js或vendor.xxx.js判断错误出在自有代码还是第三方依赖自有代码查业务模块vendor 查依赖版本和打包配置有经验的排查者看到这种报错第一反应不会是去读压缩代码而是打开浏览器的 Sources 面板看到具体文件后选择格式化。格式化之后压缩代码会展开成相对可读的形状再配合搜索browse你基本能定位到报错点附近的上下文。但格式化只能还原代码结构还原不了原始变量名所以要真正搞懂根因还是得走 Source Map 这条路。2. 第一步永远是让打包代码说人话Source Map 临时调试法2.1 临时开启 source-map 的配置改法遇到生产构建才出现的问题我的习惯是先临时给生产构建开 Source Map把压缩后的报错映射回原始源码。这样做的好处是构建产物本身还是压缩后的形态只是额外生成了.map文件报错时的调用栈会自动翻译成源码路径和行号。在 Webpack 配置里临时加上一行// webpack.prod.js module.exports { mode: production, devtool: source-map, // 临时加修完记得去掉 }如果你用的是基于 Webpack 的脚手架比如某款以 Vue 为主的旧版工具链入口配置略有不同但原理一样本质都是给生产构建打开 source-map 开关。改完重新npm run build把 dist 目录放到本地静态服务器上跑一遍之前那个l.a.browse is not a function就会变成类似TypeError: browse is not a function at preview-helper.js:8:5 at file-browse.js:12:20看到这行问题范围一下子就缩小了。注意这个 source-map 开关只在排查阶段开启不要顺手提交到正式配置里。Source Map 文件一旦跟着静态资源公开别人用浏览器 DevTools 就能完整还原你的源码属于泄露风险后面我会讲怎么处理线上误报时的取码问题。2.2 备选方案关掉压缩做对照实验有时候你已经生成了旧的 dist 包不想为了调试重新构建或者构建工具对 source-map 开关有限制这时候可以做一个更快的对照实验临时关掉压缩。module.exports { optimization: { minimize: false, }, }关掉压缩之后重新构建部署如果报错直接消失了说明问题十有八九和 Terser 的改名、变量提升、模块合并顺序强相关重点检查循环依赖和模块初始化顺序如果报错还在说明模块图本身就有问题跟你压不压缩没关系重点检查导入导出方式。这个对照实验比单纯开 Source Map 多提供了一条判断维度我排查这类开发环境正常、生产环境白屏的问题时通常两个都做先开 source-map 看清 stack再关压缩验证一下问题的触发条件两轮下来基本能锁定方向。2.3 不重新构建的快速定位法如果你手上只有旧的 dist 包又没法立刻重新构建还有一条土办法直接在压缩产物里搜关键字。浏览器 DevTools 里打开报错指向的那个 js 文件点左下角格式化按钮然后搜索browse看附近代码长什么样或者在终端里直接 grepgrep -o .\{0,80\}browse.\{0,80\} dist/js/app.*.js | head -20这个命令会从产物里抽出一段段含browse的上下文配合报错行号基本能看出它是在哪个模块、以什么方式被调用的。虽然不能像 Source Map 那样直接还原源码但能帮你在没有构建环境的情况下快速确认是业务代码调用还是第三方库在调用。3. 顺着调用栈往上查最常撞上这三类真凶3.1 循环依赖开发环境不报错、构建后必翻车的头号元凶我把这类问题排第一位因为它太典型了。两个模块互相 import就叫循环依赖。ES Module 本身允许循环依赖存在模块之间的 import 关系是活绑定live binding也就是说一个模块导出的变量在另一个模块里通过 import 引用时指向的是同一个内存位置而不是拷贝值。但活绑定有个前提你只能在运行时通过引用去读取这个值不能在另一个模块初始化完成的瞬间去立即读取它。看一个最小复现// a.js import { setup } from ./b export const browse () { setup() }// b.js import { browse } from ./a export const setup () { browse() }如果b.js在模块顶层立刻使用browse// b.js import { browse } from ./a const target browse // 模块顶层立即读取 a 模块的导出 export const setup () { target() }这时候就出问题了。当加载器先执行a.jsa.js去 importb.jsb.js又回头 import 尚未初始化完成的a.js。browse这个const导出此刻还处于未初始化状态b.js拿到的就是一个undefined。等a.js执行完browse才被赋值但b.js早就把它丢进target里保存了之后调用target()自然就是 not a function。为什么开发环境不容易暴露因为开发模式下 Webpack 把每个模块都包成函数用__webpack_require__懒执行模块的实际求值顺序常常和 import 语句的书写顺序不完全一致很多时候碰巧加载顺序正好问题就被藏住了。生产构建则不同Webpack 会启用模块合并ModuleConcatenation把多个模块拍平进同一个作用域执行顺序变了原来藏在角落里的未初始化读取就暴露出来了。这就是开发好好的、一打包就挂的直接原因。破法有两个一是把互相依赖的那部分代码抽到第三个模块里让依赖关系变成单向的二是把顶层立即读取改成函数内延迟读取利用活绑定特性在真正调用时再去取导出值。改完后循环依赖消失问题自然解决。3.2 导入导出互操作失效default、命名导出、CJS 混用的经典翻车第二个高频根因是模块系统的互操作问题尤其容易出现在项目用了 Babel/TypeScript 转译 引用了老式 CommonJS 包的组合里。最常见的三种形态第一种CommonJS 库配命名导入// node_modules/some-lib/index.js module.exports { browse: function () {} }// 你的代码 import { browse } from some-libWebpack 对module.exports的静态结构做了分析理论上能支持这种命名导入但一旦库内部是动态挂载导出字段的或者它同时混了exports.defaultWebpack 的静态分析就会失手运行时browse是undefined。第二种ES Module 里 default 导出对象你却用命名导入去拿// lib 内部 export default { browse: () {} }// 你的代码 import { browse } from lib // browse undefined正确定位是import lib from lib; lib.browse()。默认导出对象不是命名导出browse属性被挂在了default对象上命名导入当然取不到。第三种.default陷阱。Babel 在把 ES Module 转成 CommonJS 时会生成一个_interopRequireDefault的辅助函数把module.exports包成{ default: module.exports }。如果你用了某种写法导致运行时访问的是l.default.browse而l.default本身又不存在的模块对象错误信息和这一类完全是兄弟关系。遇到这类问题先去翻报错来源模块的源码看它到底是怎么导出的然后回头检查你的 import 写法。如果源码里export default就用 default 导入如果module.exports obj优先用import obj from或者const obj require()。3.3 同一份源码两个版本解析字段和多副本冲突第三个根因在大型项目里也很常见同一个库在依赖树里被打进了两个不同版本或者同一个库的 ESM 构建和 CJS 构建被同时命中。先检查一下npm ls some-lib如果看到一串同一库不同版本的树状结构说明依赖没收敛。不同版本会产生两个模块实例某些方法是从另一个副本里拿出来的运行时状态也不互通经常表现为某个方法在独立验证时好用在整个应用里就是undefined。再检查 Webpack 的模块解析顺序。Webpack 5 默认的resolve.mainFields是[browser, module, main]很多库会同时发布 ESM 构建和 CJS 构建字段不同、导出方式也不同。如果某个库的 ESM 构建里没有你 import 的那个方法而 CJS 构建有打包工具却选了 ESM 构建就必然报 not a function。解法是手动钉死解析入口resolve: { alias: { some-lib$: some-lib/dist/some-lib.cjs.js, }, }或者调整mainFields。这类问题在升级依赖版本后特别容易爆发排查时把谁变了纳入怀疑清单效率会高很多。4. 我这次的真实排查过程一个工具函数引发的 not a function4.1 出问题的模块关系说回我自己这次踩的坑。项目是一个后台管理系统里面有个上传面板负责把用户选中的文件解析成预览列表。相关文件有三个src/utils/file-browse.js导出browse方法负责核心的文件解析逻辑内部还引用了预览配置模块src/helpers/preview-helper.js预览辅助模块内部在模块顶层维护了一个配置表配置表里引用了browsesrc/components/upload-panel.vue页面组件从file-browse.js里导入browse并调用。核心代码大致是这样// src/utils/file-browse.js import { getViewConfig } from ../helpers/preview-helper export const browse (files) { const config getViewConfig(files) return config.list }// src/helpers/preview-helper.js import { browse } from ../utils/file-browse const helperTable { browse } // 模块顶层立即读取 browse export const getViewConfig (files) helperTable.browse(files)看到没有问题就藏在preview-helper.js的顶层它 import 了file-browse.js里的browse并且在模块执行时立刻把它读进helperTable。而file-browse.js又反向 import 了preview-helper.js的getViewConfig。这两个文件形成了完整循环。4.2 为什么开发环境一直好好的这才是这个坑最折磨人的地方。开发模式下模块是用函数包起来懒执行的实际加载顺序取决于入口文件的 import 顺序。上传面板页面先 import 了file-browse.jsfile-browse.js开始执行执行到import { getViewConfig }时转去加载preview-helper.jspreview-helper.js要加载file-browse.js发现它正在加载中于是拿到一个尚未初始化的模块命名空间。照理说在这里就会翻车。但开发模式下还有一个缓冲很多脚手架会开启模块热更新、保留 ES 模块原生的解析方式加上入口里还有其他组件提前加载了preview-helper.js导致preview-helper.js在browse已经初始化之后才走完顶层逻辑于是问题被掩盖了。换句话说开发环境能跑纯粹是求值顺序的巧合。生产构建把所有模块拍平进一个作用域求值顺序重新排列preview-helper.js的顶层代码在browse尚未赋值时就执行helperTable.browse被写成了undefined后面一调用白屏当场爆炸。压缩后的报错信息l.a.browse is not a function实际上就是这个过程的一个快照l是模块命名空间的压缩名a是helperTable被压缩后的名字browse还是原样因为它来自跨模块的导出接口Terser 不会动它。4.3 修复、验证与收尾我用了最彻底的修法把被循环依赖的两段逻辑重新规划让依赖变成单向的。新建一个不依赖任何业务模块的叶子模块// src/utils/browse-core.js export const browse (files) { // 核心解析逻辑不依赖 preview-helper return [] }然后让file-browse.js和preview-helper.js都改为从browse-core.js导入// src/utils/file-browse.js import { browse as coreBrowse } from ./browse-core import { getViewConfig } from ../helpers/preview-helper export const browse (files) { const config getViewConfig(files) return config.list }// src/helpers/preview-helper.js import { browse as coreBrowse } from ../utils/browse-core const helperTable { browse: coreBrowse } export const getViewConfig (files) helperTable.browse(files)这样preview-helper.js不再依赖file-browse.js循环被彻底切断。注意我这里为了最小改动保留了file-browse.js对preview-helper.js的调用关系但两个模块不再互相引用所以不会再出现未初始化读取。验证分三步走第一步重新npm run build确认构建过程没有报错第二步用本地静态服务器把 dist 跑起来浏览器打开控制台确认没有再出现browse is not a function第三步在产物里搜索helperTable相关的压缩字段确认旧的访问链已经消失。顺便把circular-dependency-plugin加进构建配置里让以后的循环依赖一构建就报错而不是等上线后白屏。5. 让这类报错断根加三道关卡把问题挡在上线前5.1 第一道代码阶段的循环依赖扫描循环依赖是这类白屏问题的第一大来源但很多项目从来没有对它做过静态检查。最方便的一步是给 ESLint 加上import/no-cycle规则// .eslintrc.js module.exports { plugins: [import], rules: { import/no-cycle: [error, { maxDepth: 1 }], }, }这一步能拦截绝大多数明显并且直接的循环依赖。但 ESLint 规则是针对单文件的静态解析有些运行时才会出现的循环它未必能完全覆盖所以还要配合构建阶段的插件。在 Webpack 配置里加circular-dependency-pluginconst CircularDependencyPlugin require(circular-dependency-plugin) module.exports { plugins: [ new CircularDependencyPlugin({ exclude: /node_modules/, failOnError: true, allowAsyncCycles: false, cwd: process.cwd(), }), ], }设置failOnError: true之后任何循环依赖一旦出现构建直接失败并打印出完整的循环调用链。我修复完现场问题后第一时间把这个插件加上了就是为了避免同类问题再次偷偷上线。5.2 第二道构建阶段的产物结构复查第二道关卡是在构建流程里加一个产物可视化分析。webpack-bundle-analyzer会生成一张依赖占比图你能直观看到每个 chunk 里塞了什么、有没有同一个库被重复打包。另一个更轻量的工具是source-map-explorer可以直接对着 dist 文件分析。一般构建脚本里加一条命令npx webpack --profile --json stats.json npx webpack-bundle-analyzer stats.json这一步主要防 3.3 节那个问题多个版本的同一个库、或者同一个库的 ESM/CJS 两份构建被打进同一个产物。可视化图里出现两个长得几乎一样的色块就说明依赖没收敛赶紧去查npm ls。这类问题平时不发作一发作就是线上白屏加上复查环节能省掉很多半夜救火的经历。5.3 第三道上线阶段的错误采集与源码还原就算前面两道关卡都加了线上还是可能出别的运行时问题所以最后一道关卡是错误采集。前端加一个全局错误监听把压缩后的报错信息收集下来window.addEventListener(error, (event) { const { message, filename, lineno, colno, error } event fetch(/api/log/error, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message, filename, lineno, colno, stack: error error.stack, }), }) })配合 Source Map 做源码还原。生产环境不建议用devtool: source-map直接把.map文件暴露到公开静态目录可以用devtool: hidden-source-map。它生成 Source Map 文件但压缩产物里不写//# sourceMappingURLxxx.map这行注释所以普通浏览器不会加载这个文件、外部也不容易访问。你需要做的只是把.map文件单独上传给错误采集服务让服务端拿它做堆栈还原。这样线上报错传到监控后台时显示的就是原始源码的行号而不是l.a.browse这种压缩乱码。最后提醒一个容易忽略的部署细节修复之后如果线上还是白屏先排查缓存。打包配置里保证输出文件名带内容哈希比如output: { filename: [name].[contenthash:8].js }这样每次发布新版本文件名会随内容变化CDN 缓存自然会失效。如果你用的是老项目、静态资源由别的系统托管发布后记得手动刷新 CDN 缓存否则你修的是新版线上跑的却是旧包怎么看都是没修好。这类xxx is not a function的报错看着吓人其实是压缩混淆带来的可读性灾难。遇到先别慌开 Source Map 翻译回源码查调用栈和模块依赖关系多半能在循环依赖和导入导出互操作里找到答案。把三道关卡建好之后这类型的问题基本就不会再以半夜白屏的形式出现在你面前了。