先交代背景。手上的项目是一个工业巡检 App,2022 年立项,用 uni-app + Vue3 + TS 写的,前前后后 20 来个页面,跑在 Android 工控平板和 iOS 质检 PAD 上。整个项目最痛的点就一个:长列表卡。
巡检任务列表,一次要展示 800 到 1200 条记录,每条上有图标、三行文字、两个状态标签、一个操作按钮。用 vue 页面渲染,滚动到 500 条之后就开始掉帧,滚到 800 条基本是一边滚一边白屏。用 nvue 页面重写过一次,稍好一点,但 nvue 里很多 Vue 的写法不能用,维护成本极高,写了半年改回 vue 了。
去年下半年的时候看到 uni-app x 的正式版发布,说是编译到原生、性能接近原生体验,抱着试一试的心态,花了两周时间做了个分支迁移。结论先摆在前面:迁是值得的,但不是全迁,我最后只迁了 6 个页面。下面把整个过程摊开讲。
迁移之前,先诚实评估
拿到「要不要迁」这个问题的时候,我做的第一件事是列一张表,把项目里所有页面按「交互复杂度」和「渲染压力」两个维度分类。
| 页面类型 | 数量 | 交互复杂度 | 渲染压力 | 是否迁移 |
|---|---|---|---|---|
| 任务列表页 | 2 | 中 | 极高 | 迁移 |
| 巡检详情页 | 1 | 高 | 高 | 迁移 |
| 实时监控大屏 | 1 | 低 | 极高 | 迁移 |
| 图片标注页 | 1 | 高 | 高 | 迁移 |
| 报表查看页 | 1 | 中 | 中 | 迁移 |
| 登录、设置、消息等 | 14 | 低 | 低 | 不迁移 |
结论很清晰:只迁性能瓶颈的页面,其他保持原样。有人可能会问「能不能一半 vue 一半 uvue 混着跑」,答案是可以的,uni-app x 支持更细粒度的页面级配置,不需要整个项目一把梭。
顺便说一句,我见过太多团队一上来就「全项目迁移」,跑到第三周发现表单页面里某个三方组件不兼容、某个 CSS 属性不支持,返工,最后又改回来。别急,先解决真问题。
项目准备:先搞清版本和构建链
uni-app x 对 HBuilderX 的版本是有硬性要求的,而且从 4.24 之后每隔一两个版本都会有些重要更新。我用的当时是 4.45 稳定版,配套的 uni-app x 编译器版本是 1.x。这里有个隐形坑:不同版本对某些语法的支持程度不一样,尤其 CSS。同一个 CSS 属性在 4.24 里报错,在 4.45 里可能就能用了。团队协作的话,一定在 .gitignore 里明确锁定 HBuilderX 的版本要求,写在 README 里。
我建立了一个独立分支 feature/uvue,然后把原本的 pages.json 复制了一份,只改了一份,两边各自维护路由。这是为了后面如果迁移失败,能快速回滚。
项目结构:看似一样,实则不同
先说个好消息:uni-app x 的项目目录结构跟原来的 uni-app 基本一致。pages、static、components、pages.json、manifest.json,都在老地方。
但有三个关键差异,是迁移初期最容易踩的。
第一个差异:后缀名。原来 vue 页面用 .vue,uvue 页面用 .uvue。这个后缀很重要,HBuilderX 是根据后缀来选择编译器走 vue 还是走 uni-app x 的。
第二个差异:pages.json 里的 "style" 配置可以共存。同一个项目可以同时存在 .vue 页面和 .uvue 页面,路由配置里只要写清路径就行。我在迁移过程中把两者混跑了一段时间,验证稳定性。
第三个差异:App.vue 和 main.ts。原来的 App.vue 用 main.ts 里的 createSSRApp 挂载,uvue 版本的入口稍不一样,得用 uni-app x 的语法。我当时的做法是先保持原样,遇到报错再改。
语法差异:从「能用就行」到「严格约束」
这是迁移里最耗时间的部分。uvue 页面的 JS 层其实跟普通 Vue3 差异不大 —— 你还是能写 <script setup>、ref、computed、watch、onMounted,这些完全一样。但一旦碰到「跟渲染、跟原生能力、跟样式」相关的地方,约束就突然收紧了。
模板层的差异
模板语法基本一致,但有几个不能用的用法:
v-html不支持。uni-app x 里原生渲染没有 DOM,不能直接塞 HTML 字符串。<slot>支持,但不能像 Web 那样做复杂的动态 slot 分发,尤其是带 scoped slot 的场景限制较多。- 动态组件
<component :is="...">的用法被限制了,需要改成v-if分支。 <transition>组件不支持,所有动画得自己用 CSS 或者用uni.createAnimation。
脚本层的差异
这块还好,大部分 JS 语法都能用,差异主要在两处。
一是 any 类型。uvue 编译成原生代码的时候,如果变量类型推不出来,会编译失败。所以 let data: any = {} 这种写法要么改成明确的 interface,要么至少标注成 UTSJSONObject。这是从 TS 迁过来最不习惯的地方。
二是依赖 Web API 的代码。原来项目里有几处用了 window、document、localStorage 之类的东西。uvue 里没有这些,全部要换成 uni 的 API 或者 UTS 层面的实现。
样式的差异(重灾区)
我在迁移过程中碰到的问题,80% 都是 CSS。
第一是 不能给样式用行内 style=”…” 传变量,那种 :style="{ color: textColor }" 是可以的,但如果表达式太复杂,编译会出问题。稳妥的做法是定义 class,用 :class 切。
第二是部分 CSS 属性被阉割了。我遇到的具体例子有:background-image 只能用纯色和 linear-gradient,不能直接引用图片 path;box-shadow 支持,但某些复杂多层阴影会失效;filter: blur() 只有部分场景能用,性能还差。
第三是overflow 的行为跟 Web 不一样。原生渲染里的裁剪规则更严格一些,有些写法在 web 上可滚动,在 uvue 里完全不响应。
案例一:巡检任务列表的改造
这是迁移的核心目标,摆两个对比。
原来的 vue 页面大致是这样:
<template>
<scroll-view scroll-y class="list" @scrolltolower="loadMore">
<view v-for="item in tasks" :key="item.id" class="task-item">
<image :src="item.icon" class="icon" />
<view class="content">
<text class="title">{{ item.title }}</text>
<text class="subtitle">{{ item.location }} · {{ item.time }}</text>
<view class="tags">
<text v-for="t in item.tags" :key="t" class="tag">{{ t }}</text>
</view>
</view>
<button @click="open(item)">处理</button>
</view>
</scroll-view>
</template>
迁到 uvue 里,业务结构基本没变,但有几处要改:
<template>
<list-view class="list" @scrolltolower="loadMore">
<list-item v-for="item in tasks" :key="item.id" class="task-item">
<image :src="item.icon" class="icon" />
<view class="content">
<text class="title">{{ item.title }}</text>
<text class="subtitle">{{ item.location }} · {{ item.time }}</text>
<view class="tags">
<text v-for="t in item.tags" :key="t" class="tag">{{ t }}</text>
</view>
</view>
<button @click="open(item)">处理</button>
</list-item>
</list-view>
</template>
核心变化是 scroll-view + v-for 换成了 list-view + list-item。这就是 uni-app x 里做长列表的正确姿势:list-view 是原生虚拟列表容器,list-item 是它的子项。
这里有个细节:scroll-view 在 uvue 里也能用,但不具备虚拟化能力 —— 也就是说 800 条数据全都会创建原生 View 对象,性能上跟 vue 页面没多大区别。必须用 list-view 才有虚拟化效果。
实测结果:同样的 1000 条数据,滚动到底,vue 页面在 500 条左右就有明显掉帧,uvue 页面全程 60fps 上下,差不多是「能用」和「不能用」的差别。
但 list-view 也有坑。一是 v-for 的时候不能用 list-item 之外的包装元素,二是如果要动态高度,需要显式设置 item-size 才能达到最好的性能。这一点在官方文档里提得不多,我踩过才明白。
案例二:图片标注页的原生能力迁移
这个页面比较特殊,原来是 vue 页面里用 <canvas> 做手绘标注,然后 uni.canvasToTempFilePath 导出图片,再传给后端。迁到 uvue 里,canvas 的 API 兼容性不好,canvasToTempFilePath 也不支持。
我的做法是换一套思路:用 uvue 的 <canvas> 做绘制(uni-app x 有专门的 canvas 组件),导出图片放到原生层用 UTS 处理。既然是原生渲染,直接用 Android 的 Bitmap 和 iOS 的 UIImage 反而更顺。
写了一个简单的 UTS 插件,接收 canvas 传递过来的像素数据,转成平台原生图片,再写文件。这部分细节比较多,有兴趣的话可以单独写一篇。简单说结论:uvue 里遇到 Web API 缺位,不要硬凑,果断下潜到 UTS。
八个字总结踩过的坑
「能迁则迁,不能就绕」。下面分条说。
坑一:HBuilderX 版本必须锁死。团队里只要有人升级了 HBuilderX,其他没升级的人拉代码就会编译失败。建议用 .hbuilderx 目录做个简单的版本记录,或者干脆统一让 CI 用固定版本的 HBuilderX。
坑二:v-show 在 uvue 里是移除节点,不是 display: none。这是个反直觉的地方。原来 vue 里 v-show 切换只改 CSS,DOM 保留;uvue 里切换其实会销毁和重建原生 View,性能开销比 v-if 还大。频繁切换的场景,老老实实用 CSS 控制 opacity 或者用 v-if 一次到位。
坑三:uni.getStorageSync 的行为变了。uvue 里同步 storage 读写只支持字符串,读写对象需要自己 stringify,或者用 uni.getStorage 的异步版本。
坑四:CSS 里的 flex: 1 在 uvue 里表现微妙。Web 上 flex: 1 是 flex: 1 1 0 的简写,uvue 里可能理解成 flex-grow: 1 但 basis 保留 auto,结果是撑不满。稳妥做法是显式写 flex: 1 1 0 或者 flex-grow: 1; flex-shrink: 1; flex-basis: 0。
坑五:position: fixed 里含 text 或input 时,键盘弹起可能被挡。这个问题在 vue 页面里也有,但 uvue 里更明显。解决办法是把输入区域整体上移,或者用 uni.onKeyboardHeightChange 监听键盘高度,动态设 padding-bottom。
坑六:uni.showToast 的 duration 上限变了。原来可以传 5000ms,uvue 里超过某个值会被截断。做长提示建议用 uni.showLoading 或者自定义组件。
坑七:图片加载失败没有 onerror 事件。uvue 的 image 组件没有 @error 回调(不同版本可能有差异),要靠 uni.getImageInfo 预检。这一条是最不习惯的。
坑八:dev 工具的热重载不如 vue 顺滑。uvue 项目的编译涉及原生代码生成,热重载偶尔会出现「旧状态没清掉」的情况。开发时建议每半小时重启一次 dev server,能省掉不少灵异现象。
性能对比:数据说话
测试机型是红米 Note 11(安卓 13)和 iPad 9(iPadOS 17),测的都是同一批数据、同一段网络请求。冷启动时间指的是 App 从点击图标到首页首屏可交互的毫秒数。
| 测试项 | vue 页面 | nvue 页面 | uvue 页面 |
|---|---|---|---|
| 冷启动到首屏 | 2.8s | 2.1s | 1.6s |
| 1000 条列表首次渲染 | 4.2s | 2.5s | 0.6s |
| 滚动到 1000 条时的帧率 | 28fps | 45fps | 58fps |
| 列表内存占用(1000 条) | 180MB | 120MB | 52MB |
| 页面切换动画流畅度 | 一般 | 较好 | 好 |
数据比我预想的要好。最惊艳的是列表首次渲染时间,从 4.2 秒降到 0.6 秒,因为虚拟列表只创建了可见的十来条。内存占用也降了三分之二。iPad 上的测试结果类似,但整体比安卓更好。
什么项目该迁,什么不该迁
说了这么多好处,也得泼盆冷水。不是所有项目都值得迁。
值得迁的:
- 长列表、大表格、实时图表这类渲染密集的页面
- 对滚动流畅度有明确要求的交互
- 项目生命周期还长,团队有精力维护两套代码的
不建议一上来就迁的:
- 页面以表单、设置、说明为主,几乎没有性能压力的项目
- 重度依赖三方 UI 组件库的项目 —— uni-app x 的生态还在建设期,很多组件需要自己造
- 主要跑在 H5 上的项目,uni-app x 的 H5 支持不如 App 端成熟
- 依赖大量 Web API 的老项目,迁移成本可能超过收益
我的判断标准很简单:如果现在的痛点是「性能」,就迁;如果痛点是「开发速度」或者「生态丰富度」,先别动。
迁移流程建议
最后给一套我的实操步骤,供参考。
- 锁死 HBuilderX 版本,团队统一。
- 新建分支,不删原页面,uvue 页面跟 vue 页面并行存在。
- 选 1 到 2 个性能最痛的页面做试点,跑通上线。
- 试点成功之后,逐步扩大迁移范围,每次不超过 3 个页面。
- 迁移完成后,原 vue 页面再保留两个版本迭代周期,确认没有回归问题再删。
- 把踩过的坑、版本兼容性问题,写进团队的迁移手册。
两周的迁移给我最大的感触,倒不是性能提升了多少,而是意识到「Web 层兜不住的事,往下走一层就好了」。在 vue 页面里跟性能缠斗,用各种懒加载、虚拟滚动的库救场,本质都是在 JS 层打补丁。而 uni-app x 提供的思路是:让原生去做原生擅长的事。这个思路一旦转过来,很多以前觉得无解的问题,就变得简单了。
当然,uni-app x 现在还不是银弹,生态上的短板需要时间填。我的建议是:新项目可以放心用;老项目按需迁,别追求一次到位。工具是拿来解决问题的,不是拿来证明先进的。合适就好。

