
小程序开发里有一类报错特别气人代码在开发者工具里跑得好好的一点真机预览页面直接白屏控制台甩出一句Uncaught SyntaxError: Unexpected token export。你翻遍自己写的业务代码一个export关键字都没找到于是开始怀疑编译器、怀疑基础库、怀疑人生。这个错误的本质其实非常明确——某一段 JS 代码在真正执行时仍然带着 ES Module 的export语法而执行它的运行环境只认 CommonJS。换句话说export这个词对那个环境来说就是天书词法分析阶段就崩了连一行业务逻辑都没跑到。这篇内容会把这条报错从头到尾拆开它到底是谁抛的、五类最常见的触发场景分别长什么样、怎么用三步把范围缩到一个文件、以及从改源码到改构建的四套落地方案。刚接触小程序模块机制的新手能照着排错被 npm 依赖坑过的老手也能在里面找到几条没注意过的细节。1. 报错现场还原这句 Unexpected token export 到底是谁抛出来的1.1 从报错文本本身能读出哪些信息先看这句报错的措辞。Uncaught SyntaxError里的Uncaught说明它不是被try/catch捕获后打印的业务异常而是引擎层面的语法错误SyntaxError说明代码在解析阶段就失败了压根没进入执行阶段Unexpected token export说明解析器在一个不该出现标识符的位置遇到了export这个关键字。这三条信息叠加起来结论很硬出问题的文件是以「脚本」身份被加载的而不是以「模块」身份被加载的。在 ES Module 规范里export只能出现在模块顶层解析器看到export时会把整个文件当作模块处理而在 CommonJS 或者传统的脚本执行环境里export只是一个普通标识符出现在语句开头就是语法错误。所以这条报错的完整含义是某段原本用 ES Module 语法写的代码被当成普通脚本塞进了一个不支持 ES Module 的运行时。它不是你的代码写错了而是你的代码没被处理就直接跑了。这里有个容易被忽略的点报错堆栈里往往只显示一个文件名和行号但那个文件可能并不是你手写的源文件。它可能是构建产物、可能是miniprogram_npm里的第三方文件、可能是某个包dist目录下的xxx.esm.js。我见过不止一次开发者盯着自己pages/index/index.js找了半小时最后发现报错文件是miniprogram_npm/some-lib/index.js。1.2 小程序的模块系统到底认不认 export小程序原生提供的是 CommonJS 风格的模块机制用require()引入用module.exports或exports导出每个文件天然是一个独立模块变量不会泄漏到全局。这套机制从很早就存在且在所有基础库版本上都稳定。那import/export能不能用能用但是有条件的能用。条件在于编译环节开发者工具在打包时会对 JS 做一遍转译把 ES Module 语法降级成 CommonJS。这个转译不是无条件开启的它依赖于「将 JS 编译成 ES5」这个开关不同版本的 IDE 里叫法略有差异老版本叫「ES6 转 ES5」。开关打开export会被转成exports.xxx ...开关关掉代码原样下发谁执行谁报错。这就解释了一个非常典型的分裂现象同一个项目不同人的机器上表现不一样。因为project.config.json里的编译设置属于本地设置有些人为了调试方便关了转译有些人默认开着代码同步过去之后行为就变了。这类问题排查起来最耗时间因为你会本能地认为代码一样结果就该一样。提示project.config.json中与这件事直接相关的字段是setting.es6是否转 ES5和setting.enhance是否启用增强编译。这两个开关的语义在不同版本的 IDE 里有过调整接手老项目时不要想当然打开配置文件确认一遍比猜快得多。1.3 为什么开发者工具能跑、真机却白屏这是这个报错最迷惑人的地方。开发者工具的渲染层和逻辑层跑在桌面端的浏览器内核上现代浏览器内核原生支持 ES Module。所以当一段带export的代码被原样下发时工具里可能只是没走进你预期的模块路径甚至根本不报错——因为浏览器认识这个语法。真机就不一样了。iOS 上的逻辑层跑在系统自带的 JS 引擎里Android 上的引擎版本也各不相同低版本基础库对 ES Module 的支持非常有限甚至完全没有。于是同一份代码工具里风平浪静真机上一进页面就抛语法错误、整个页面白屏。我踩过最难受的一次是功能在工具里测了两天都没问题提审后灰度环境全部白屏。后来定位到是某个新引入的工具库它的main字段指向的是一个.mjs文件而当时的构建流程没把它纳入转译范围。工具里因为内核支持所以没事真机直接原地爆炸。注意真机白屏 控制台只有一条 SyntaxError基本可以锁定是模块格式问题。如果真机上连报错都看不到可以挂上真机调试或者在App.onError里把错误信息写进本地存储下次启动时读出来看。2. 五类高频触发场景逐个对号入座2.1 第三方依赖的入口文件指向了 ESM 产物这是出现频率最高的一类。现在很多工具库为了同时兼容多种环境会在package.json里配置多个入口字段{ main: dist/index.cjs.js, module: dist/index.esm.js, exports: { .: { import: ./dist/index.esm.js, require: ./dist/index.cjs.js } } }问题在于小程序的 npm 构建流程解析入口字段的优先级和 Node 不完全一致。它会优先看包里有没有miniprogram字段没有的话回落到main。但有些包的main本身就指向 ESM 文件尤其是较新的、以打包工具为主的库这时候构建出来的miniprogram_npm里躺着的就是export语法一require就炸。还有一种更隐蔽的情况包同时提供了main和modulemain是 CommonJS 没错但你在代码里用了import x from pkg的写法转译器按module字段去解析最终打进包里的却是 ESM 版本。源码写法会影响依赖解析结果这一点很多人没意识到。判断方法很直接打开miniprogram_npm/包名/package.json看里面实际被引用的那个文件再打开那个文件的第一屏看看有没有export或export default。2.2 编译开关被关掉或者开了但没作用到目标文件前面说过「将 JS 编译成 ES5」这个开关决定了转译是否执行。但即使开关开着也有两种情况会漏第一种文件不在编译管线的输入范围内。编译管线只处理miniprogramRoot指向的目录以及构建 npm 产出的目录。如果你为了图省事直接把某个库的源码文件夹整个拖进了项目而那个文件夹刚好被packOptions.ignore或者.gitignore类似的忽略规则排除在外它就不会被转译。第二种转译器的行为受babel配置影响。如果项目根目录下有一个.babelrc或者babel.config.js它的presets配置可能覆盖了默认行为。比如只配置了babel/preset-env但没配modules: commonjs转译器就会认为模块语法不需要处理原样保留export。这种情况的典型特征是项目里所有手写文件都正常只有某个特定目录下的文件报错。一旦发现报错集中在某个路径下先去看那个路径有没有被排除在编译范围之外。2.3 文件落在了不参与转译的特殊目录小程序项目里有几个目录是特殊通道处理的编译规则和普通业务代码不一样目录/文件类型编译规则是否支持 ESM普通页面与组件pages、components完整转译支持开关开启时miniprogram_npm构建产物来自 npm 构建流程不再二次转译取决于包本身workers目录下的 worker 脚本走独立的简单编译能力受限不建议使用.wxs文件独立的脚本语言语法自成一套完全不支持插件项目的components、pages独立编译视插件配置云函数目录由服务端运行时执行与小程序端无关.wxs是最容易被误伤的地方。它的语法和 JS 有相似之处但模块机制完全是另一套只能用module.exports写成export一定报错。而 worker 脚本因为运行在独立的线程里编译流程更保守我一般直接按 CommonJS 写省得赌。还有一类分包和独立分包。独立分包是独立编译的如果分包里引用了一个主包里的 ESM 模块解析路径可能在编译期被处理成外部依赖而跳过转译运行时就会拿到带export的原始代码。2.4 跨端框架的依赖转译白名单漏配如果你用的是跨端框架编译到小程序这条路会更绕。这类框架底层是 webpack 或 vite 一类的打包器而打包器默认不转译node_modules里的代码——它们的假设是依赖包自己会提供浏览器可用的产物。于是你就需要在配置里显式声明哪些依赖需要被转译。以常见的配置项为例大概长这样// 各框架配置项名称不同逻辑是一致的 module.exports { // 关键把只提供 ESM 产物的包加进白名单 transpileDependencies: [ some-esm-only-lib, another-lib ] }这个白名单一旦漏配症状极其典型本地开发时热更新正常打包上传后真机白屏。因为开发模式下方 Webpack dev server 跑在现代浏览器里生产模式下的产物才会真正下发到小程序运行时。我见过一个项目卡了一整天最后发现就是一个正则表达式库没加进白名单。提示跨端项目排查这类问题最快的办法是直接去构建输出目录dist/dev/mp-weixin之类里搜export哪个文件的命中位置在node_modules对应的产物里就把那个包名加进白名单。2.5 动态执行与字符串拼接里的隐藏语法最后一类相对少见但排查难度最高。有些代码会用new Function()、eval()或者字符串模板来动态构造函数体。如果这段字符串里包含了export一样会报同样的错。典型来源是两个一是某些模板引擎或者表达式求值库内部用new Function编译表达式二是从服务端下发的可执行配置被前端直接eval执行。后一种本身就不推荐但如果历史代码里存在排查时很容易漏掉因为你在源码里搜索export是搜不到的——它是运行时拼出来的。另外还有一种可能性某些混淆或压缩工具在处理代码时会把export保留下来但改变了上下文比如把一个模块的顶层语句包进了一个函数里。这种情况下的报错位置会很奇怪指向的往往是一段看起来完全正常的代码。3. 缩小范围的三步定位法3.1 先读堆栈再做二分隔离拿到报错后的第一件事不是改代码是读堆栈。堆栈里通常会有文件名哪怕被压缩过也能从路径结构看出它属于业务代码、miniprogram_npm、还是某个框架的运行时。路径在miniprogram_npm下直接跳到 2.1 节路径在workers下跳到 2.3 节路径是一堆数字组成的 chunk 名说明是框架产物往 2.4 节走。如果堆栈信息被压缩得看不出所以然就用二分法。具体做法在app.js的onLaunch里把可疑模块的require一行一行注释掉每次重新编译真机预览看报错是否消失。模块数量在十个以内时这个方法十分钟就能定位超过十个先按目录分组二分到目录再二分到文件。二分的时候有个小技巧不要只注释 import要把使用该模块的代码一起注释掉否则打包器可能因为引用了但没用到而做 tree-shaking导致你注释了个寂寞问题依旧在。3.2 看构建产物别只看源码新手最容易犯的错是在源码里全局搜索export搜不到就认为是编译器的问题。源码里没有export不代表产物里没有。第三方依赖、框架运行时、被内联进来的工具函数都可能在产物里引入export。正确的做法是看产物。具体位置取决于项目类型原生小程序开发者工具菜单里的「代码依赖分析」或者「本地代码包分析」能看到最终打进包里的所有文件列表和体积跨端框架直接打开dist下对应平台的输出目录上传后的线上包可以在后台的版本管理里下载代码包解压后搜。拿到产物目录后用一个命令就能扫出来# 在当前目录及其子目录的所有 js 文件里找 export 语句 grep -rn --include*.js -E ^\s*export\s(default|\{|const|let|var|function|class|\*) .这条命令会把所有以export开头的行揪出来附带文件名和行号。如果命中的文件在你预期之外那基本就是它了。3.3 真机日志与工具日志交叉验证定位到可疑文件之后还需要一次交叉验证确认这个文件在真机上确实是没被转译的状态。做法很简单在可疑文件的开头加一行console.log(MARKER_XXX)然后分别在工具和真机上跑。如果工具里能打印出来、真机打印不出来、并且真机报错说明这个文件在真机上解析阶段就挂了那就是它。反过来如果两边都打印不出来说明它压根没被加载问题在别的地方——可能是加载顺序问题也可能是某个前置模块报错导致后续模块根本没执行。这一步的意义在于排除假阳性。有时候报错的文件只是受害者真正的元凶是它依赖的某个模块先抛了错导致整条加载链断掉错误信息被覆盖了。4. 四套修复方案与完整的语法对照4.1 源码级手改把 export 换成 module.exports最简单直接的办法是把手写代码里的 ES Module 语法改成 CommonJS。适合的场景是报错文件是你自己写的、或者你可以直接改的少量文件。下面这张对照表可以直接抄ES Module 写法CommonJS 等价写法需要注意的地方export default foomodule.exports foo引用方require()直接拿到fooexport default { a, b }module.exports { a, b }对象字面量要整体赋值export const a 1exports.a 1不能写成exports {...}export function f() {}exports.f function f() {}函数声明改成函数表达式赋值export class C {}exports.C class C {}同上export { a, b }module.exports { a, b }一次性整体赋值export { a as b }exports.b a改名导出拆成单条赋值export * from ./xObject.assign(module.exports, require(./x))会把default也拷过来按需过滤import x from ./xconst x require(./x)若原模块是default导出按 5.1 节处理import { a } from ./xconst { a } require(./x)解构赋值import * as ns from ./xconst ns require(./x)整个模块对象import ./xrequire(./x)副作用导入直接 require 即可改的过程中有两个坑必须提。第一个坑exports和module.exports的指针关系。exports只是module.exports的一个初始引用。你写exports.a 1是往同一个对象上挂属性没问题但你写exports { a: 1 }就是把指针指向了新对象module.exports还是原来那个空对象导出结果为空。所以只要涉及整体替换导出内容一律用module.exports ...。第二个坑动态导出。ES Module 的导出是静态绑定的导出的变量在源模块里被重新赋值时引用方拿到的值会跟着变live binding。CommonJS 是值拷贝对基本类型而言exports.a 1之后再a 2引用方拿到的还是 1。如果原代码依赖了这个特性改成 CommonJS 之后逻辑会失效。这种情况需要用 getter 形式模拟let count 0; Object.defineProperty(exports, count, { enumerable: true, get() { return count; } });4.2 依赖层面换入口、加别名、退回旧版本如果报错文件在miniprogram_npm里说明问题出在依赖包本身改业务代码没用。这时有三个方向方向一让构建流程取到 CommonJS 入口。大多数同时支持两种模块格式的包都会提供 CJS 产物。可以检查包里有没有miniprogram字段——小程序 npm 构建会优先取这个字段。如果包里没有但你确认它有 CJS 产物可以自己写一个薄薄的中转文件// utils/some-lib.js // 手动指向包里的 CommonJS 构建产物 module.exports require(../miniprogram_npm/some-lib/dist/index.cjs.js);然后业务代码统一require这个中转文件不要直接require(some-lib)。这样绕开了包package.json的入口解析直接把指针指到正确的位置。方向二退版本。这是最省事但需要慎重的方案。查一下这个包的版本历史找出最后一个纯 CommonJS 发布的版本把版本号锁死。好处是立竿见影坏处是失去了后续的安全更新和功能。我的做法是先在package.json里用精确版本号不带^锁住同时在项目文档里留一条记录写明为什么锁在这个版本避免后来的人随手升级踩回去。方向三换库。如果这个库的定位就是现代浏览器 only强行适配小程序的成本可能高于换一个。判断标准看两点它有没有依赖 DOM 或浏览器专属 API、它的打包产物是不是只面向现代环境。两项都中换库比改造更划算。注意改完node_modules或者锁了版本之后必须重新执行「构建 npm」。小程序用的是构建产物快照不是node_modules的实时映射。我见过有人改了版本跑npm install然后直接编译报同样的错折腾半天才发现没重新构建。构建前建议把miniprogram_npm整个删掉避免残留文件干扰。4.3 构建层面把开关和目录配对起来这一层的目标是让该转译的文件都进转译管线。分三件事做。第一件确认全局开关。打开project.config.json检查setting里的编译相关字段。对于团队协作项目建议把这份配置提交到版本库而不是留在本地设置里——本地设置是不同步的这是别人机器上好好的这类问题的根源。第二件确认文件没有被排除。检查packOptions.ignore和项目根目录的忽略规则确保业务代码目录没有被误伤。特别留意那些为了减小包体积加的忽略规则它们往往是历史遗留加的时候没考虑编译范围。第三件CI 环境要单独配置。用命令行工具上传时编译设置是在调用参数里传的不是读本地 IDE 的配置。如果 CI 脚本里漏了转译相关的选项就会出现本地预览正常、流水线打出来的包白屏这种极其难查的问题。建议在 CI 脚本里显式写全编译选项并在构建后加一步产物扫描# 构建完成后扫描产物发现顶层 export 就让流水线失败 if grep -rqE ^\s*export\s(default|\{|\*|const|let|var|function|class) ./dist; then echo 产物中存在未转译的 ES Module 语法构建终止 exit 1 fi这一步加进去之后这类问题就再也不会漏到线上了。成本不到十行脚本收益是把上线后发现变成提交时就发现。4.4 特殊环境下的手写兜底workers目录和.wxs文件这两类我建议直接放弃 ESM 语法老老实实写 CommonJS。worker 脚本的运行环境相对独立编译能力有限用它去赌 ESM 转译是否生效性价比太低。.wxs更是完全另一套语法体系它有自己的module.exports跟 JS 的模块系统不通用混着写只会给自己埋雷。还有一种兜底思路是在入口做统一适配。比如整个项目里只有一个第三方库是 ESM 产物你可以在app.js的最开始就把它require进来配合 4.2 节的中转文件把不一致性收敛到一个点。这样即使将来要换方案改动面也只有一处。但如果依赖里有大量 ESM 模块我不推荐在运行时做转译比如引入一个浏览器版的转译器。小程序包体积本来就紧张运行时转译会带来明显的启动开销而且转译器本身也可能依赖浏览器 API陷入新的坑。这种情况下应该回到构建层面解决而不是在运行时打补丁。5. 几个只有踩过才知道的细节5.1 default 互操作与 __esModule 标记这是改代码时最容易翻车的地方。ES Module 的export default foo和 CommonJS 的module.exports foo看起来等价但在混合引用的场景下不等价。转译器在做 ESM → CJS 转换时会在产物里加一个标记Object.defineProperty(exports, __esModule, { value: true }); exports.default foo;这个标记的意义是告诉引用方我这个模块原本是 ESM我的default导出挂在exports.default上不在module.exports上。于是问题来了如果 A 文件是转译过的 ESM导出挂在.defaultB 文件用require()去拿拿到的会是{ __esModule: true, default: foo }这个对象不是foo本身。这时候 B 里写const x require(./a)然后x()就会报不是一个函数。反过来如果 A 是手写的 CommonJSmodule.exports fooB 用import x from ./a引入转译器会做兼容处理通常能正确拿到foo——但这个行为依赖转译器的interop配置不同工具链的处理不完全一致。我的经验是同一个模块导出方式要么全 ESM要么全 CJS不要混。如果必须混引用方加一层判断const mod require(./some-module); // 兼容两种导出形态 const actual mod mod.__esModule ? mod.default : mod;这行判断成本很低但能省掉大量为什么拿到的不是函数的排查时间。5.2 循环依赖下导出提升的行为差异ES Module 的import是提升的而且绑定是活的CommonJS 的require是同步执行的拿到的是执行到那一刻的快照。这个差异在循环依赖时会暴露得很明显。假设 A 依赖 BB 又依赖 A。ESM 的语义下B 在初始化时看到的是 A 的未初始化绑定只要不在模块顶层立即使用运行到后面就能拿到正确的值。改成 CommonJS 之后B 在require(./a)的那一刻拿到的是 A 的半成品导出对象——A 还没执行完所以module.exports上可能什么都没有。具体表现是某个函数或者常量在循环依赖的某一环里突然变成了undefined而且报错信息跟export毫无关系只是恰好在你改完模块格式之后出现。遇到这种情况处理办法不是去研究模块加载顺序而是打破循环。把两边都依赖的公共部分抽到一个第三方文件里让依赖关系变成单向。这比调加载顺序可靠得多也更好维护。5.3 构建缓存与依赖变更后的重构建小程序的 npm 构建是有缓存和快照的。miniprogram_npm目录里的内容是某一次构建的结果它和node_modules之间不存在实时的映射关系。这意味着装了新包不重新构建 npm业务代码里require不到升级了包不重新构建 npm用的还是老版本的产物手动改了miniprogram_npm里的文件下次构建会被覆盖掉删了包但没重新构建残留文件还在包体积白涨。我现在的习惯是只要动过package.json就先删miniprogram_npm再执行构建绝不做增量式的想当然。多花的那十几秒比排查半小时要划算。另外跨端框架的node_modules缓存更顽固。有时候明明改了transpileDependencies构建结果没变那是因为打包器的持久化缓存没失效。这种情况下要先清缓存再构建否则你会怀疑自己改的配置根本没生效——事实上确实没生效。5.4 上线前的模块格式自查清单把上面这些内容收敛成一张可以照着走的清单我个人是把它贴在项目 README 里的检查项具体动作不合格的表现全局转译开关确认project.config.json里编译相关字段已开启并提交入库换台机器就出错依赖入口字段检查miniprogram_npm里每个包的入口文件是否含export真机白屏、只有工具正常跨端转译白名单把只发 ESM 产物的包全部加入transpileDependencies开发正常、生产白屏特殊目录workers、.wxs统一手写 CommonJS局部功能失效产物扫描CI 里加一步 grep 检查顶层export问题漏到线上循环依赖检查模块依赖图是否存在环某些导出变成undefined重构建改过依赖后清空miniprogram_npm再构建改了没用、报错照旧还有一条清单外的经验接手老项目时先跑一遍真机预览不要只在工具里看。工具能跑不代表真机能跑这个信息差是这类问题的温床。我现在的习惯是新项目第一次拉下来不管三七二十一先真机预览一遍把所有模块加载一遍早暴露早处理。真机预览的时候如果是多页面项目建议每个页面都点一遍尤其是那些引用了第三方库、图表库、富文本解析库的页面——这些恰恰是最容易引入 ESM 依赖的地方。工具里点一遍、真机再点一遍两边的控制台都盯一下比上线后被动救火强太多。最后再分享一个小技巧在app.js里挂一个全局的错误收集把onError里的错误信息和堆栈写进本地存储保留最近二十条。真机上出问题的时候用户退出重进一次你就能从存储里把现场捞出来。这个手段成本极低但对那些只在特定机型上白屏的问题几乎是唯一可行的取证方式。