第6章 6.4 nvue 原生渲染
🎯 开场 3 分钟:为什么要学这个?
上一章我们聊了各种小程序平台的差异,你会发现:不同平台长得不一样就算了,连底层技术都各不相同。这就尴尬了——你写的东西在这个平台能跑,换个平台可能就卡成 PPT。
痛点来了:
- 用 vue 写界面,渲染慢、帧率低,玩复杂动画直接卡死
- 想做个视频播放界面原生一点,但 vue 里的 video 组件体验稀碎
- 听说 nvue 能解决性能问题,但打开文档一看——啥是 weex?啥是 subNVue?直接劝退
这一章我们就来解决这个问题。学完之后,你就能判断什么时候该用 nvue、什么时候继续用 vue,还能写出一个能跑的原生态列表页面。下章我们还会用它来做一个真正的跨端电商 App,所以这一章是铺垫!
🧱 基础 25 分钟:核心概念(小白视角)
6.4.1 先搞清楚:nvue 是个啥?
类比时间到: 想象你去餐厅吃饭。
- vue 就像是你点了一份"外卖」:厨师做好之后,装进盒子里,骑手送到你手上\n\n
\n\n
\n\n。你吃到的味道,和厨房里出锅的味道,中间隔了好几个环节。 - nvue 就像是你坐在「透明厨房」里:厨师就在你面前做,你看到的是第一手的成品,没有中间商赚差价。
翻译成技术话:nvue 是 uniapp 基于 Weex 引擎的原生渲染模式,它直接用原生组件来构建界面,而不是 WebView 里的 H5 页面。
6.4.2 nvue vs vue:一张表说清楚
| 对比项 | vue 页面 | nvue 页面 |
|---|---|---|
| 渲染引擎 | WebView(浏览器内核) | Weex(原生渲染) |
| 性能 | 一般,复杂动画容易卡 | 流畅,接近原生体验 |
| 支持的平台 | 所有平台 | App 端(iOS/Android)+ 部分小程序 |
| 组件库 | H5 组件,样式丰富 | 原生组件,样式有限 |
| 写法 | Vue 标准写法 | Vue 写法 + Weex 限制 |
| 支持 5+ | ❌ 不支持 | ✅ 支持 |
什么时候用 nvue?
- 要做高性能需求的页面(瀑布流、长列表、游戏界面)
- 需要和原生组件深度交互(视频、地图、直播推流)
- 做 App 端开发,追求原生体验
什么时候继续用 vue?
- 快速开发,跨平台兼容要求高
- 页面比较简单,不需要极致性能
- 需要丰富的 H5 组件和生态
6.4.3 第一个 nvue 页面:手把手
好,废话不多说,先跑起来再说。
步骤 1:创建 nvue 文件
在 uniapp 项目里,找 pages 目录,新建一个文件叫 nvue-demo.nvue(注意后缀是 .nvue 不是 .vue)。
步骤 2:写代码
<template>
<view class="container">
<text class="title">我的第一个 nvue 页面</text>
<text class="subtitle">这是原生渲染的哦!</text>
<button @click="handleClick">点我试试</button>
</view>
</template>
<script>
export default {
data() {
return {
clickCount: 0
}
},
methods: {
handleClick() {
this.clickCount++
uni.showToast({
title: `点击了 ${this.clickCount} 次`,
icon: 'none'
})
}
}
}
</script>
<style>
.container {
flex: 1;
justify-content: center;
align-items: center;
padding: 20px;
}
.title {
font-size: 22px;
font-weight: bold;
color: #333;
margin-bottom: 10px;
}
.subtitle {
font-size: 14px;
color: #666;
margin-bottom: 30px;
}
</style>
代码解释:
<template>里写界面结构,用的是原生组件(view、text、button)data()定义数据,这里存了个点击计数器methods定义方法,点击按钮就 +1<style>里的写法比 vue 简化很多,只支持 flex 布局(这也是个坑,后面会讲)
步骤 3:配置路由
在 pages.json 里添加:
{
"pages": [
{
"path": "pages/nvue-demo/nvue-demo",
"style": {
"navigationBarTitleText": "nvue 初体验"
}
}
]
}
运行一下,你会看到页面的渲染速度明显比 vue 快!
6.4.4 关键概念:原生组件和 Weex 限制
nvue 用的是原生组件,不是 H5 组件。这意味着:
❌ nvue 里没有这些 vue 常用组件:
<div>、<span>、<p>这些 HTML 标签<image>用不了,得用<image>组件(注意大小写)<input>、<textarea>有限制
✅ nvue 里用这些原生组件:
<view>- 容器,相当于 div<text>- 文本,相当于 span/p<image>- 图片<scroll-view>- 可滚动区域<list>- 长列表专用组件,比 scroll-view 性能好太多!<cell>- 列表项,专和<list>配合使用<video>、<map>、<canvas>等原生组件
重点:list-view 性能差异
如果你要做长列表(商品列表、聊天记录),一定要用 <list> 而不是 <scroll-view>。性能差距有多大?100 条数据以内看不出区别,1000 条以上,scroll-view 可能卡成幻灯片,list 依然流畅。
<template>
<list class="list">
<cell v-for="item in itemList" :key="item.id" class="list-item">
<text>{{ item.name }}</text>
</cell>
</list>
</template>
<script>
export default {
data() {
return {
itemList: Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
name: `商品 ${i + 1}`
}))
}
}
}
</script>
<style>
.list {
flex: 1;
}
.list-item {
padding: 15px;
border-bottom: 1px solid #eee;
}
</style>
6.4.5 subNVue:弹出层的解决方案
有时候你需要在页面上覆盖一个原生组件(比如弹出层、原生导航栏、侧边栏),用 vue 做的话很费劲,但 subNVue 就是专门干这个的。
类比: subNVue 就像是在视频上叠加一个「弹幕层」——弹幕是独立的,能覆盖视频内容,但又和视频同步。
使用场景:
- 原生下拉刷新
- 原生弹出菜单
- 原生侧滑导航
- 直播弹幕
配置方法:
在 pages.json 里配置 subNVue:
{
"pages": [
{
"path": "pages/index/index",
"style": {
"subNVues": [
{
"id": "popup",
"path": "pages/subNVue/popup",
"type": "popup"
}
]
}
}
]
}
然后在 vue 页面里控制它:
// 打开 popup
const subNVue = uni.getSubNVueById('popup')
subNVue.show('slide-in-bottom', 300)
// 关闭 popup
subNVue.hide('slide-out-bottom', 300)
🔥 实战 35 分钟:3 个递进的小项目
项目 1(5 分钟):仿原生联系人列表
需求: 用 nvue 做一个联系人列表,点击联系人不报错(只是弹 Toast 提示)。
完整代码:
<!-- pages/contacts/contacts.nvue -->
<template>
<view class="page">
<view class="header">
<text class="header-title">通讯录</text>
<text class="header-count">{{ contactList.length }} 位联系人</text>
</view>
<list class="contact-list" @scroll="handleScroll">
<cell v-for="contact in contactList" :key="contact.id" class="contact-item" @click="onContactClick(contact)">
<view class="avatar">
<text class="avatar-text">{{ contact.name.charAt(0) }}</text>
</view>
<view class="info">
<text class="name">{{ contact.name }}</text>
<text class="phone">{{ contact.phone }}</text>
</view>
</cell>
</list>
</view>
</template>
<script>
export default {
data() {
return {
contactList: [
{ id: 1, name: '张三', phone: '13800138001' },
{ id: 2, name: '李四', phone: '13800138002' },
{ id: 3, name: '王五', phone: '13800138003' },
{ id: 4, name: '赵六', phone: '13800138004' }
]
}
},
methods: {
onContactClick(contact) {
uni.showToast({
title: `联系 ${contact.name}`,
icon: 'none'
})
},
handleScroll(e) {
console.log('滚动中', e.contentOffset)
}
}
}
</script>
<style>
.page {
flex: 1;
background-color: #f5f5f5;
}
.header {
padding: 15px;
background-color: #fff;
border-bottom: 1px solid #eee;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.header-count {
font-size: 12px;
color: #999;
margin-top: 5px;
}
.contact-list {
flex: 1;
}
.contact-item {
flex-direction: row;
align-items: center;
padding: 12px 15px;
background-color: #fff;
border-bottom: 1px solid #f0f0f0;
}
.avatar {
width: 45px;
height: 45px;
border-radius: 22px;
background-color: #007AFF;
justify-content: center;
align-items: center;
margin-right: 12px;
}
.avatar-text {
color: #fff;
font-size: 18px;
font-weight: bold;
}
.info {
flex: 1;
}
.name {
font-size: 16px;
color: #333;
}
.phone {
font-size: 13px;
color: #999;
margin-top: 3px;
}
</style>
预期输出: 运行后能看到带头像的联系人列表,点击任意联系人会弹 Toast。
项目 2(15 分钟):从 JSON 读数据的商品展示页
需求: 读取本地 JSON 文件,展示商品列表,每个商品显示图片、名称、价格,点击弹出商品详情(用 subNVue)。
先准备商品数据文件 static/goods.json:
[
{
"id": 1,
"name": "iPhone 15 Pro",
"price": 7999,
"image": "/static/iphone.png",
"desc": "苹果旗舰手机,A17 Pro 芯片"
},
{
"id": 2,
"name": "小米 14 Ultra",
"price": 5999,
"image": "/static/xiaomi.png",
"desc": "徕卡影像,骁龙 8 Gen3"
},
{
"id": 3,
"name": "华为 Mate 60 Pro",
"price": 6999,
"image": "/static/huawei.png",
"desc": "麒麟 9000S,卫星通话"
}
]
nvue 页面代码:
<!-- pages/goods/goods.nvue -->
<template>
<view class="page">
<view class="header">
<text class="header-title">商品列表</text>
</view>
<list class="goods-list">
<cell v-for="goods in goodsList" :key="goods.id" class="goods-item" @click="showDetail(goods)">
<image class="goods-image" :src="goods.image" mode="aspectFill"></image>
<view class="goods-info">
<text class="goods-name">{{ goods.name }}</text>
<text class="goods-desc">{{ goods.desc }}</text>
<text class="goods-price">¥{{ goods.price }}</text>
</view>
</cell>
</list>
</view>
</template>
<script>
export default {
data() {
return {
goodsList: []
}
},
onLoad() {
this.loadGoods()
},
methods: {
loadGoods() {
// 模拟从本地 JSON 读取数据
uni.request({
url: '/static/goods.json',
success: (res) => {
this.goodsList = res.data
},
fail: () => {
// 如果读取失败,用内置数据(演示用)
this.goodsList = [
{ id: 1, name: '商品示例', price: 999, desc: '这是演示数据', image: '' }
]
}
})
},
showDetail(goods) {
uni.showModal({
title: goods.name,
content: `${goods.desc}\n\n价格:¥${goods.price}`,
showCancel: false
})
}
}
}
</script>
<style>
.page {
flex: 1;
background-color: #f5f5f5;
}
.header {
padding: 15px;
background-color: #fff;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.goods-list {
flex: 1;
}
.goods-item {
flex-direction: row;
padding: 10px 15px;
background-color: #fff;
border-bottom: 1px solid #eee;
}
.goods-image {
width: 80px;
height: 80px;
border-radius: 8px;
background-color: #f0f0f0;
margin-right: 12px;
}
.goods-info {
flex: 1;
justify-content: center;
}
.goods-name {
font-size: 16px;
font-weight: bold;
color: #333;
}
.goods-desc {
font-size: 13px;
color: #888;
margin-top: 4px;
}
.goods-price {
font-size: 15px;
color: #ff5000;
font-weight: bold;
margin-top: 6px;
}
</style>
预期输出: 页面加载后显示商品卡片列表,点击任意商品弹出详情弹窗。
项目 3(15 分钟):组合做一个「待办清单小工具」
需求: 用 nvue 做一个待办清单,支持添加任务、标记完成、删除任务,数据存本地。
核心思路:
- 用
<list>渲染任务列表 - 用
uni.setStorageSync做本地持久化 - 点击切换完成状态
<!-- pages/todo/todo.nvue -->
<template>
<view class="page">
<!-- 输入区域 -->
<view class="input-area">
<input class="todo-input" v-model="newTask" placeholder="输入新任务..." @confirm="addTask" />
<button class="add-btn" @click="addTask">添加</button>
</view>
<!-- 任务列表 -->
<list class="todo-list">
<cell v-for="(task, index) in taskList" :key="task.id" class="todo-item">
<view class="todo-content" @click="toggleTask(index)">
<view :class="['checkbox', task.done ? 'checkbox-done' : '']">
<text v-if="task.done" class="check-icon">✓</text>
</view>
<text :class="['todo-text', task.done ? 'todo-done' : '']">{{ task.text }}</text>
</view>
<text class="delete-btn" @click="deleteTask(index)">删除</text>
</cell>
</list>
<!-- 统计 -->
<view class="stats">
<text class="stats-text">共 {{ taskList.length }} 项,已完成 {{ doneCount }} 项</text>
<text class="clear-btn" @click="clearDone">清除已完成</text>
</view>
</view>
</template>
<script>
export default {
data() {
return {
newTask: '',
taskList: [],
nextId: 1
}
},
computed: {
doneCount() {
return this.taskList.filter(t => t.done).length
}
},
onLoad() {
this.loadTasks()
},
methods: {
loadTasks() {
const stored = uni.getStorageSync('my-todos')
if (stored) {
this.taskList = stored.list
this.nextId = stored.nextId
}
},
saveTasks() {
uni.setStorageSync('my-todos', {
list: this.taskList,
nextId: this.nextId
})
},
addTask() {
const text = this.newTask.trim()
if (!text) {
uni.showToast({ title: '请输入任务内容', icon: 'none' })
return
}
this.taskList.unshift({
id: this.nextId++,
text: text,
done: false
})
this.newTask = ''
this.saveTasks()
},
toggleTask(index) {
this.taskList[index].done = !this.taskList[index].done
this.saveTasks()
},
deleteTask(index) {
this.taskList.splice(index, 1)
this.saveTasks()
},
clearDone() {
this.taskList = this.taskList.filter(t => !t.done)
this.saveTasks()
}
}
}
</script>
<style>
.page {
flex: 1;
background-color: #f5f5f5;
}
.input-area {
flex-direction: row;
padding: 15px;
background-color: #fff;
border-bottom: 1px solid #eee;
}
.todo-input {
flex: 1;
height: 40px;
padding: 0 15px;
background-color: #f5f5f5;
border-radius: 20px;
font-size: 14px;
}
.add-btn {
width: 70px;
height: 40px;
margin-left: 10px;
background-color: #007AFF;
border-radius: 20px;
color: #fff;
font-size: 14px;
justify-content: center;
align-items: center;
}
.todo-list {
flex: 1;
}
.todo-item {
flex-direction: row;
align-items: center;
padding: 15px;
background-color: #fff;
border-bottom: 1px solid #f0f0f0;
}
.todo-content {
flex: 1;
flex-direction: row;
align-items: center;
}
.checkbox {
width: 22px;
height: 22px;
border-radius: 11px;
border: 2px solid #ddd;
justify-content: center;
align-items: center;
margin-right: 12px;
}
.checkbox-done {
background-color: #07c160;
border-color: #07c160;
}
.check-icon {
color: #fff;
font-size: 14px;
font-weight: bold;
}
.todo-text {
font-size: 15px;
color: #333;
}
.todo-done {
color: #999;
text-decoration: line-through;
}
.delete-btn {
color: #ff3b30;
font-size: 14px;
padding: 5px 10px;
}
.stats {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
background-color: #fff;
border-top: 1px solid #eee;
}
.stats-text {
font-size: 13px;
color: #666;
}
.clear-btn {
font-size: 13px;
color: #007AFF;
}
</style>
预期输出:
- 输入框输入文字,点击「添加」或按回车,任务出现在列表顶部
- 点击任务左侧圆圈,任务变绿并带删除线
- 点击「删除」直接删掉该任务
- 刷新页面数据依然在(本地存储)
💪 进阶 20 分钟:常见坑 + 性能小贴士
🕳️ 坑 1:nvue 里只能用 flex 布局
❌ 错误写法:
<style>
.box {
display: block;
position: absolute;
left: 10px;
}
</style>
✅ 正确写法:
<style>
.box {
flex-direction: row; /* 或者 column */
}
</style>
nvue 的样式只支持 flex 布局,不支持普通流、position 定位(absolute/fixed 不生效)。如果你要做「相对于父容器居中」,就用 justify-content: center; align-items: center;。
🕳️ 坑 2:文字必须包在 <text> 标签里
❌ 错误写法:
<view>这是纯文本</view>
✅ 正确写法:
<view><text>这是文本</text></view>
nvue 里,所有文字都必须是 <text> 组件的子节点,否则不会显示。
🕳️ 坑 3:图片路径要注意平台差异
❌ 错误写法:
<image src="../../static/logo.png"></image>
✅ 正确写法(使用绝对路径):
<image src="/static/logo.png"></image>
nvue 里图片路径建议使用绝对路径(以 / 开头),相对路径在 App 端可能会出现找不到图片的问题。
🕳️ 坑 4:onLoad 可能在某些场景不触发
nvue 页面的生命周期和 vue 略有不同。如果你的 onLoad 不触发,可以试试把初始化逻辑放到 onShow 里:
export default {
onShow() {
console.log('nvue 页面显示了')
this.loadData()
}
}
🕳️ 坑 5:v-model 在 nvue 里有限制
nvue 不完全支持 v-model,尤其是输入框。上面项目 3 里我们用的是 :value + @input 的组合。如果你要用 v-model,最好测试一下是否正常工作。
⚡ 性能小贴士:用 <list> 而不是 <scroll-view>
这条我们项目里已经用过了,再强调一次:长列表必须用 list 组件。
<!-- ❌ 卡顿警告 -->
<scroll-view scroll-y>
<view v-for="item in list">...</view>
</scroll-view>
<!-- ✅ 流畅体验 -->
<list scroll-y>
<cell v-for="item in list">...</cell>
</list>
🔧 调试技巧:console.log 和 webview 调试
nvue 的调试比 vue 麻烦一点,但有几个方法:
- 直接用 console.log:在 HBuilderX 的控制台能看到输出
- App 端 webview 调试:在手机设置里打开「开发者选项 → USB 调试」,然后在 Chrome 访问
chrome://inspect
✏️ 练习题 + 作业题
练习题(5 道,10 分钟内完成)
练习 1(2 分钟):改个标题
- 输入:把项目 1 的页面标题改成「我的通讯录」
- 预期输出:页面顶部显示「我的通讯录」
- 提示:找到 header-title 对应的 <text> 标签改文字
练习 2(2 分钟):加个判断
- 输入:在项目 1 里,如果联系人数量为 0,显示「暂无联系人」
- 预期输出:没有联系人时,列表区域显示「暂无联系人」文字
- 提示:用 v-if 控制列表显示,配合一个 <text> 标签做空状态提示
练习 3(3 分钟):换个数据源
- 输入:用项目 2 的方法,但读取另一个 JSON 文件(自己创建一个 fruits.json,放几种水果的名字和价格)
- 预期输出:页面显示水果列表,不是商品列表
- 提示:JSON 格式参考项目 2 的 goods.json
练习 4(5 分钟):串起来
- 输入:把项目 2 的商品列表改成「待办事项」(用项目 3 的本地存储思路,但用 nvue + list 实现)
- 预期输出:一个可以用 nvue 渲染的待办清单,数据存本地
- 提示:核心是把项目 3 的 todo 逻辑迁移到 nvue 的 list 结构里
练习 5(3 分钟):报错分析
- 输入:下面这段 nvue 代码运行后报「Cannot read property 'charAt' of undefined」,请分析原因
<text class="avatar-text">{{ contact.name.charAt(0) }}</text>
- 预期输出:说出原因并给出修复方案
- 提示:检查
contact.name是否可能为空,或者数据结构是否符合预期
作业题(30 分钟 - 2 小时)
作业:做一个「nve 原生渲染实战工具」
需求描述:
做一个「图书管理小工具」,用 nvue 原生渲染,实现以下功能:
功能点:
- 图书列表展示:用
<list>展示图书,每本书显示书名、作者、评分 - 添加图书:底部有输入框,可以添加新书(书名、作者必填,评分默认 8.0)
- 删除图书:左滑或点击删除按钮删除图书
- 本地持久化:刷新页面后数据不丢失
加分项:
- 实现「按评分排序」(高的在前)
- 用 subNVue 做一个「添加成功」的轻提示动画
验收标准:
- 能跑起来,不报错
- 添加图书后立即显示在列表
- 关闭再打开 App,数据依然在
- 列表滚动流畅(用
<list>组件)
提交方式: 评论区贴核心代码片段或 GitHub 链接,老粉优先回复!
📚 总结 + 资源
本文学到的 3 个核心点:
- nvue 是基于 Weex 的原生渲染,比 vue 性能好,但只支持 App 端和小程序
- nvue 只能用 flex 布局和原生组件,没有 div/span,需要适应新的写法
- 长列表必须用
<list>+<cell>,这是 nvue 性能优化的关键
延伸学习资源:
- uniapp 官方 nvue 文档 - 最权威的参考资料
- Weex 官方文档 - 深入理解 nvue 底层
- DCloud 社区「nvue 专题」- 很多实战踩坑经验
互动钩子:
「你做过需要高性能的页面吗?是用 nvue 解决的还是用 vue 优化的?评论区聊聊,老粉优先回复!」
📖 上一章我们学了各种小程序平台差异,下一章我们要用 nvue + vue 组合,做一个真正的跨端电商 App实战项目,把前面学的跨平台知识真正用起来!

评论(0)