小程序开发里有一类报错特别气人:代码在开发者工具里跑得好好的,一点真机预览,页面直接白屏,控制台甩出一句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和module,main是 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 foo | module.exports = foo | 引用方require()直接拿到foo |
export default { a, b } | module.exports = { a, b } | 对象字面量要整体赋值 |
export const a = 1 | exports.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 './x' | Object.assign(module.exports, require('./x')) | 会把default也拷过来,按需过滤 |
import x from './x' | const x = require('./x') | 若原模块是default导出,按 5.1 节处理 |
import { a } from './x' | const { a } = require('./x') | 解构赋值 |
import * as ns from './x' | const ns = require('./x') | 整个模块对象 |
import './x' | require('./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(导出挂在.default),B 文件用require()去拿,拿到的会是{ __esModule: true, default: foo }这个对象,不是foo本身。这时候 B 里写const x = require('./a')然后x()就会报"不是一个函数"。
反过来,如果 A 是手写的 CommonJS(module.exports = foo),B 用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 依赖 B,B 又依赖 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里的错误信息和堆栈写进本地存储,保留最近二十条。真机上出问题的时候,用户退出重进一次,你就能从存储里把现场捞出来。这个手段成本极低,但对那些"只在特定机型上白屏"的问题,几乎是唯一可行的取证方式。