凌晨改完最后一版页面,真机预览一点击就白屏,控制台一行红字:Uncaught SyntaxError: Unexpected token export。这种报错最气人的地方在于它不告诉你"哪一行有问题"以外的任何信息,堆栈还经常指向一个你根本没写过的文件。如果你正在搜Uncaught SyntaxError: Unexpected token export这个关键词,大概率说明你已经把某个第三方库装进项目、构建 npm 之后跑不起来了。这篇内容就是把这件事从头到尾拆开:这个报错在小程序环境里到底意味着什么,为什么本地开发能跑、真机就崩,五种修法各自适合什么场景,以及我踩过的那些坑。适合已经会写页面、但对小程序编译链路和 npm 构建机制不太熟的同学,也适合被这个问题卡了半天想直接抄方案的老手。
1. 这个报错到底在说什么:先把 export 这件事讲透
1.1 JS 引擎眼里的 export 就是个非法字符
浏览器和 JS 引擎加载一段 JavaScript 有两种身份:脚本(Script)和模块(Module)。同一段代码,以脚本身份解析时,export、import是保留字之外的东西,解析器走到第一个export就直接抛语法错误,连执行都不执行。以模块身份解析时,引擎才认识这套语法。这就好比同一份文件,用记事本打开是乱码,用专门的软件打开才正常——内容没变,是"打开方式"决定了结果。
所以Unexpected token export的本质不是"你的 export 写错了",而是"这段代码被当作普通脚本加载了"。它压根没进入模块解析流程,自然不会去做依赖分析、也不会去做语法降级。你在源码里写export default之所以没事,是因为开发阶段有一层编译器在你按下保存之后就把这些语法改写成了module.exports,等代码真正送到引擎手里时,export早就消失了。一旦某个文件绕过了这层编译器,export就会原封不动地送到引擎面前,报错只是时间问题。
这里有个很重要的认知:报错的位置不是问题的位置。报错行往往是一个库文件的第 1 行第 0 列,看起来像是"这个文件有问题",但实际上问题出在"这个文件为什么没被编译"上。方向搞反了,你会一直在那个文件里找语法错误,而它其实一个字符都没写错。
1.2 小程序为什么格外容易触发:三层加载链路
浏览器里跑 ESM 早就是标配,<script type="module">一写就行。小程序不一样,它的逻辑层 JS 引擎按脚本方式加载代码,模块系统是自己实现的一套 CommonJS 风格的东西:require同步引入,module.exports导出。这套机制决定了它对 ES Module 语法的支持完全依赖编译期的转译,运行期是不认的。
真正让问题变复杂的是链路上有三层,每层的覆盖范围不一样:
第一层是你自己写的业务代码。放在miniprogramRoot目录下的 js 文件,开发者工具默认会过一遍编译,import/export会被转成require/module.exports。这层最省心,基本不会出问题。
第二层是miniprogram_npm目录。这个目录是"构建 npm"的产物,工具会去node_modules里把包按入口文件复制过来,生成一份给小程序用的精简结构。问题就出在这里:复制过来的代码很多是库作者为浏览器或 Node 打包的产物,格式可能是 ESM,而这一层经常不在转译覆盖范围内,或者说转译得不彻底。你在 IDE 里看着一切正常,打包上传后真机一跑就炸。
第三层是真机上的基础库版本。开发者工具用的是最新版模拟环境,语法支持度很高;用户手机上的基础库可能是两年前的版本,对可选链、空值合并、类字段这些新语法一律不认。所以你会遇到"工具里没事、真机报错"的经典现象——这不是玄学,是两套运行环境的差异。
1.3 代码从 npm 到真机,中间到底发生了什么
把这条链路摊开看会更清楚。你在命令行敲下npm install some-esm-lib,文件进node_modules;开发者在工具里点"构建 npm",工具读取该包package.json的入口字段,把整个包目录(或者按配置裁剪过的子集)复制到miniprogram_npm/some-esm-lib/;你的业务代码require('some-esm-lib')实际指向的是这份复制品;最后打包上传,这份复制品被原样塞进代码包,送到真机。
关键就在"原样"两个字上。如果这个包的入口文件长这样:
// miniprogram_npm/some-esm-lib/index.js export { default as Chart } from './chart'; export { default as Theme } from './theme';那么从复制到上传的全过程,没有任何一个环节会去改写它。真机引擎读到第一行就抛出Unexpected token export。整个链路里唯一的补救机会,是在复制之前把产物换成 CommonJS 或 IIFE 格式。
2. 五分钟定位:报错文件到底是哪一个
2.1 读堆栈的三个关键信息
很多人看报错只看最后一行,其实前面几行才是重点。堆栈里的文件路径能直接告诉你问题出在哪一层:路径里带miniprogram_npm的,基本可以确定是第三方包产物格式问题;路径是你自己写的utils/或pages/下的,说明那个文件被排除在编译范围之外了,常见于配置了忽略规则、或者文件放在了miniprogramRoot之外;路径指向.esm.js、.mjs结尾的,那几乎百分百是产物格式选错了。
第二个要看的是报错行号。如果行号是 1,说明整个文件的第一条语句就是export,这是典型的 ESM 打包器输出;如果行号在几十上百行,那可能是文件里有局部的一段用了模块语法,比如某个条件分支里的动态导出。
第三个要看的是调用栈的深浅。如果栈很深,从你的页面一路调到库内部才报错,说明入口文件是好的,是某个被懒加载的子模块出问题;如果栈只有一两层,那就是入口本身有问题。这个区分很重要,因为前者你要改的是子模块的引用方式,后者要改的是整个包的接入方式。
2.2 命令行的两把筛子:grep 与 es-check
图形界面找起来慢,直接用命令行筛。在项目根目录执行:
# 找 miniprogram_npm 下所有以 export / import 开头的行 grep -rn "^\s*export\|^\s*import " miniprogram_npm --include="*.js" | head -50这条命令会把所有顶层模块语法揪出来,输出格式是文件路径加行号。如果输出为空,说明miniprogram_npm里是干净的,问题在别处;如果有输出,把对应文件路径记下来,那个包就是罪魁祸首。
第二把筛子更彻底,直接检查语法兼容性:
npx es-check es5 'miniprogram/**/*.js' --verbosees-check会按 ES5 标准逐文件解析,任何一个文件用了 ES5 之外的语法都会报出来,包括export、const、箭头函数、可选链。它比 grep 强的地方在于能覆盖到所有语法点,缺点是噪音大——如果你的项目本来就依赖编译期转译,业务代码里全是 ES6 语法,那输出会非常长。实际用的时候建议先只扫miniprogram_npm和libs两个目录:
npx es-check es5 'miniprogram_npm/**/*.js' 'libs/**/*.js'这两个目录是"不该有现代语法"的区域,扫出来的一律要处理。
2.3 开发者工具的三个开关,先拨一遍
在动代码之前,先把工具里的开关捋一遍,很多问题拨一下就好了,不用改任何代码。
第一个是将 JS 编译成 ES5,对应project.config.json里的setting.es6。这个开关打开后,工具会对项目目录下的 js 做语法降级。注意它的作用范围,从实际表现看,它对miniprogramRoot内的业务代码覆盖得比较全,对miniprogram_npm的覆盖就没那么彻底,所以不能指望它解决所有问题。
第二个是增强编译,对应setting.enhance。这个用的是新版编译器,语法支持范围比老编译器大得多,而且对 npm 包的处理明显更好。我在好几个项目里遇到export报错,打开增强编译之后直接就好了,一行代码没改。如果你还没开这个开关,先开它试一次,成本最低。
第三个是上传时压缩混淆,对应setting.minified。这个跟报错本身关系不大,但会影响排查——压缩后的代码行号全乱,堆栈基本没法看。定位问题阶段建议先关掉压缩,等修完再打开。
注意:改完开关一定要重新点一次"构建 npm"并且清缓存重新编译,光点编译不一定会让新的编译配置生效。这一步漏掉的话,你会以为开关没用。
3. 五种修法的适用边界与选型逻辑
3.1 修法一:让构建 npm 走对入口
有些库同时提供了多种格式的产物,比如dist/index.esm.js、dist/index.cjs.js、dist/index.umd.js,但它的package.json里main字段指向了 ESM 那一份。这种情况下,你不需要改任何代码,只需要让构建 npm 读到正确的入口。
做法是在你自己项目的package.json里配置手动构建:
{ "dependencies": { "some-esm-lib": "^1.2.0" }, "miniprogram": "dist" }配合project.config.json的packNpmManually和packNpmRelationList,可以精确指定从哪个package.json构建、产物输出到哪里:
{ "setting": { "packNpmManually": true, "packNpmRelationList": [ { "packageJsonPath": "./package.json", "miniprogramNpmDistDir": "./miniprogram/" } ] } }另外有个细节值得记住:部分库会专门声明一个miniprogram字段指向小程序专用产物,构建工具会优先读它。所以排查时打开那个包的package.json,把main、module、browser、miniprogram、exports这几个字段都看一遍,哪个指向 ESM,问题就出在哪个字段被选中了。
3.2 修法二:Babel 批量降级
如果库的产物只有 ESM 一份、又不想引入打包工具,用 Babel 批量转一遍是最快的。核心是@babel/preset-env的modules选项——默认值是auto,在不知道目标环境时会保留 ESM 语法,必须显式设成commonjs才会真正转换。
npm i -D @babel/core @babel/cli @babel/preset-env配置文件:
{ "presets": [ ["@babel/preset-env", { "modules": "commonjs", "targets": { "ios": "10", "android": "5" } }] ] }然后对着node_modules里的目标目录转:
npx babel node_modules/some-esm-lib/dist \ --out-dir miniprogram/libs/some-esm-lib \ --extensions ".js" \ --config-file ./babel.vendor.json转完把产物放到业务目录(比如miniprogram/libs/)而不是miniprogram_npm/,原因后面会讲。引用方式改成相对路径:
const lib = require('../../libs/some-esm-lib/index.js');这个方案的优点是可控、可复现,缺点是每次升级库都要重跑一遍。建议把命令写成 npm script,升级时一条命令搞定。另外要注意targets别设得太激进,ios: 10以下会引入大量 polyfill,包体积会明显变大。
3.3 修法三:Rollup 打成单文件
如果你的项目只用到某个库的一两个 API,Babel 全量转译会把整个包都搬进来,体积不划算。这时候用 Rollup 打单文件更合适,它只把真正被引用到的代码打进去,配合 tree-shaking 体积能小一大截。
配置的核心是输出格式。小程序支持 CommonJS,所以format: 'cjs'是最省事的;如果想彻底避免require的路径解析问题,format: 'iife'也行,它会打成一个自执行函数,挂一个全局变量出去。两种都能跑,我更常用cjs,因为调试时堆栈更清晰。
这个方案适合"只需要库的少数功能"和"库体积大但用到的部分小"这两种情况。反过来,如果你要用库的大部分能力,比如一整套图表组件,那 tree-shaking 收益有限,不如老老实实全量转译。
3.4 修法四:换库或换版本
听起来像废话,但这是实际排查中最常用的一招。很多库在某个大版本之后全面转向 ESM,之前的版本还是 CommonJS。如果你不是非用新特性不可,锁一个旧版本就能绕开所有构建麻烦。
排查方法是去翻那个包的更新日志,看它从哪个版本开始改产物格式,然后package.json里锁死版本号,去掉^前缀。同时把package-lock.json提交进仓库,避免团队成员各自装出不同版本——这类问题最典型的症状就是"我这能跑他那不能跑",本质是两个人装到的版本不一样。
还有一种情况是换一个功能类似、但对小程序更友好的库。判断标准很简单:看它的package.json里有没有miniprogram字段,有的话基本可以直接用;再看看社区里有没有针对小程序环境的构建产物。这一步花的时间往往比折腾构建配置少得多。
3.5 修法五:自写适配层与懒加载桥接
最后一种是纯手写方案,适合库本身很小、只有几个函数的情况。直接把它需要的部分抄成 CommonJS 放进项目里,或者写一层薄薄的适配:
// miniprogram/libs/adapter.js let inner = null; function getLib() { if (!inner) { inner = require('./some-esm-lib/index.js'); } return inner; } module.exports = { format: (...args) => getLib().format(...args), };这种写法的额外好处是延迟加载——库只在真正调用时才被 require,首屏启动不被拖慢。缺点是可维护性差,库升级要手工同步。我一般只用在自己写的、几十行的工具库上,第三方库不太推荐这么干。
3.6 五种修法怎么选
| 修法 | 适用场景 | 改动成本 | 后续维护 | 体积影响 |
|---|---|---|---|---|
| 走对构建入口 | 库提供多格式产物 | 低,改配置 | 低 | 无 |
| Babel 批量降级 | 只出 ESM,且需要完整功能 | 中 | 中,升级要重跑 | 略增 |
| Rollup 打单文件 | 只用少数 API,库体积大 | 中 | 中 | 明显减小 |
| 换库或锁版本 | 有可用替代方案 | 低 | 低 | 视库而定 |
| 自写适配层 | 库很小,只有几个函数 | 高 | 高 | 最小 |
选型逻辑其实就一条:优先在配置层解决,其次在构建层解决,最后才动代码。配置能解决就不用装工具,工具能解决就不用自己写代码,因为每往下走一层,维护成本就翻一倍。
4. 完整实操:把只出 ESM 的库塞进小程序
4.1 最小复现
先造一个能稳定复现问题的场景,这样修完之后可以验证。假设有一个虚构的图表工具库some-esm-lib,它的package.json是这样:
{ "name": "some-esm-lib", "version": "1.0.0", "main": "dist/index.esm.js", "module": "dist/index.esm.js", "type": "module" }三个字段全指向 ESM,还声明了"type": "module"。装进项目、构建 npm、业务代码里require('some-esm-lib'),真机预览立刻报Unexpected token export。这就是标准的复现场景。
我习惯先确认一遍产物内容,用head看前几行:
head -5 miniprogram_npm/some-esm-lib/index.js如果输出里有export关键字,问题就坐实了。接下来按上文的选型逻辑,配置层解决不了(没有其他产物),就走 Rollup。
4.2 Rollup 配置逐行讲
装依赖:
npm i -D rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-terser建一个入口文件,只把用到的 API 挑出来:
// build/vendor-entry.js export { createChart, defaultTheme } from 'some-esm-lib';配置文件:
// rollup.config.mjs import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import terser from '@rollup/plugin-terser'; export default { input: 'build/vendor-entry.js', output: { file: 'miniprogram/libs/vendor-chart.js', format: 'cjs', exports: 'named', sourcemap: false, }, plugins: [ resolve({ browser: true }), commonjs(), terser({ compress: { drop_console: true } }), ], treeshake: { moduleSideEffects: false }, };几个参数得说明白。resolve({ browser: true })是为了让解析器优先选浏览器方向的产物,避免打到 Node 专用的分支。commonjs()负责把依赖里混着的 CommonJS 模块也一起处理掉,不加这个插件遇到混合格式的库会直接报错。treeshake.moduleSideEffects: false是让 tree-shaking 更激进,代价是如果某个模块有副作用(比如自动注册样式),可能会被误删——如果打完发现功能异常,先把这个选项去掉。
exports: 'named'这个参数容易被忽略。它决定了输出文件的导出形式:设成named输出exports.createChart = ...,设成default输出module.exports = ...。如果你在业务代码里用require('...').createChart取值,就必须用named,否则拿到的是undefined。
执行打包:
npx rollup -c rollup.config.mjs产物生成在miniprogram/libs/vendor-chart.js,用head看一眼,应该是'use strict'; Object.defineProperty(exports, '__esModule', ...)之类的东西,没有export关键字就对了。
4.3 落地位置与引用方式
产物为什么放在miniprogram/libs/而不是miniprogram_npm/?因为miniprogram_npm是构建产物目录,每次点"构建 npm"都会被清空重建,你手工放进去的东西会被覆盖掉。这个坑我踩过不止一次:改了miniprogram_npm里的文件,调试通过,第二天同事重新构建一次 npm,问题又回来了,而且还找不到原因。
正确做法是把打包产物放在业务目录下的独立文件夹,比如miniprogram/libs/。这个目录属于miniprogramRoot范围,会被正常编译,也不会被构建 npm 覆盖。引用时用相对路径:
const { createChart, defaultTheme } = require('../../libs/vendor-chart.js');路径深度按你页面的位置算。这里有个小技巧:把这类第三方产物统一收在一个libs目录下,再写一个libs/index.js做统一出口,业务代码只 require 这一个文件:
// libs/index.js module.exports = { chart: require('./vendor-chart.js'), dateUtil: require('./vendor-date.js'), };这样以后换实现只需要改一处,业务代码不用动。
4.4 project.config.json 关键项
配置里跟这个问题直接相关的有几个,逐条过一遍:
{ "setting": { "es6": true, "enhance": true, "minified": false, "packNpmManually": true, "packNpmRelationList": [ { "packageJsonPath": "./package.json", "miniprogramNpmDistDir": "./miniprogram/" } ] }, "packOptions": { "ignore": [] } }es6: true对应"将 JS 编译成 ES5",业务代码的语法降级靠它。enhance: true是增强编译,覆盖范围更大,建议一直开着。minified: false在排查阶段先关掉,方便看堆栈,上线前再打开。packNpmManually加packNpmRelationList是手动指定构建源,适合项目结构比较复杂、package.json不在根目录的情况。
packOptions.ignore要特别注意。如果你在里面配了忽略规则,把某些目录排除掉了,那些目录下的文件就不会参与编译,写 ESM 语法一样会报错。排查时先把这个数组清空试一次。
4.5 验证与体积体检
打完包别急着提交,先跑一遍兼容检查和体积检查:
# 语法兼容检查 npx es-check es5 'miniprogram/libs/**/*.js' # 体积检查 ls -lh miniprogram/libs/体积这块有个经验值:单个第三方产物超过 200KB 就该考虑按需引入了。图表类、富文本类库很容易打到 500KB 以上,这时候要么拆成多个入口按页面加载,要么放到分包里,别让它进主包拖累首次启动。
验证真机效果有个小技巧:在开发者工具里点"真机调试",用低版本基础库的设备验证一遍。工具右上角可以切换调试基础库版本,切到比较老的版本再跑一次,能提前发现新语法兼容问题。这一步花两分钟,能省掉上线后被用户投诉的一天。
5. 常见问题与踩坑实录
5.1 高频问题速查表
| 现象 | 大概率原因 | 处理动作 |
|---|---|---|
| 首次引入某库就报 export | 库只提供 ESM 产物 | Rollup 或 Babel 转成 CJS |
| 构建 npm 后突然报错 | 库升级换了产物格式 | 锁版本,去掉^ |
| 开发者工具正常,真机报错 | 工具开了增强编译,真机基础库旧 | 统一产物格式,别依赖工具兜底 |
报错指向dist/index.esm.js | package.json 的 main 指向 ESM | 手动指定构建入口 |
| 报错在自己写的 util 里 | 文件在编译范围外或被忽略 | 检查miniprogramRoot与packOptions.ignore |
| 改了 miniprogram_npm 就好,重构建又坏 | 改的是构建产物 | 把产物挪到libs/目录 |
报错是Unexpected token '.'不是 export | 可选链等新语法在老基础库不支持 | 降级语法或提高最低基础库版本 |
| 只有分包页面报错 | 分包文件没走主包编译配置 | 检查分包目录是否在编译范围内 |
5.2 三个隐蔽边界:动态 import、import.meta、export *
除了显式的export,还有三种写法在小程序里同样会触发语法错误,而且更难排查,因为它们藏得深。
第一种是动态导入import('./module')。这种写法返回 Promise,浏览器里很常见,但小程序运行时不支持这个语法,编译期如果没处理掉,运行时就报Unexpected token import。判断方法是全局搜import(,找到之后改成require加延迟执行的写法。
第二种是import.meta。模块打包器用它来拿当前模块的 URL、环境变量之类的东西,浏览器 ESM 里合法,小程序里不认。搜import.meta能找到,一般出现在库的构建辅助代码里。处理办法是在打包配置里把它替换成常量,Rollup 可以用@rollup/plugin-replace把它替换成{}。
第三种是export * from './xxx'。这种再导出语法在 Babel 转commonjs时能处理,但如果那个文件压根没进转译流程,一样会报错。它的麻烦之处在于报错可能出现在一个你完全没引用过的文件上——因为export *会把整条依赖链拉进来。排查时顺着报错文件往上找引用关系,一层层追到入口。
5.3 构建产物被覆盖与版本漂移
前面提过一次,但还是值得单独说,因为这是最高频的"修好了又坏"原因。miniprogram_npm整个目录是构建产物,任何手工修改都活不过下一次"构建 npm"。所以修复动作必须落在两个地方之一:要么改依赖的引入方式(放进libs/),要么改构建配置(让工具生成正确的产物)。绝对不能改miniprogram_npm里的文件。
版本漂移是另一个隐形杀手。团队里有人装的是 1.2.0,有人装的是 1.3.0,新版改成 ESM 了,于是"我这能跑他那不能跑"。解决方法很朴素:package.json里锁精确版本,package-lock.json提交进仓库,CI 里用npm ci而不是npm install。这三件事做完,版本问题基本绝迹。
还有一种更隐蔽的情况:库本身没变,但它的某个间接依赖换了产物格式。这种情况你在package.json里看不出来,得看package-lock.json里被提升到顶层的间接依赖版本。排查时可以先删掉node_modules和package-lock.json重新装一遍——如果问题消失,那就是间接依赖的问题,把package-lock.json恢复回去对比一下就能定位。
5.4 几条实操心得
关于开关的组合,我的经验是:增强编译打开、ES6 转 ES5 打开、压缩先关掉。这个组合下,大部分第三方库能直接跑起来,跑不起来的再走打包方案。别一上来就上 Rollup,配置成本不低,而且每次库升级都要重新打一遍,长期看是负担。
关于包体积,转译和打包之后一定要看一眼产物大小。有些库在 ESM 格式下做了大量 tree-shaking 优化,一旦转成 CommonJS,原本能被摇掉的代码全留下了,体积可能翻两三倍。我遇到过一个库转译前 40KB、转译后 180KB,最后换成按需引入才把体积压回去。所以转译完之后的那次体积体检不能省。
关于真机验证,建议准备一台低版本基础库的设备或者用工具切版本测。工具里跑得再顺,真机上出问题的概率也不低,尤其是在用了一些新语法的时候。把这一步放进发布前检查清单,比出了问题再回滚划算得多。
最后分享一个小技巧:在app.js里加一段启动自检,把关键第三方库的引用包在 try-catch 里,出问题时打一行明确的日志出来。这样即使报错信息不友好,你也能立刻知道是哪个库挂的,不用再靠二分法一个个注释。这段自检代码上线时可以保留,代价几乎为零,收益是排查时间从半小时缩短到十秒。