☰
生产白屏与not a function:Source Map和循环依赖排查实战
2026/10/11 4:04:38 网站建设 项目流程

凌晨十二点,负责上线的同事在群里发了一张截图:线上页面白屏,控制台里躺着一行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 l=loadFileUtils(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,看附近代码长什么样;或者在终端里直接 grep:

grep -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.js,a.js去 importb.js,b.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-lib'

Webpack 对module.exports的静态结构做了分析,理论上能支持这种命名导入,但一旦库内部是动态挂载导出字段的,或者它同时混了exports.default,Webpack 的静态分析就会失手,运行时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 function

4.1 出问题的模块关系

说回我自己这次踩的坑。项目是一个后台管理系统,里面有个上传面板,负责把用户选中的文件解析成预览列表。相关文件有三个:

  • src/utils/file-browse.js:导出browse方法,负责核心的文件解析逻辑,内部还引用了预览配置模块;
  • src/helpers/preview-helper.js:预览辅助模块,内部在模块顶层维护了一个配置表,配置表里引用了browse;
  • src/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.js,file-browse.js开始执行,执行到import { getViewConfig }时转去加载preview-helper.js,preview-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-plugin:

const 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 文件,但压缩产物里不写//# sourceMappingURL=xxx.map这行注释,所以普通浏览器不会加载这个文件、外部也不容易访问。你需要做的只是把.map文件单独上传给错误采集服务,让服务端拿它做堆栈还原。这样线上报错传到监控后台时,显示的就是原始源码的行号,而不是l.a.browse这种压缩乱码。

最后提醒一个容易忽略的部署细节:修复之后如果线上还是白屏,先排查缓存。打包配置里保证输出文件名带内容哈希,比如output: { filename: '[name].[contenthash:8].js' },这样每次发布新版本,文件名会随内容变化,CDN 缓存自然会失效。如果你用的是老项目、静态资源由别的系统托管,发布后记得手动刷新 CDN 缓存,否则你修的是新版,线上跑的却是旧包,怎么看都是"没修好"。

这类xxx is not a function的报错,看着吓人,其实是压缩混淆带来的可读性灾难。遇到先别慌,开 Source Map 翻译回源码,查调用栈和模块依赖关系,多半能在循环依赖和导入导出互操作里找到答案。把三道关卡建好之后,这类型的问题基本就不会再以"半夜白屏"的形式出现在你面前了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询