Taro 开发小程序这件事,最容易被低估的成本不是写页面,而是"改一行代码,等它重新加载"这段看似不起眼的时间。我前后跟过几个 Taro 项目,从早期的编译时框架一路做到运行时框架,最深的一个体会是:重新加载这条链路上任何一环出问题,都不会给你明确的报错,只会给你一个白屏、一个旧页面,或者一个"改了但没完全改"的样式。这篇就围绕"Taro 重新加载小程序"这个具体场景,把链路拆开、把坑摆出来,把排查思路讲透。
先明确一下读者画像:你最好已经用 Taro 跑通过至少一个能上线的项目,知道dist目录在哪、知道开发者工具加载的是哪个路径;如果你是刚接触 Taro 的新手,前两节也能看懂,因为我会尽量把编译产物和运行时行为对应起来讲。全文的重点不在 API 手册,而在于"为什么明明改了代码,小程序却像没听见一样"这类只有真跑过项目才会遇到的问题。
1. Taro 的"重新加载"其实横跨了四层,很多人只盯着最上面一层
大部分人说的"重新加载小程序",指的其实是开发者工具右上角那个编译按钮。但只要你在 Taro 项目里待够久就会发现,这个按钮能解决的只是一小部分问题。真正决定"改完代码能不能看到效果"的,是四层机制叠在一起的结果,任何一层断了,表现都是同一个:没变化。
1.1 从源码到 dist:Taro CLI 到底做了什么
Taro 的构建本质是一次"跨 DSL 翻译"。你写的是 JSX 或 Vue 单文件组件,配上 TS 和 SCSS,最终要落到小程序能认的四种文件:.wxml、.wxss、.js、.json。这个过程不是简单的语法转换,而是把一套组件化、响应式的写法,拆解成小程序那套"模板 + 数据 + 事件"的结构。
具体拆开看,一次构建大致经过这些步骤:
- 转译层:Babel 或 SWC 把 TS、JSX、可选链这些语法降级到小程序基础库能跑的版本。
- DSL 转换层:把 React 组件树的结构信息提取成
.wxml模板,把onClick这类事件绑定映射成小程序的bindtap。 - 样式处理层:SCSS 编译、
postcss-pxtransform做 px 到 rpx 的换算、样式作用域处理。 - 配置生成层:把
app.config.ts里的pages、subPackages、tabBar编译成app.json。 - 运行时注入:Taro 3 之后产物里会带一份运行时,负责把 React 的更新映射成
setData调用。
关键点在于:这几层里,只有前四层是"文件级"的产物变化,第五层是"内存级"的行为。所以你会遇到两种情况——文件确实更新了但页面没变(运行时没重新初始化),或者运行时重新跑了但文件是旧的(构建没跟上)。这两种情况的表现几乎一模一样,但排查方向完全相反。
我一般的判断方法是看dist目录里对应文件的修改时间。命令行里执行一条ls -l --time-style=full-iso dist/app.js,再对比你刚才保存源码的时间。如果 dist 的时间没动,问题在构建层;如果 dist 动了但页面没变,问题在开发者工具或运行时层。这一步看起来笨,但它能一刀把问题切成两半,比漫无目的地重启要高效得多。
1.2 开发者工具监听的到底是什么
很多人有个误解,以为 Taro 的 watch 和小程序的自动编译是同一件事。不是的。它们是两条独立的链路:
| 链路 | 触发条件 | 负责方 | 失败表现 |
|---|---|---|---|
| 构建链路 | 源码文件变化 | Taro CLI(webpack/vite watch) | dist 不更新,日志可能还在滚 |
| 加载链路 | dist 文件变化 | 开发者工具文件监听 | dist 更新了但模拟器不动 |
| 运行时链路 | 代码包重新载入 | 小程序基础库 | 页面重载但状态丢失、白屏 |
| 手动链路 | 点击编译/清缓存重编 | 开发者 | 前面三层都失效时的兜底 |
开发者工具监听的是project.config.json里miniprogramRoot指向的那个目录。这个配置一旦和 Taro 的outputRoot对不上,就会出现"Taro 一直在编译,工具一直在读另一个文件夹"的诡异局面。我见过最典型的一次是有人把outputRoot改成dist/${process.env.TARO_ENV},但project.config.json里还写着dist/,结果工具读的是一个空目录,界面上什么都不报,就是一直白屏。
所以每次接手一个新项目,我的第一个动作永远是把这两个值对一遍:
// project.config.json { "miniprogramRoot": "dist/", "setting": { "urlCheck": false, "es6": false, "minified": false } }// config/index.js module.exports = { outputRoot: 'dist', // ... }另外注意setting.es6和setting.minified这两个开关。Taro 产出的代码已经是转译过的,如果开发者工具再做一次 ES6 转 ES5,某些情况下会二次处理导致运行时报错或者行为异常。我在几个项目里都关掉了这两个选项,稳定性明显更好。
1.3 你以为的"热更新"和小程序的能力边界
H5 那边习惯了 HMR,改个组件状态能保住、页面不刷新。小程序这边做不到同等体验,原因很直接:小程序的运行环境是宿主提供的,代码包更新必须走"重新载入"这条路。Taro 3 提供的所谓热更新,本质上是重新执行模块、重新挂载组件,而不是浏览器里那种精细的模块替换。
这就带来一系列副作用,最典型的是useEffect重复执行。你在开发环境里改一次代码,可能会看到接口被调用了两次、定时器被创建了两个、事件监听被绑了两遍。这不是你代码写错了,是热更新重跑导致的。我一般的处理方式是在开发环境加一个显式的保护:
useEffect(() => { let cancelled = false fetchData().then(res => { if (cancelled) return setData(res) }) return () => { cancelled = true } }, [])这个cancelled标志不只是防 React 的警告,在热更新场景下它能实打实地避免"旧组件的异步回调覆盖新组件状态"这类疑难杂症。别小看这一行,我曾经因为一个异步回调没做取消,排查了整整半天,最后发现是热更新把两个组件实例同时挂在了一个页面上。
2. watch 模式假装在工作:几类典型的假死场景
taro build --type weapp --watch是日常开发的主命令。它大部分时候很稳,但一旦假死,最难的地方在于日志还在滚,你会误以为它在正常工作。这一节把几个我踩过的假死场景和定位方法摆出来。
2.1 日志在滚但 dist 没变:文件监听上限
Linux 和 macOS 都有文件监听句柄的上限。项目一大、node_modules一深,webpack 的 watch 很容易撞到这个天花板,表现是部分文件变更被漏掉——不是全都不生效,而是"改了 A 文件生效,改了 B 文件不生效",非常迷惑。
排查很简单,看系统当前的监听上限:
# Linux cat /proc/sys/fs/inotify/max_user_watches # macOS 一般不用改,但可以用下面这条看看打开的文件数 ulimit -n如果这个值是个几万的小数字,而你项目里有几十万文件,那就基本可以确定是它了。这时候有两个方向:一是把node_modules里不需要监听的部分排除掉,二是在配置里显式忽略一些目录:
// config/index.js module.exports = { mini: { webpackChain(chain) { chain.watchOptions({ ignored: /node_modules\/(?!@tarojs)/, aggregateTimeout: 300 }) } } }aggregateTimeout这个参数值得说一下。默认值往往偏小,文件一多就会触发频繁重建,反而拖慢整体速度。调到 300 毫秒左右,让变更先攒一攒再统一构建,实际体验会顺畅很多。这个值不是越大越好,超过 500 毫秒你会开始感觉到明显的延迟感。
2.2 改了配置本身却指望热更新
有一条规律必须刻在脑子里:config/index.js、project.config.json、package.json这三个文件的改动,watch 是不会自动生效的,必须重启构建进程。
原因在于这三者是在构建器启动时读取的,读完之后就固化在内存里了。你改了designWidth、改了alias、加了新的插件,进程完全不知道。我见过不少人在那儿反复保存、反复点编译,就是不看终端——终端里其实已经打了警告,只是被其他日志刷过去了。
相对地,app.config.ts里的改动是另一个情况:它能被构建,但新增页面必须让开发者工具重新编译一次才能识别路由。因为app.json虽然在 dist 里更新了,但开发者工具对页面路径的解析是在编译阶段完成的,它不会因为你 dist 里多了个文件就自动注册新路由。
我的习惯是:每次在app.config.ts里加完新页面,立刻去开发者工具点一次编译,然后再跳转测试。多花三秒钟,省掉半小时的"为什么 navigateTo 失败"。
2.3 真机预览拿到的是旧包
这条坑的隐蔽性最高。开发阶段你连着 watch,模拟器上一切正常;扫码真机预览,看到的却是上一次的代码。原因在于真机预览加载的是上传时的构建产物,跟你本地 watch 生成的东西没直接关系。
具体来说,真机预览有两种路径:一种是通过工具的预览二维码,它会把当前miniprogramRoot下的产物打包上传;另一种是通过 CI 上传体验版,那更是完全独立的构建。如果你在 watch 模式下改了大量代码,但预览二维码是十分钟前生成的,真机上跑的就是十分钟前的代码。
我在项目里固定了一个流程:真机验证前,先停 watch,跑一次干净的生产构建,再生成预览码。命令大概是:
# 清理旧产物 rm -rf dist # 生产构建 NODE_ENV=production taro build --type weapp # 然后再去工具里点预览rm -rf dist这一步看着粗暴,但它能解决掉一大类"产物残留"问题——旧页面文件还在、旧分包还在、旧的 sourcemap 还在,混在一起就会出现"删掉的页面还能跳过去"这种灵异现象。
2.4 定位假死的通用三步法
不管遇到哪种"改了没反应",我现在都按固定三步走,基本不会走偏:
- 看 dist 时间戳:确认构建层有没有产出。没产出就是构建问题,往 watch 配置、文件权限、磁盘空间方向查。
- 看工具编译日志:开发者工具的调试器里有一个编译面板,那里能看到它到底读到了哪些文件、有没有报错。很多解析错误只在这里出现,终端里看不到。
- 手动清缓存重编:工具菜单里的"清缓存 → 全部清除"之后再编译一次。这一步能排掉 90% 的路径错乱和模块缓存问题。
提示:清缓存会清掉小程序的本地存储和登录态。如果你的调试依赖某些本地缓存数据,清之前先想一下怎么恢复,别清完了发现要重新走一遍登录流程。
3. 重新加载之后白屏:从现象倒推的五条检查线
白屏是重新加载场景里最让人抓狂的一类,因为控制台干干净净,什么错都没有。这种情况往往不是"代码有 bug",而是"代码根本没跑起来"或者"跑起来但渲染失败"。我整理了一套从外到内的检查顺序,按这个走能省不少时间。
3.1 先确认入口有没有执行
白屏且无报错,第一个怀疑对象永远是app.ts或app.jsx的入口逻辑。Taro 的入口文件里通常放着全局初始化:登录态检查、全局状态注入、日志上报初始化。这里任何一行抛异常,整个应用都不会往下走,而某些基础库版本对入口异常的提示非常弱,你看到的就只是一个空白页。
我的做法是在入口最顶上打一行日志:
// app.ts console.log('[app] entry start', Date.now()) class App extends Component { componentDidMount() { console.log('[app] mounted') } }如果entry start都没打出来,那问题就在更外层——文件根本没被加载。常见原因包括:app.json里的pages第一项路径写错、分包配置把主包撑爆了导致主包没下载全、或者project.config.json的appid和当前账号不匹配。
如果entry start打了但mounted没打,说明组件初始化过程卡住了,往constructor里的同步逻辑查,尤其是那些"读本地存储再决定跳转"的代码。这里有个很隐蔽的点:某些基础库版本下,在constructor里调Taro.setStorageSync会有时序问题,建议全部挪到componentDidMount里。
3.2 页面栈与 onLoad 的错位
有一类"看起来正常但就是不刷新"的问题,根源在页面栈而不是渲染。最典型的是 tabBar 页面——从 tab A 切到 tab B 再切回 tab A,onLoad是不会重新触发的,只有onShow会。如果你的数据拉取逻辑全写在onLoad里,那切回来看到的就是旧数据,用户会以为页面卡了。
这在小程序里是官方行为,不是 bug。所以约定很关键:onLoad只放一次性的初始化(比如解析路由参数),数据刷新一律放onShow。我在团队里推过这条约定,效果立竿见影,因为它是那种"知道就没事、不知道就天天踩"的规则。
还有一类是跳转层级的问题。navigateTo的页面栈上限是十层,超了会静默失败(部分版本会给个警告)。重新加载后如果用户是通过深层链接进来的,页面栈的初始状态和你预期的不一样,navigateBack的行为也会跟着变。涉及深层路径的项目,我建议在入口统一做一次路由规整:
// 统一处理:非预期入口一律 reLaunch 到首页 const allowedEntry = ['pages/index/index', 'pages/detail/index'] const current = getCurrentPages() if (current.length === 0 || !allowedEntry.includes(current[0].route)) { Taro.reLaunch({ url: '/pages/index/index' }) }3.3 分包路径的两级根目录
分包是白屏的高发区,因为路径解析有两层:subPackages的root是一层,页面路径本身又是一层。写错任何一层,表现都是"页面找不到",而工具给的提示往往只说明第一层,容易误导。
一个正确的小程序分包配置长这样:
// app.config.ts export default { pages: ['pages/index/index'], subPackages: [ { root: 'packageA', pages: ['pages/user/index', 'pages/order/index'] } ] }对应到磁盘上的真实路径是src/packageA/pages/user/index.tsx。第一次写容易写成packageA/user/index,少一层pages,构建能过,运行时就是找不到。我一般会写个小脚本,扫一遍目录结构和配置做比对,构建前跑一次,能挡掉这类低级错误。
3.4 运行时异常被 try/catch 吞掉
这条比较阴。有些项目为了"稳定",会在关键渲染路径上套一层 try/catch,捕获后只打一条 warn 就返回 null。结果就是白屏,但控制台看不到红色错误,只有一行不起眼的黄色警告,滚动日志的时候根本注意不到。
我的建议是:开发环境下不要吞异常,让它炸出来。真要兜底,也在process.env.NODE_ENV === 'production'下再兜。开发阶段需要的是暴露问题,不是掩盖问题。
if (process.env.NODE_ENV === 'production') { // 生产环境兜底 try { renderApp() } catch (e) { reportError(e) } } else { renderApp() }3.5 一条完整的排查链路示例
把上面几条串起来,我给一个最近真实处理过的排查过程,你能看到思路是怎么一层层收窄的:
- 现象:新增一个分包页面,跳转后白屏,控制台无报错。
- 第一步,看 dist:
dist/packageA/pages/user/index.js是否存在?存在。 - 第二步,看 app.json:
dist/app.json的subPackages里有没有这个页面?没有。问题锁定在配置生成层。 - 第三步,看源码配置:
app.config.ts里写了pages: ['user/index'],但root是packageA,实际路径应该是packageA/pages/user/index。 - 第四步,修正:改成
pages: ['pages/user/index'],重新构建。 - 第五步,验证:dist 里的 app.json 正确了,但工具里还是白屏。
- 第六步,再往上查:手动清一次缓存重编,正常。
整个过程最有价值的是第 3 步和第 6 步:配置生成层的问题看 dist 就能确认,而工具侧的文件解析缓存需要手动清。这两步合起来,覆盖了我遇到的分包白屏的绝大多数情况。
4. 样式"假生效":改完看着像变了,其实没变
样式问题比白屏更让人迷惑,因为它经常呈现一种半生效状态:部分样式变了,部分没变。这种情况往往不是缓存问题,而是 Taro 的样式处理链路里有某个环节的规则没吃透。
4.1 px 转 rpx 的换算与 designWidth 的约定
Taro 默认把源码里的px按designWidth换算成rpx。这个机制对新人极其不友好,因为它意味着你写font-size: 14px,最终在真机上不是 14 物理像素,而是按比例换算后的值。
换算逻辑大致是:rpx = px * 750 / designWidth。如果你的designWidth是 750,写 14px 出来就是 14rpx;如果designWidth是 375,写 14px 出来就是 28rpx。所以同一个设计稿,designWidth配置不同,最终视觉差一倍。
我踩过的坑是这样的:项目从别的框架迁过来,设计稿基于 375,但 Taro 配置里designWidth还是默认的 750。结果所有尺寸都只有预期的一半,页面看起来"缩水"了。当时第一反应是样式没生效,查了半天才发现是换算基准不对。
// config/index.js module.exports = { designWidth: 375, deviceRatio: { 375: 2 / 1, 640: 2.34 / 2, 750: 1, 828: 1.81 / 2 } }注意:
designWidth改动后必须重启构建进程,watch 不会感知到。而且改完之后一定要真机看一遍,模拟器和真机的换算在某些机型上有细微差异。
4.2 全局样式与组件样式的边界
小程序有样式隔离机制,组件的样式默认不会影响到外部,外部样式也不会自动"穿透"进组件。Taro 把这个机制抽象成了配置项,但很多人不配置,结果就是"组件里写了样式,父页面怎么都不生效"。
这里有个常见误解:以为import './index.scss'写在组件文件里,样式就是局部的。实际上在小程序侧,样式的作用域取决于styleIsolation的配置,而不是引入位置。我的经验做法是:
- 全局变量、重置样式、通用工具类:只在
app.scss里引入一次。 - 页面级样式:写在页面对应的 scss 里,通过 BEM 命名做隔离。
- 组件样式:显式配置
styleIsolation: 'isolated',避免被外部污染。
// 组件配置 export default { options: { styleIsolation: 'isolated' } }配了isolated之后,组件自己的样式就彻底闭环了,父级的 class 再也不会莫名其妙影响进来。代价是你不能用父级传 class 的方式改组件样式,需要靠externalClasses显式开放。这个取舍我倾向于"先隔离",因为样式污染排查起来的成本远高于多写几行配置。
4.3 缓存导致的"改了样式不生效"
样式文件在开发阶段特别容易被缓存,原因有两个:一是开发者工具本身对 wxss 有编译缓存,二是 Taro 的构建产物如果没做 hash,工具可能认为文件没变。
我遇到最典型的一次是:改了app.scss里的一个全局颜色变量,模拟器毫无反应;重启工具之后才生效。后来发现是 scss 的@import链路上的中间文件没被正确标记为变更。
排查这类问题的思路是看最终产物:
# 看 dist 里对应 wxss 文件的内容 grep -n "你的颜色值" dist/app.wxss如果产物里是新值,那就是工具的缓存;如果产物里还是旧值,那就是构建没跟上。两种情况处理方式完全不同,所以先看产物这一步不能省。
顺带提一句,开发阶段尽量别用太复杂的 scss 变量嵌套和@mixin递归,构建链路上任何一环出问题都会放大成"样式不生效",而这类问题定位起来比写业务代码累得多。
4.4 动态 class 与条件样式的写法陷阱
小程序的模板里,class 是字符串拼接,没有 React 那套对象语法。Taro 会帮你转,但转换有边界。比如这种写法在部分版本里会出现类名没拼上的情况:
<View className={`box ${active ? 'box--active' : ''}`}>更稳的写法是用数组 join,或者用一个小工具函数:
const cls = (...args) => args.filter(Boolean).join(' ') <View className={cls('box', active && 'box--active')}>这个细节看着无关紧要,但在热更新场景下它会放大——因为热更新可能让状态变化和样式应用出现先后差异,拼接出来的 class 少一个空格或者多一个false,样式就整块失效。我在项目里统一封装了这个函数之后,这类问题基本清零了。
5. 状态与缓存:重新加载之后数据为什么会"回到过去"
重新加载不光是代码重新跑,它还意味着内存状态全部重置。这对小程序来说影响特别大,因为小程序没有浏览器的sessionStorage那种方便的东西,一切持久化都得走本地存储。
5.1 本地存储的同步与异步选择
Taro 提供了两套存储 API:同步和异步。同步的用起来方便,但它会阻塞主线程。我见过有人在页面onShow里循环读十几个getStorageSync,结果从后台切回前台的时候明显卡顿。
我的取舍原则是:单个小字段用同步,批量数据用异步。
// 单个标志位,同步够用 const token = Taro.getStorageSync('token') // 批量初始化,用异步并行 const [userInfo, settings, config] = await Promise.all([ Taro.getStorage({ key: 'userInfo' }), Taro.getStorage({ key: 'settings' }), Taro.getStorage({ key: 'config' }) ])还有一点容易被忽略:本地存储的总容量是有限制的,单个 key 也有大小限制。往里面塞大的 JSON 迟早出问题,遇到"存进去了但读出来是空"这种诡异现象,先想想是不是超限了。
5.2 重新加载后的状态恢复策略
如果你的页面依赖用户填了一半的表单,重新加载之后全丢了,体验会非常糟。我的做法是给关键表单加一层草稿保护:
- 输入变化时防抖写本地存储(一般 500 毫秒够用)。
- 页面
onLoad时读出草稿并回填。 - 提交成功后清掉草稿。
const saveDraft = useMemo( () => debounce((data) => Taro.setStorageSync('draft:form', data), 500), [] )这个策略在支付、下单这类流程里价值特别高,因为小程序被系统回收、被用户误关的概率不低。加这么一层,用户回来还能接着填,比什么都强。
但要注意另一个方向的问题:草稿太"粘"也会出问题。比如用户上次填了一半放弃了,下次进来又自动回填,会让人困惑。我一般会加一个过期时间,超过 24 小时就丢弃。
5.3 全局状态在热更新中的表现
用 Redux、MobX 这类方案管理全局状态的项目,在热更新时经常出现状态不一致——一部分模块重新实例化了,另一部分还持有旧的引用。表现是页面上某些数据显示正常,某些数据突然变成初始值。
根本原因是模块级的作用域在热更新时被重新执行了。你的 store 实例在重新执行中被换成了新的,但某些组件还握着旧的引用。
规避方法是在开发模式下把 store 挂到全局对象上,让它在热更新时复用:
let store = createStore(reducers) if (process.env.NODE_ENV === 'development') { const g = globalThis || {} if (g.__taro_store__) { store = g.__taro_store__ } else { g.__taro_store__ = store } }这个写法在开发阶段能显著降低"热更新后状态乱跳"的干扰。上线前记得把这段去掉,或者用环境判断包起来,别把调试用的全局挂载带到线上。
5.4 环境变量与多环境切换
多环境(开发、测试、预发、生产)的项目里,环境切换是最容易在重新加载时出问题的环节。Taro 支持defineConstants注入编译期常量,这个机制很好用,但有个前提:常量是在构建期确定的,改完必须重新构建。
// config/index.js module.exports = { defineConstants: { API_BASE: JSON.stringify(process.env.API_BASE || 'https://dev.example.invalid') } }我见过有人指望在运行时改环境变量切换接口地址,这是行不通的,因为值已经被编译进代码里了。要在运行时切换,只能自己维护一个环境配置文件,从本地存储里读当前环境,再据此选 baseURL:
const ENV_MAP = { dev: 'https://dev.example.invalid', test: 'https://test.example.invalid', prod: 'https://api.example.invalid' } const env = Taro.getStorageSync('env') || 'dev' export const baseURL = ENV_MAP[env]这种方案配合一个隐藏的调试入口,测试阶段非常好用——不用重新发版就能切环境验证。
6. 从 2 到 3 到 4:版本升级期的重新加载阵痛
Taro 从 2 到 3 是一次架构级的跃迁,从"编译时框架"变成了"运行时框架"。这个变化对重新加载的影响非常直接,如果你手头有老项目要升,下面这几条经验能省不少事。
6.1 编译时和运行时的本质差异
Taro 2 的思路是把 React 的写法在编译期翻译成小程序的模板和逻辑,产物里几乎没有框架运行时。好处是包体积小、启动快;坏处是表达能力受限于翻译规则,动态性差。
Taro 3 换了思路,产物里带一份运行时,把 React 或 Vue 的渲染结果通过setData同步到视图层。好处是生态兼容性大幅提升,各种第三方库能用;坏处是包体积增大、setData的数据量需要控制。
这个差异在热更新场景下的表现是:Taro 2 改完代码基本等同于"重新跑一遍翻译",Taro 3 改完代码需要"重新初始化运行时"。所以 Taro 3 项目里的热更新更容易出现状态错乱、重复挂载这类问题。
6.2 生命周期语义的变化
升级过程中最容易踩的是生命周期。Taro 2 里componentWillMount之类的老生命周期用得很多,Taro 3 对应到 React 的函数组件之后,语义不完全一样。特别是onShow和useEffect的组合,容易出现"进页面调两次接口"的问题。
我的处理方式是统一封装一个页面级 hook:
function usePageVisible(callback) { useDidShow(() => { callback('show') }) useDidHide(() => { callback('hide') }) }所有依赖"页面可见性"的逻辑都走这个 hook,而不是散在useEffect里。这样在热更新和真机切换的时候,行为是一致的、可预期的。
6.3 编译器换代之后的构建速度变化
再往上升,编译器从 JS 生态换成了 Rust 实现,构建速度提升明显,但初次配置需要重新适配一些自定义插件。我遇到过的问题主要是自定义的webpackChain配置在新编译器下不生效,因为配置结构变了。
处理思路是:升级后先把所有自定义配置注释掉,跑到能正常构建,再一条一条加回来,每加一条验证一次。别指望一次性全量迁移,那样出问题根本不知道是哪一条引起的。
6.4 一个实用的版本升级检查清单
| 检查项 | 说明 | 风险等级 |
|---|---|---|
| 生命周期映射 | componentWillMount等老 API 的替代方案 | 高 |
| 自定义 webpack 配置 | 新编译器下配置结构可能不兼容 | 高 |
| 第三方组件的样式引入 | 部分库依赖 Taro 2 的样式处理方式 | 中 |
| 分包异步化配置 | 新版本对分包策略有调整 | 中 |
| 构建产物体积 | 运行时引入后包体积上升,注意主包 2M 限制 | 高 |
| 环境变量注入方式 | defineConstants的行为一致性 | 低 |
主包体积这条单独说一下。Taro 3 加了运行时之后,主包很容易逼近 2M 的上限。我的经验是尽早把非核心页面拆到分包里,别等到最后构建报错才想起拆,那时候改动面会非常大。
7. 把重新加载的等待时间压下去:几个见效快的工程约定
聊了这么多坑,最后分享几个我在项目里实际推过、效果比较明显的做法。核心思路是:减少"必须重新加载"的次数,缩短"重新加载"的耗时。
7.1 目录结构上一刀切
我推的约定很简单:src下按域划分,每个域一个目录,里面放页面、组件、样式、接口,互不交叉。公共能力放src/shared。这样做的直接收益是构建时的依赖图更清晰,增量构建的范围更小——改一个域的文件,不会牵动整个项目的重建。
配套的一条硬规则是:跨域引用一律走shared,禁止域之间直接 import。这条规则能挡掉大量循环依赖,而循环依赖正是增量构建变慢的隐形杀手。
7.2 把"重"的东西挪到启动后
小程序启动时的首屏时间很敏感。我的做法是把非首屏必需的初始化全部推迟:登录态校验、埋点初始化、AB 实验拉取,这些全部放到首屏渲染之后异步执行。首屏只做最必要的渲染。
// 首屏渲染完成后再做重活 useReady(() => { setTimeout(() => { initAnalytics() fetchExperiments() }, 0) })这个setTimeout不是玄学,它把重活推到当前渲染帧之后,让首屏先出来。实测下来首屏时间能压下去一截,尤其在低端机上效果明显。
7.3 一套日常自检清单
我把日常最常遇到的重新加载问题整理成了一份清单,每次遇到"改了没反应"就按顺序过一遍,基本能在五分钟内定位:
| 序号 | 检查项 | 判断依据 |
|---|---|---|
| 1 | dist 是否更新 | 对比文件修改时间 |
| 2 | project.config.json 的 root 是否正确 | 与 outputRoot 对照 |
| 3 | 是否有新增页面未重新编译 | app.json 里是否存在该页面 |
| 4 | 是否改了 config/index.js 未重启 | 终端是否有重启提示 |
| 5 | 样式产物是否含新值 | grep dist 里的 wxss |
| 6 | 工具缓存是否污染 | 清缓存后重编验证 |
| 7 | 真机是否用的旧包 | 重新构建后再预览 |
这份清单看着朴素,但它把"靠感觉排查"变成了"按步骤排查",效率差别很大。团队里新人上手的时候,我直接把这张表发过去,比口头讲半小时管用。
7.4 一个小的提效脚本
最后分享一个我自己用的小脚本,放在项目根目录,用来一键做干净构建,省得每次手敲:
#!/usr/bin/env bash set -e TYPE=${1:-weapp} ENV=${2:-development} echo "清理旧产物..." rm -rf dist echo "构建: type=${TYPE} env=${ENV}" NODE_ENV=${ENV} npx taro build --type "${TYPE}" echo "构建完成,产物时间:" ls -l --time-style=full-iso dist/app.js 2>/dev/null || ls -l dist/app.js用法就两种:./build.sh weapp development做开发构建,./build.sh weapp production做生产构建。养成"遇到诡异问题先跑一次干净构建"的习惯,能挡掉相当一部分由产物残留引起的问题。
再补一个我个人踩了不止一次的坑:开发阶段千万别在两个终端里同时跑 watch。两个进程写同一个dist目录,产物会互相覆盖,表现是"文件时间在变但内容是错的",排查起来极其痛苦。一个项目一个 watch 进程,这条没有例外。