背景

最近因为项目需要开始接触和学习uni-app。在已有H5和Vue基础上,所以对于一些基础的语法和代码的理解是基本没有问题的。在了解中,发现uni-app真正难的地方,不在“语法”,而在“思维切换”。

它表面上确实还是.vue 单文件组件,也保留了Vue的数据绑定和事件处理方式;但uni-app的核心目标不是单纯做 Web,而是“一套代码跑多端”,它背后是“编译器 + 各端 runtime”的体系:Web、App、各家小程序最终运行环境并不一样,所以很多写法虽然看起来像 Vue,底层却已经不是纯浏览器那套逻辑了。官方也明确提到,uni-app 会把代码编译到不同平台,而不同平台有各自的 runtime,这也是它能跨端的基础。

flowchart TD
A[开发者编写 uni-app 代码<br/>.vue / js / css / ts] --> B[uni-app 编译器]

B --> C[Web 平台产物]
B --> D[各家小程序产物]
B --> E[Android App 平台产物]
B --> F[iOS App 平台产物]

C --> G[Web Runtime]
D --> H[小程序 Runtime]
E --> I[Android App Runtime]
F --> J[iOS App Runtime]

G --> K[最终页面运行效果]
H --> K
I --> K
J --> K

误区

环境区别

一开始我带着Vue经验去看uni-app,很容易产生两个惯性:

第一,默认页面最终都会运行在浏览器环境里。

第二,默认很多问题都能靠DOM思维解决。

但这两个惯性在uni-app里都不完全成立。

官方文档里反复强调,uni-app在小程序端和App端,很多时候是逻辑层和渲染层分离的。也就是说,JavaScript运行的地方和页面渲染的地方不是一个环境。正因为这样,逻辑层里不能天然把window、document、navigator这些浏览器API当成理所当然的存在。官方性能与架构文档都提到,这些浏览器专用API在逻辑层并不适用。

多端差异

“一套代码多端运行”,并不等于“一份代码完全没有差异”。

官方对条件编译的描述很直接:实际项目里,不只是JS,很多代码都可能要按平台条件编译,这样才能真正解决跨端问题。

因此,公共部分尽量复用,平台差异有组织地隔离。

变化

页面标签

在H5开发里,我会很自然地写divspanp

但uni-app更推荐使用它自己的基础组件,比如viewtextbutton等,这些组件是为了多端统一行为而设计的

官方文档也说明,uni-app的基础组件命名整体更接近小程序规范,而不是纯HTML标签体系

关于在一些平台场景下,文本包裹规则和Web并不完全一样,比如app-nvue下只有<text>能包裹文本,<view> 里直接放文字并不可靠

