背景 最近因为项目需要开始接触和学习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开发里,我会很自然地写div、span、p
但uni-app更推荐使用它自己的基础组件,比如view、text、button等,这些组件是为了多端统一行为而设计的
官方文档也说明,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的老经验判断时机,而是要区分:
这是组件级别的问题?
还是页面进入、显示、返回时的问题?
我自己的理解是:
页面路由 每次新建页面,都需要在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,再由后端向微信换取用户标识信息,最后由自己系统生成业务登录态,后续请求都基于这个自定义登录态完成。
注意事项
会话密钥 session_key 是对用户数据进行加密签名 的密钥。为了应用自身的数据安全,开发者服务器不应该把会话密钥下发到小程序,也不应该对外提供这个密钥 。
临时登录凭证code只能使用一次