Taro重新加载小程序:链路排查与软重启实践
去年接手一个要同时跑小程序端和 H5 端的项目,团队里绝大多数人写 React,权衡了两天最后定了 Taro。从那之后,“Taro 重新加载小程序”这件事几乎成了我每天按得最多的一个动作——改一行样式,等它自动编完,看界面有没有变化。顺利的时候它是无感的,不顺利的时候,你能对着屏幕怀疑人生:代码明明改了,产物明明生成了,小程序就是不给你面子。这篇东西就是把这几年在 Taro 上折腾小程序攒下来的经验摊开讲,重点聊“重新加载”这条链路上到底有哪些环节会断,以及断了之后按什么顺序去查。适合已经上手写过一两个 Taro 项目、但一遇到“改了没反应”就只会重启电脑的朋友,也适合正准备把老项目迁到 Taro 的团队拿去做个参考。
1. 先把“重新加载”这个词拆开:它在 Taro 项目里有四层含义
1.1 四层含义与各自的触发条件
很多人搜“Taro 重新加载小程序”,脑子里想的其实是同一件事——让界面按照我最新写的代码重新渲染一遍。但落到工程上,这四个动作的触发者、作用范围、失败表现完全不同,混在一起想就会导致排查方向跑偏。
第一层是编译层的增量构建。webpack(或 Vite)在 watch 模式下监听到源文件 mtime 变化,只重新编译受影响的模块,把结果写进dist。这一层失败的表现是:终端没有任何新日志,dist里对应文件的修改时间没变。
第二层是开发者工具的文件监听与重渲染。开发者工具盯着miniprogramRoot指向的目录,发现产物变了就重新加载页面甚至整个小程序。这一层失败的表现是:dist里的文件时间戳是新的,但模拟器里的界面还是旧的。
第三层是代码里的软重启。业务上需要一个“把整个应用状态清干净、回到首页”的能力,比如切换账号、切换语言、换肤,或者用户点了“恢复默认设置”。小程序没有提供reload这类 API,只能用reLaunch一类的手段去模拟。
第四层是线上版本更新后的重启。新版本发布后,用户手里跑的还是旧代码,需要走UpdateManager那一套,提示用户重启应用。
把这四层分清楚,你才知道自己遇到的问题该往哪儿看。我见过有同学改完代码没反应,第一反应是去清小程序缓存,折腾十分钟,最后发现是 dev server 早就因为一个 TS 类型报错挂掉了。
1.2 为什么这四层特别容易混淆
因为它们的表象高度相似:都是“我改了,但没生效”。而 Taro 这个框架又恰好横跨了 Node 侧的构建进程和小程序侧的运行时,中间隔着一个文件系统,任何一端出问题,现象都是一样的白屏或者旧界面。
更麻烦的是,Taro 编译出来的app.json、页面 JSON 都是生成物。你在dist里手动改一下能立刻生效,但只要源文件再动一次,你的修改就被覆盖回去。这种“改了一次有效、第二次失效”的诡异现象,很容易让人误判成缓存问题。
我的建议是养成一个习惯:先看终端,再看目录,最后看工具。终端有没有新日志,决定了构建进程是不是还活着;dist里的时间戳,决定了产物是不是真的更新了;只有前两项都正常,才轮到怀疑开发者工具的缓存。这个顺序能把大部分问题在三分钟内定位。
2. 工程骨架怎么搭,决定了后面踩坑的数量
2.1 初始化时的几个关键选择
用官方脚手架起项目,交互里会问框架、TypeScript、CSS 预处理器、构建工具。我的固定组合是 React + TypeScript + Sass + webpack5。选 webpack5 不是因为 Vite 不好,而是团队里老项目多,webpack 的插件生态和报错信息更熟悉,遇到奇怪问题时能查到的资料也更多。如果你是新项目、对构建速度敏感,Vite 那套完全可以上,但要注意它和 webpack 在路径别名、静态资源处理上的行为差异,迁移时容易掉坑。
初始化之后先别急着写业务,花半小时看一遍目录:
├── config/ │ ├── index.ts │ ├── dev.ts │ └── prod.ts ├── src/ │ ├── app.config.ts │ ├── app.tsx │ ├── app.scss │ ├── pages/ │ └── components/ ├── project.config.json ├── package.json └── dist/ # 构建产物,不要提交config/index.ts里的outputRoot决定了产物落在哪,默认是dist。project.config.json里的miniprogramRoot必须跟它对齐。这两个值不一致,是新手最容易犯的错——Taro 把东西编到 A 目录,开发者工具盯着 B 目录,结果就是你每次改完都要手动点一次“编译”才能看到变化,而你还以为是热更新坏了。
2.2 编译产物的落点,决定了工具能不能监听到
开发者工具的监听范围是miniprogramRoot下的所有文件。如果这个目录在某个被系统或同步盘“冻结”的位置,监听就会时灵时不灵。我遇到过一次,项目放在一个自动云同步的文件夹里,同步客户端会在后台对文件做扫描和加锁,导致开发者工具偶尔漏掉变更事件。表现是十次改动里有两次没反应,非常折磨人。把项目挪到本地普通目录之后就再没出现过。
另一个常见问题是dist被加进了.gitignore,但 CI 上又忘了先构建。本地开发时dist是存在的,你以为一切都对,换台机器拉下来一跑,页面全都找不到。所以凡是涉及构建产物的路径,都要在 README 里写清楚,别指望下一个人能自己猜出来。
2.3 开发与生产配置的分离
config/dev.ts和config/prod.ts里最值得改的是环境常量和 sourcemap。开发期我会打开defineConstants注入的API_BASE,指向本地 mock 服务;生产环境走正式域名。同时开发环境保留 sourcemap,虽然会拖慢一点构建速度,但真机上出问题的时候,堆栈能不能看懂差别巨大。
这里有个细节:小程序真机调试时,process.env.NODE_ENV的值取决于你用哪条命令构建。用--watch跑出来的包,如果配置里没写清楚,可能带着开发环境的常量被传到了体验版,导致接口直连测试服务器。我的做法是在app.tsx启动时打印一行环境标识,肉眼即可确认当前包是哪套配置,比翻配置文��快得多。
3. 让“保存即刷新”稳定跑起来的完整配置
3.1 从一次保存到界面更新,链路到底有多长
把这条链路画出来,你才知道该在哪一段设卡。
第一步,编辑器把文件写进磁盘,更新 mtime。第二步,webpack 的 watcher 捕获变更事件。第三步,增量编译,把受影响的模块重新打包。第四步,产物写入dist。第五步,开发者工具的 watcher 捕获dist的变化。第六步,工具重新加载对应的页面或整个小程序。第七步,小程序运行时执行新代码,界面刷新。
七步里任何一步断掉,你看到的都是“没反应”。而绝大多数人排查时只盯着第六步,也就是“清缓存”和“点编译”,这就是为什么很多问题清完缓存还是原样。
3.2 启动命令与脚本配置
我的package.json里长期保留这么几条:
{ "scripts": { "dev:weapp": "taro build --type weapp --watch", "build:weapp": "taro build --type weapp", "dev:h5": "taro build --type h5 --watch" } }--watch这个参数是热更新的前提,漏了它,Taro 编译一次就退出,后面怎么改都不会有产物更新。这一点在复制别人脚本的时候特别容易丢。另外一个细节是,别把dev:weapp和build:weapp同时跑在一个终端里,端口和输出目录会打架,产物互相覆盖,现象就是界面时不时跳回旧版本。
要让终端里的日志真正有用,可以在config/index.ts里关掉过于啰嗦的进度条输出,保留错误和警告。我个人的偏好是让编译错误足够醒目,因为一个被忽略的 TS 报错挂在那里,往往就是“改了没反应”的真正原因——很多人只看到终端一直在滚,没注意到最后一行是红的。
3.3 开发者工具侧必须打开的开关
工具里的“详情 - 本地设置”有几项值得固定下来:
- 文件保存时自动编译:打开。这是让第五步生效的关键,关掉之后你就得靠手动点编译。
- 不校验合法域名:开发期打开。否则本地 mock 接口全部 403,你会误以为是请求封装写错了。
- 启用多核心编译:机器性能够就开,增量构建会快一截。
- 调试基础库版本:锁定一个版本,别用“自动”。基础库一升级,某些组件的默认行为可能就变了,团队里几个人看到的界面不一致,排查成本极高。
还有一项容易被忽略:工具右上角的“编译模式”。如果你配了自定义编译模式,指定了启动页面和参数,那么每次编译都会从那个页面进。有一天你发现改首页没反应,其实是因为编译模式指向了详情页——这种乌龙我至少干过两次。
3.4 什么时候必须手动点一次“编译”
有几类改动会修改app.json的结构,Taro 会重新生成这个文件,但开发者工具对结构性变更的响应并不总是可靠:
| 改动类型 | 是否需要手动编译 | 原因 |
|---|---|---|
| 修改页面内的 JSX / 样式 | 否 | 增量编译即可生效 |
| 新增或删除页面 | 是 | app.json的pages数组变了 |
修改tabBar配置 | 是 | 涉及原生导航结构 |
新增分包 / 改preloadRule | 是 | 分包结构需要重新登记 |
修改project.config.json | 是 | 工具本身需要重新读取项目配置 |
| 新增自定义组件的引用路径 | 视情况 | 路径解析失败时会报组件找不到 |
判断方法很简单:只要这次改动会让dist/app.json的内容发生变化,就老老实实点一次编译。养成这个习惯之后,能省掉大量“为什么新页面白屏”的困惑。
4. 编译缓存与“我明明改了却没生效”的标准处理流程
4.1 五层缓存,从上到下逐层排查
Taro 项目的缓存不止一处,盲目清全部会浪费大量时间。按影响范围从大到小排:
| 层级 | 位置 | 典型症状 |
|---|---|---|
| 构建进程内存缓存 | webpack 进程内 | 改配置不生效,需重启 |
| 持久化缓存 | node_modules/.cache或项目根.temp | 改了依赖版本但产物没变 |
| 产物目录 | dist/ | 删了页面文件但旧产物还在 |
| 开发者工具缓存 | 工具菜单“清缓存” | 存储、文件系统、授权状态残留 |
| 真机缓存 | 手机上的小程序容器 | 手机上表现和模拟器不一致 |
第三层和第四层最常见。dist目录是增量写入的,Taro 不会主动删除已经不再被引用的旧文件。你删掉了一个页面,源文件没了,但dist里那个页面的产物还在,app.json也已经更新了,工具可能还在加载旧文件,于是报出各种“找不到组件”的错。我的习惯是每次大改目录结构之后,直接删一次dist再重新构建。
4.2 一套按代价从低到高的清理动作
我把它整理成一个固定顺序,从代价最低的开始,能解决就停手:
- 终端
Ctrl + C,重新跑dev:weapp。这一步能解决大部分“进程挂了但你没发现”的情况。 - 删
dist,重新构建。解决产物残留问题。 - 删缓存目录:
rm -rf node_modules/.cache .temp dist。解决依赖升级后产物不一致。 - 开发者工具“清缓存 - 全部清除”,然后重新编译。解决存储、授权、文件系统残留。
- 删
node_modules重装。解决依赖树被污染,通常是多人协作时 lock 文件冲突导致的。 - 换一个项目目录重新克隆。解决路径里有特殊字符、软链接、同步盘干扰。
第 3 步的命令我一般会写成脚本:
rm -rf dist .temp node_modules/.cache npm run dev:weapp注意.temp这个目录在不同 Taro 版本里的位置不完全一样,有的版本放在项目根,有的并进node_modules/.cache。动手之前先ls -a看一眼,别删了不该删的东西。
4.3 我踩过的四种“假缓存”
第一种,文件名大小写不一致。本地是 macOS 或 Windows,文件系统不区分大小写,Header.tsx和header.tsx都能被解析。推到 CI 上跑 Linux 构建,直接报模块找不到。这类问题在本地永远复现不了,唯一的办法是团队统一命名规范,并且在提交前用工具检查一遍。
第二种,编辑器的“安全写入”。某些编辑器保存时会先把内容写到临时文件,再原子替换。理论上 mtime 会变,但配合某些同步软件或杀毒软件的实时扫描,watcher 可能收不到事件。表现是隔几次保存才有一次生效,极其随机。换编辑器或者关掉“安全写入”选项可以验证是不是这个原因。
第三种,多包仓库的软链接。如果你把公共组件抽成了本地包,通过软链接引入,webpack 默认不会跟随软链接去监听源目录。改了公共组件,业务项目毫无反应。这需要在配置里显式打开resolve.symlinks相关的 follow 选项,或者干脆用 workspace 的方式管理。
第四种,同名文件被旧产物覆盖。这个最隐蔽:你在src里新建了一个和旧文件同名的组件,路径不同但文件名一样,dist里两个都在,运行时加载了错的那个。现象是“我明明改了文案,界面上还是老的”,但你去搜源码,改的地方确实是对的。删dist是唯一可靠的解法。
5. 代码层面的“重新加载”:软重启怎么做才干净
5.1reLaunch的边界在哪里
业务上经常需要一个“整个应用重来一遍”的动作。小程序没有提供真正的重启 API,最接近的是Taro.reLaunch。它会关闭所有页面,打开指定页面,页面栈被清空,视觉效果上确实像重启了。
但它有三个东西不会动:App实例不会被销毁,globalData里的数据还在;全局单例(比如自己 new 出来的 store、request 实例)还在;定时器、事件监听、WebSocket 连接也还在。所以如果你只是reLaunch一下,用户切了账号,结果发现列表里还是上一个人的数据,就会觉得这个“重启”是假的。
我的做法是封装一个restartApp,把该清的都清干净再跳:
import Taro from '@tarojs/taro' import { resetStore } from '@/store' export async function restartApp(target = '/pages/index/index') { // 1. 停掉所有轮询与定时器,注册表统一管理 clearAllTimers() // 2. 断开长连接 socket?.close() // 3. 清掉全局事件总线上的监听 Taro.eventCenter.trigger('app:before-reset') Taro.eventCenter.off() // 4. 重置状态管理 resetStore() // 5. 清理本地存储中与身份相关的键 const keepKeys = ['deviceId', 'firstLaunchAt'] const info = Taro.getStorageInfoSync() info.keys .filter((k) => !keepKeys.includes(k)) .forEach((k) => Taro.removeStorageSync(k)) // 6. 最后再跳,保证跳过去的时候环境是干净的 await Taro.reLaunch({ url: target }) }这段代码的关键在于顺序。一定要先清理再跳转,反过来写,新页面已经渲染出来了,清理动作还在异步执行,就会出现界面闪一下又变回旧数据的情况。另外Taro.eventCenter.off()不带参数会清掉所有监听,包括框架自己注册的,用之前确认一下你的业务没有依赖框架内部的通道,保险起见可以按事件名逐个 off。
5.2 两级跳:绕过“跳到当前页不重建”的问题
有个很坑的行为:如果你当前就在首页,然后调用reLaunch到首页,某些基础库版本下页面不会真正重建,onLoad不会重新执行,你清理掉的全局状态没人去重新初始化,界面就空了。
解法是先跳到一个空白过渡页,再在过渡页的onLoad里redirectTo回目标页:
// utils/restart.ts export async function hardRestart(target: string) { await Taro.reLaunch({ url: '/pages/blank/index' }) // blank 页面的 onLoad 里执行: // Taro.redirectTo({ url: decodeURIComponent(options.to) }) }过渡页本身什么都不渲染,只负责转发。参数用encodeURIComponent包一层,避免路径里的查询参数被截断。这个技巧在换肤、切换语言这类需要整站刷新的场景里特别好用,实测比单纯reLaunch稳定得多。
5.3 换肤、切语言、切账号,处理方式其实不一样
| 场景 | 需要清的东西 | 推荐做法 |
|---|---|---|
| 换肤 | 样式变量、缓存的图片 | 全局状态 + CSS 变量,不必重启 |
| 切语言 | 文案表、日期格式、缓存数据 | 重置 i18n 实例后reLaunch |
| 切账号 | 全部身份相关数据、长连接 | 完整restartApp流程 |
| 崩溃恢复 | 页面栈、异常状态 | 过渡页硬重启 |
换肤完全不需要重启,把主题色做成 CSS 变量挂在根节点上,切换时改变量就行,重启反而是过度设计。切语言稍微复杂一点,因为有些文案是在页面加载时就算好塞进 data 的,必须重新走一遍生命周期,所以reLaunch是合理的。切账号最严格,任何残留都可能导致越权展示,必须走完整流程。
6. 线上版本热更新:让用户主动重启的正确姿势
6.1UpdateManager的完整用法
新版本发布后,用户下次冷启动小程序时会下载新包,但需要一次重启才能应用。UpdateManager就是干这个的。这段代码我几乎是原样复制到每个项目里:
import Taro from '@tarojs/taro' export function setupUpdateManager() { if (process.env.TARO_ENV !== 'weapp') return const manager = Taro.getUpdateManager() manager.onCheckForUpdate((res) => { // res.hasUpdate 为 true 表示有新版本正在后台下载 console.log('hasUpdate:', res.hasUpdate) }) manager.onUpdateReady(() => { Taro.showModal({ title: '更新提示', content: '新版本已经准备好,重启后生效', showCancel: false, confirmText: '立即重启', success: () => manager.applyUpdate() }) }) manager.onUpdateFailed(() => { Taro.showModal({ title: '更新失败', content: '请删除小程序后重新搜索打开', showCancel: false }) }) }applyUpdate()调用之后,小程序会被强制重启,效果等同于用户杀掉进程重新进入,onLaunch会重新执行。这是唯一官方支持的真·重启。
6.2 几个容易翻车的细节
第一,开发版和体验版不触发更新流程。你在本地怎么测都测不出来,必须发到线上才能验证。所以这段代码写完,至少要走一次真实的发布流程确认弹窗能出来。
第二,showModal的showCancel一定要设成false。如果用户点了取消,新包已经下载好了但没应用,下次启动又会弹一次,体验很差。强制重启是更合理的选择。
第三,不要在onLaunch里同步调用。小程序启动时立刻弹窗会打断首屏渲染,建议延迟几百毫秒,或者等首屏主要数据加载完再弹。我一般放在首页useDidShow里,配合一个只执行一次的标记。
第四,基础库版本太老可能不支持。判断一下Taro.canIUse('getUpdateManager'),不支持就走降级逻辑,别让整个启动流程崩掉。
7. 样式与布局:小程序端的那些“不一样”
7.1 单位换算是最容易出错的一环
Taro 默认把源码里的px转成小程序的rpx,转换比例由designWidth和deviceRatio决定。默认designWidth: 750,意思是设计稿按 750 宽出,你写100px,产物里就是100rpx,在 iPhone 6 上等于 50 物理像素。
如果设计稿是 375 宽,一定要改配置:
// config/index.ts mini: { postcss: { pxtransform: { enable: true, config: { selectorBlackList: ['nut-'] } } } }同时在designWidth那里填 375。不改的后果是所有尺寸大了一倍,你在模拟器上看着还行(因为模拟器按 375 宽渲染),真机上直接溢出。
如果需要某个值保持px不被转换,写成大写的1PX。这个语法在很多 UI 库的源码里能看到,作用是让某些边框线在 Retina 屏上保持 1 物理像素。我第一次看到的时候还以为是拼写错误。
7.2 样式隔离与全局样式的边界
app.scss里的样式是全局的,页面和组件都能用。自定义组件的样式默认是隔离的,外部影响不进去,里面的也不会漏出去。这个机制是好事,但会带来一个困惑:你在页面里写了个选择器想覆盖组件内部的元素,怎么写都不生效。
解法有三种:给组件加externalClasses暴露出去;用cssModules;或者最简单的,在组件内部加一个 prop 控制样式。我倾向于第二种和第三种,第一种写起来最省事但维护起来最乱,尤其是几个人同时维护一个组件时,外部类名满天飞,最后谁也不知道哪个样式在起作用。
还有一点,小程序对选择器的支持是有限的。通配符*、部分属性选择器、伪元素在自定义组件内的行为都不完全一致。写样式时尽量用类选择器,别图省事用标签选择器,跨端编译到 H5 时表现也会不一样。
7.3 图片与背景图的两个硬限制
第一,本地图片超过一定体积(一般 10KB 左右)不会被内联成 base64,而是被打包成独立文件。图片多了之后包体积涨得飞快,主包 2MB 的红线很容易撞上。我的原则是:图标类走字体或 SVG 内联,插画类一律放 CDN。
第二,background-image不支持本地相对路径。写url('./bg.png')在小程序里是无效的,必须用网络地址或者 base64。这个限制在 H5 端不存在,所以跨端项目里经常出现“H5 好好的,小程序背景图没了”的情况。统一改用网络地址是最省心的做法。
8. 生命周期、页面栈与数据流的高频坑
8.1useEffect和useDidShow到底该用哪个
Taro 3 用 React 写页面,但页面生命周期是小程序那一套,两者要对应起来:
| 需求 | 用哪个 | 说明 |
|---|---|---|
| 页面首次加载取参数 | useLoad/useRouter | 只在首次执行 |
| DOM 就绪后测量尺寸 | useReady | 对应onReady |
| 每次页面显示都刷新 | useDidShow | 从详情页返回也会触发 |
| 组件挂载逻辑 | useEffect | 不感知页面显隐 |
| 页面隐藏时暂停轮询 | useDidHide | 对应onHide |
最典型的错误是把“从详情页返回后刷新列表”的逻辑写在useEffect里。useEffect的依赖不变就不会重新执行,用户从详情页改完数据返回,列表还是旧的。正确做法是放进useDidShow,并且注意首次进入时useDidShow也会触发,别把数据重复请求两遍。我的处理方式是用一个useRef记录首次加载标记,首次交给useLoad,后续交给useDidShow。
8.2 页面栈十层上限与返回刷新
小程序页面栈最多十层,超过之后navigateTo会失败。用户一路点下去,第十一次点击就毫无反应,这种体验很差。稳妥的做法是在跳转前判断Taro.getCurrentPages().length,接近上限时改用redirectTo。
另一个高频问题是返回时如何通知上一页。三种方式:eventChannel适合点对点传数据;Taro.eventCenter适合广播;最简单的其实是让上一页在useDidShow里重新拉一次接口,牺牲一点性能换代码简洁。我一般先用第三种,等接口压力真的上来了再换成前两种,过早优化会让代码变得很难读。
8.3setData的量级直接影响流畅度
Taro 3 的 React 更新最终会转成小程序的setData。这个过程涉及跨线程通信,数据量越大越慢。长列表一次塞几百条进去,滚动就会明显掉帧。
几个实测有效的做法:列表分页,一页二十条;滚动中不要频繁setState,把滚动位置存在 ref 里,需要时再读;列表项用稳定的key,别用数组下标,否则每次更新都会整片重渲染;大对象不要整个塞进 state,拆成多个字段按需更新。还有个小技巧,只在useDidShow里更新必要字段,而不是整个列表对象重新赋值,能省下不少通信开销。
9. 请求、分包与体积:上线前必须过的一关
9.1 请求层的统一封装
Taro.request本身够用,但业务里需要统一处理 token、错误码、加载态。我的封装大致长这样:
import Taro from '@tarojs/taro' const BASE = process.env.NODE_ENV === 'development' ? 'http://127.0.0.1:3000' : 'https://api.example.com' export async function request<T>(options: { url: string method?: 'GET' | 'POST' data?: Record<string, unknown> }): Promise<T> { const token = Taro.getStorageSync('token') const res = await Taro.request({ url: BASE + options.url, method: options.method || 'GET', data: options.data, header: token ? { Authorization: `Bearer ${token}` } : {} }) if (res.statusCode === 401) { await Taro.reLaunch({ url: '/pages/login/index' }) throw new Error('unauthorized') } if (res.statusCode >= 400) { Taro.showToast({ title: '网络异常', icon: 'none' }) throw new Error(`http ${res.statusCode}`) } return res.data as T }有一点要注意,Taro.request在 H5 端走的是浏览器的 fetch/XHR,跨域要靠服务端配 CORS;小程序端则依赖后台配置的合法域名,开发期靠工具里的“不校验合法域名”开关绕过。跨端项目里这两套机制要同时在文档里写清楚,否则接手的人一定会卡在请求发不出去这一步。
9.2 分包不是可选项,是必选项
主包 2MB 是硬线,超了直接传不上去。一个稍微复杂点的项目,把所有页面塞进主包,加上几个 UI 库和图表库,很容易就顶到红线。
分包的思路是按访问路径拆:首页、tabBar 页面、登录页放主包,其余按业务域拆成若干分包。配置写在app.config.ts里:
export default defineAppConfig({ pages: ['pages/index/index', 'pages/user/index'], subpackages: [ { root: 'package-order', pages: ['list/index', 'detail/index'] }, { root: 'package-setting', pages: ['index/index', 'about/index'] } ], preloadRule: { 'pages/user/index': { network: 'all', packages: ['package-order'] } } })preloadRule的作用是在进入某个页面时预下载分包,用户真正点进去的时候就不用等。但别乱配,预下载会消耗流量,全部配满等于没分包。我的经验是只对“用户一定会去”的路径预下载,比如个人中心下面挂的订单模块。
拆分完之后一定要在真机上验证一遍,因为分包加载失败的表现是白屏,模拟器上不一定复现。测试方法是在开发者工具里勾选“按需加载”,模拟弱网环境走一遍完整流程。
9.3 体积排查的具体手段
工具里自带代码依赖分析,能看清楚每个包的大小构成。常见的体积大户:UI 组件库全量引入、图表库、富文本解析库、日期库。
UI 库一定要按需引入,配置好 babel 插件或者直接用 ES 模块的具名导入。日期库换掉重型方案,用原生的Intl或者几行工具函数就够。图表库如果能用服务端出图,就别在前端渲染,尤其是那种一个月才看一次的报表页,完全没必要为它打包一个几百 KB 的库。
10. 常见问题速查表
把这几年遇到的问题整理成一张表,遇到类似症状先在这里找一遍,能省不少时间:
| 现象 | 最可能的原因 | 处理动作 |
|---|---|---|
| 改代码无反应,终端无日志 | dev server 已挂 | 重启--watch进程 |
| 终端有日志,界面无变化 | 工具缓存或产物残留 | 删dist重新构建 |
| 新页面白屏,报页面不存在 | app.json未重新生成 | 手动点一次编译 |
| 组件找不到路径 | 新增组件路径未重新解析 | 重启 dev server |
| 样式不生效 | 组件样式隔离 | 用externalClasses或提升到页面样式 |
| 尺寸整体偏大一倍 | designWidth配置与设计稿不符 | 改成 375 或调整换算比例 |
| 背景图不显示 | 本地路径不被支持 | 换网络地址或 base64 |
| 中文输入光标跳动 | 输入框受控写法在部分基础库上有问题 | 改用非受控 + 默认值 |
| 从详情返回列表不刷新 | 逻辑写在useEffect里 | 移到useDidShow |
| 跳转无反应 | 页面栈达到十层 | 判断栈深,改用redirectTo |
| 真机白屏,模拟器正常 | 分包未加载或域名未配置 | 检查preloadRule与合法域名 |
| 上传失败提示超限 | 主包超过 2MB | 拆分包、图片走 CDN |
| CI 编译报模块找不到 | 文件名大小写不一致 | 统一命名并检查引用 |
| 更新弹窗不出现 | 开发版不触发更新流程 | 发布后线上验证 |
| 长列表滚动卡顿 | 一次setData数据过大 | 分页 + 稳定key |
这张表不是终点,只是起点。每次遇到新问题,我都会补一行进去,时间长了它就成了团队里最实用的文档,比任何框架教程都管用。
最后分享几个实际操作中的体会
关于“重新加载”这件事,我最大的感受是:别把它当成一个按钮,把它当成一条链路。链路里的每一段都有明确的验证方法,终端日志验证构建进程,文件时间戳验证产物,工具的控制台验证运行时。养成按顺序验证的习惯,比记住一百个偏方有用得多。
第二个体会是减少不必要的重启。早期我遇到问题就删node_modules,后来发现真正需要那一步的场景可能只占百分之五。大部分问题在重启 dev server 和删dist这两步之内就能解决,剩下的时间本可以用来写业务。
第三个体会是把环境差异当成一等公民。本地能跑不代表 CI 能跑,模拟器正常不代表真机正常,开发版正常不代表线上正常。这三组差异我在项目里都是靠固定流程来兜底的:提交前跑一次生产构建,发版前走一遍真机主流程,上线后自己装一次最新版验证更新弹窗。这几个动作花不了多少时间,但能把“上线才发现”的概率压到很低。
最后一个,是我现在每个新项目都会做的一件事:在app.tsx启动时打印一行构建时间戳,格式精确到分钟。有了这行日志,任何“我改的到底进没进去”的疑问,一秒就能有答案。这个习惯是从一次连续四十分钟找不到问题根源的经历里留下来的,从那以后,我再没在缓存问题上浪费过半小时以上。