<template>
<view class="page">
<view class="card">
<view class="header-row">
<view class="back-btn" @click="handleClose">
<text class="back-icon"></text>
</view>
<view class="header-text">
<text class="header-title">备忘录详情</text>
<text v-if="updatedAtText" class="header-desc">最近修改:{{ updatedAtText }}</text>
</view>
</view>
<view class="field">
<text class="label">标题</text>
<input
v-model="form.title"
class="input title-input"
type="text"
placeholder="标题标题标题标题"
placeholder-class="placeholder"
/>
</view>
<view class="field">
<text class="label">内容</text>
<view class="content-meta">
<text class="word-count">字数 {{ wordCount }}</text>
<view v-if="contentMode === 'preview'" class="edit-btn" @click="contentMode = 'edit'">
<text>编辑</text>
</view>
</view>
<view v-if="contentMode === 'preview'" class="content-preview-wrap">
<view class="content-preview" @click="contentMode = 'edit'">
<rich-text v-if="contentHtml" :nodes="contentHtml" class="preview-inner"></rich-text>
<view v-else class="preview-empty">
<text class="preview-empty-text">点击添加内容</text>
<text class="preview-empty-hint">支持粗体、标题、列表等格式</text>
</view>
</view>
</view>
<template v-else>
<view class="format-bar">
<view class="format-toolbar">
<view class="fmt-btn" @mousedown.prevent="applyFormat('bold')" @touchstart.prevent="applyFormat('bold')" @click.prevent>
<text class="fmt-icon bold">B</text>
</view>
<view class="fmt-btn" @mousedown.prevent="applyFormat('italic')" @touchstart.prevent="applyFormat('italic')" @click.prevent>
<text class="fmt-icon italic">I</text>
</view>
<view class="fmt-btn" @mousedown.prevent="applyFormat('underline')" @touchstart.prevent="applyFormat('underline')" @click.prevent>
<text class="fmt-icon underline">U</text>
</view>
<view class="fmt-btn" @mousedown.prevent="applyFormat('strike')" @touchstart.prevent="applyFormat('strike')" @click.prevent>
<text class="fmt-icon strike">S</text>
</view>
<view class="fmt-divider"></view>
<view class="fmt-btn" @click.prevent="toggleFormatMenu">
<text class="fmt-icon menu">格式</text>
</view>
</view>
<view v-if="showFormatMenu" class="format-menu">
<view class="menu-item" @mousedown.prevent="applyBlockFormat('title')" @touchstart.prevent="applyBlockFormat('title')" @click.prevent>标题</view>
<view class="menu-item" @mousedown.prevent="applyBlockFormat('subtitle')" @touchstart.prevent="applyBlockFormat('subtitle')" @click.prevent>小标题</view>
<view class="menu-item" @mousedown.prevent="applyBlockFormat('subtitle2')" @touchstart.prevent="applyBlockFormat('subtitle2')" @click.prevent>副标题</view>
<view class="menu-item" @mousedown.prevent="applyBlockFormat('body')" @touchstart.prevent="applyBlockFormat('body')" @click.prevent>正文</view>
<view class="menu-item" @mousedown.prevent="applyBlockFormat('mono')" @touchstart.prevent="applyBlockFormat('mono')" @click.prevent>等宽样式</view>
<view class="menu-divider"></view>
<view class="menu-item" @mousedown.prevent="applyBlockFormat('bullet')" @touchstart.prevent="applyBlockFormat('bullet')" @click.prevent>· 项目符号列表</view>
<view class="menu-item" @mousedown.prevent="applyBlockFormat('dash')" @touchstart.prevent="applyBlockFormat('dash')" @click.prevent>- 短划线列表</view>
<view class="menu-item" @mousedown.prevent="applyBlockFormat('ordered')" @touchstart.prevent="applyBlockFormat('ordered')" @click.prevent>1. 编号列表</view>
<view class="menu-divider"></view>
<view class="menu-item" @mousedown.prevent="applyBlockFormat('quote')" @touchstart.prevent="applyBlockFormat('quote')" @click.prevent>| 块引用</view>
</view>
</view>
<view class="edit-done-btn" @click="contentMode = 'preview'">
<text>完成</text>
</view>
<view class="editor-wrap">
<textarea
id="contentTextarea"
v-model="form.content"
class="input content-input"
placeholder="输入内容,支持 **粗体**、*斜体* 等格式,点「完成」可预览效果"
placeholder-class="placeholder"
@select="onContentSelect"
/>
</view>
</template>
</view>
<view v-if="memoId" class="delete-wrap">
<text class="link delete-link" @click="handleDelete">删除备忘录</text>
</view>
</view>
</view>
</template>

API

uni-app的 js API 由标准 ECMAScript 的 js API 和 uni 扩展 API 这两部分组成。

以前做H5,碰到存储、网络、页面能力,我第一反应通常是:

  • localStorage

  • fetch

  • window.location

  • DOM 事件

但是uni-app更标准的做法,是优先使用uni.*体系,比如uni.request、uni.getStorage等。官方文档提到,这些是 runtime内置的跨端API,目的是在不同平台下尽量保持一致;如果遇到平台独有能力,再通过条件编译或平台专有API去处理。

补充说明:

  • uni.on开头的 API 是监听某个事件发生的API接口,接受一个CALLBACK函数作为参数。当该事件触发时,会调用CALLBACK函数。

  • 如未特殊约定,其他API接口都接受一个OBJECT作为参数。

  • OBJECT中可以指定success,fail,complete来接收接口调用结果。

  • 平台差异说明若无特殊说明,则表示所有平台均支持。

  • 异步API会返回errMsg字段,同步API则不会。比如:getSystemInfoSync 在返回结果中不会有 errMsg

生命周期

生命周期不只是Vue的生命周期,还要加上“页面生命周期”

谈到生命周期,我会自然地想到Vue里onMounted、onUpdated、onUnmounted这些组件生命周期。Vue官方对这些钩子的定义很清楚,比如onMounted更偏向组件挂载完成后的副作用处理,尤其常和DOM访问有关。

但uni-app除了组件生命周期,还有一套更接近小程序语义的“页面生命周期”,比如onLoad、onShow、onReady。官方生命周期文档明确列出了这些页面级生命周期。

这意味着在uni-app里,我不能只用Vue的老经验判断时机,而是要区分:

  • 这是组件级别的问题?

  • 还是页面进入、显示、返回时的问题?

我自己的理解是:

  • Vue生命周期,更多是“组件实例何时创建、更新、销毁”

  • uni-app页面生命周期,更多是“页面何时被打开、展示、初始化参数、回到前台”

页面路由

每次新建页面,都需要在pages.json的pages列表中配置;未在pages.json -> pages中注册的页面,uni-app 会在编译阶段忽略。

pages.json文件用来对uni-app进行全局配置,决定页面文件的路径、窗口样式、原生的导航栏、底部的原生 tabbar 等。

这点让我感受到:uni-app的应用组织方式,不是纯Vue Router思维,而是更接近“小程序应用壳”的思维。

eg.这是我自己demo项目的pages.json的代码

{
"pages": [
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "登录",
"navigationBarBackgroundColor": "#e8dff5",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/memo/index",
"style": {
"navigationBarTitleText": "备忘录",
"navigationBarBackgroundColor": "#e8dff5",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/memo/detail",
"style": {
"navigationBarTitleText": "备忘录详情",
"navigationBarBackgroundColor": "#e8dff5",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/profile/index",
"style": {
"navigationBarTitleText": "我的",
"navigationBarBackgroundColor": "#e8dff5",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/profile/setNickname",
"style": {
"navigationBarTitleText": "设置昵称",
"navigationBarBackgroundColor": "#e8dff5",
"navigationBarTextStyle": "black"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
}
}

easycom组件规范

HBuilderX 2.5.5起支持

easycom 的核心就是“按规范放组件,直接当标签用”,这样在uni-app里写组件会比传统Vue少一步手动注册,更适合日常页面开发。

传统vue组件,需要安装、引用、注册,三个步骤后才能使用组件。easycom将其精简为一步。

只要组件安装在项目的components目录下或uni_modules目录下,并符合components/组件名称/组件名称.(vue|uvue)目录结构(注意:当同时存在vue和uvue时,uni-app项目优先使用vue文件,而uni-app x 项目优先使用 uvue 文件,详情)。就可以不用引用、注册,直接在页面中使用。

接口调用

uni.request()是uni-app提供的跨端统一请求API。

和Vue里常用axios不一样,uni.request()更偏向跨端统一调用,同一套写法可以用于H5、App和小程序。官方文档说明它用于发起网络请求,支持设置url、data、header、method等参数;如果不传success / fail / complete,还可以直接返回Promise。

uni.request()的重点不只是“怎么发请求”,而是要意识到它服务的是多端环境,所以像域名白名单、cookie、请求兼容性这些问题,也比普通Vue项目更值得注意

登陆

在uni-app中,前端先通过uni.login()获取登录凭证,再把凭证发给后端,最后由后端完成用户身份校验并返回自己的登录态,比如token。 官方文档说明,uni.login()是uni-app统一封装的登录API,适用于不同平台的常见登录方式;而登录后的业务请求,一般再通过uni.request()把数据发给服务端处理。

除了前端API,DCloud还提供了uni-id,这是一个云端一体的、完整的、账户开源框架。不仅包括客户端API,还包括前端页面、服务器代码、管理后台等所有与登录账户有关的服务,包括短信验证码、密码加密存储、忘记密码、头像更新等所有常见账户相关功能。

uni.login({
provider: 'weixin', //使用微信登录
success: function (loginRes) {
console.log(loginRes.authResult);
}
});

另外我也关注了一下对于小程序内微信登陆的流程:

值得关注的是,小程序登录不是“前端直接登录成功”,而是“先拿平台凭证,再交给自己后端换成业务登录态”。

小程序登录的本质不是前端直接拿到最终token,而是先通过wx.login()获取临时code,再由后端向微信换取用户标识信息,最后由自己系统生成业务登录态,后续请求都基于这个自定义登录态完成。

注意事项

  1. 会话密钥 session_key 是对用户数据进行加密签名的密钥。为了应用自身的数据安全,开发者服务器不应该把会话密钥下发到小程序,也不应该对外提供这个密钥

  2. 临时登录凭证code只能使用一次