小程序具有跨平台、体验好、高灵活性以及即用即走、无需下载安装诸多优势。随着微信团队推出微信小程序,国内各大互联网团队也相继推出了各自的小程序,手机厂商也联合推出了快应用。但由于各种原因,各团队的小程序,并没有形成统一的标准或联盟,导致部分平台差异化大,要实现多个平台的小程序就得写多套小程序代码,这给开发者和企业带来不少额外的负担。这种情况下,一个多端统一的解决方案就显得尤为重要。统一多端标准后,让一处代码,多处运行成为了可能,而uniapp作为国内小程序的开创者,也制定了一套基于Vuejs的解决方案,并跻身前列,成为多端构建解决方案的优秀者之一。
快递100
作为中国领先的快递物流信息服务商,在国内各大小程序平台均有发布小程序应用(微信
、百度
、支付宝
、头条
、QQ
、QQ浏览器
、美团
等)。在迭代的过程中,保持多个平台业务和体验一致十分必要。目前,快递100已实现了多端统一。以下分别为微信小程序、头条小程序和百度小程序二维码,你可以扫码(或者在各个客户端搜索快递100
)进行体验。
目前多端构建有不少的解决方案,在比较了多种构建方案后,快递100团队将筛选目标锁定在了Taro
和uniapp
两个解决方案上。关于多端构建解决方案如何选型,我们会考虑以下几个维度:
基于以上的维度结合快递100开发团队的实际情况,经过多轮评审和调研后,我们团队最终选择了uniapp。尽管uniapp已经做得足够优秀,但是在实战开发的过程中依然会遇到这样那样的棘手的问题。本文将从零到一搭建一个简单的应用,解决实战过程中遇到的各种问题,并实现稳定的发布到所有的小程序端。
本文不会介绍uniapp相关API与组件的使用,也不会介绍Vue生态具体的使用方法,相关内容可以参考uniapp官网文档以及Vue官网文档。
文章主要包括以下几个方面的内容:
uni-app支持通过 可视化界面、vue-cli命令行 两种方式快速创建项目。本文将通过以HBuilderX 可视化界面创建项目为例,从零到一搭建起一个完整的项目。安装的方法请查看uniapp官网文档之快速上手
使用uniapp内置组件模板创建后的目录结构如下:
各目录和文件说明如下:
components
: 公共组件目录,默认内置了uniapp的官方扩展组件,应用的公共组件也将放置在该目录下pages
: 业务页面目录,一个页面对应小程序的一个页面路由static
: 存放应用引用静态资源(如图片、视频等)的目录,静态资源只能存放在此目录,该目录下的内容不会经过编译App.vue
: 全局应用文件,用于配置全局样式、全局生命钩子等main.js
: 应用初始化文件,一般全局的扩展均在该文件实现mainfest.json
: 应用配置文件,主要包含各个小程序的appid、版本号等信息pages.json
: 路由配置,主要配置应用的页面路由、外观等uni.scss
: 全局的scss变量文件,在该文件定义的scss变量全局可用安装后通过HBuilderX运行到微信小程序(需要配置微信开发者工具的路径),效果如下:
接下来让我们一步一步来完善整个应用。
在开发的过程中,我们经常需要重复的使用一些方法,如http请求相关的方法,通常我们会将这些方法封装为统一的模块。在这里,我们把公共的方法统一放到项目根目录下的utils
目录下。
用户操作的过程中,经常需要一些操作反馈,如toast,modal等。不同的应用一般都有不同的默认反馈(如文案、字体颜色等),我们首先来封装一下这些方法,相对比较简单。
在utils目录下创建interactiveFeedback.js
文件
(1)toast
第一步,新建showToast
和modal
方法,如下:
// toast反馈
export function showToast(content = '', duration = 1500, icon = 'none') {
uni.showToast({
title: content,
icon: icon,
mask: true,
duration: duration
})
}
// 弹窗反馈
export function modal(content = '', opts = { showCancel: true }) {
return new Promise((resolve, reject) => {
uni.showModal({
title: opts.title || '提示',
content: content,
cancelText: (opts.cancelText || '取消').slice(0, 4),
confirmText: (opts.confirmText || '确定').slice(0, 4),
cancelColor: opts.cancelColor || '#bebebe',
confirmColor: opts.confirmColor || '#317ee7',
showCancel: opts.showCancel !== false,
success: res => {
if (res.confirm) {
resolve()
} else if (res.cancel) {
opts.handleCancel === true && reject()
}
}
})
})
}
在小程序中,
confirmText
和cancelText
长度最长为4个字符,以上modal方法遵循promise规范
第二步,定义install方法
function install(Vue) {
Vue.prototype.$toast = showToast
Vue.prototype.$modal = modal
}
可以根据实际情况定义更多的方法,如
loading
,hideLoading
等
第三步,导出相关的方法
export default { install }
第四步,打开main.js
文件,安装模块
import interactiveFeedback from '@/utils/interactiveFeedback'
Vue.use(interactiveFeedback)
安装后,我们就可以用以下的方式使用:
this.$toast("这是一个吐司提示")
this.$modal("这里是弹窗的提醒内容", {
confirmText: "我知道了",
handleCancel: true
}).then(() => {
console.log("确定了")
}).catch(() => {
console.log('取消了')
})
http模块专门用来做接口请求,通常一个应用中接口都会有公共的参数、请求头、授权信息等,将这些信息统一封装非常有必要。由于项目上经常要用到一些常量等,因此,我们先项目根目录下创建config.js
文件,用于配置项目上使用的一些常量(如请求超时、接口服务器地址、小程序appid等),这里不一一列举,参照项目源码的config.js
文件
接着在utils目录下创建request.js
文件
第一步,引入常量和其他公共方法配置
import store from '@/store'
import { showToast } from './interactiveFeedback.js'
import {
API_BASE,
NETWORK_TIMEOUT,
DEFAULT_ERR_MESSAGE,
PLATFORM,
NETWORK_ERR_MESSAGE
} from '@/config'
第二步,定义两个核心处理方法
/**
* 基本的http请求
* @param {url: String} 请求的链接
* @param {opts.data: Object} 请求的参数
* @param {opts.needAuth: Boolean } 是否需要登录
* @param {opts.handleFail: Boolean} 是否处理错误信息,设置为false将忽略请求时的错误
* @param {opts.accessToken: Boolean} 是否将token覆盖为提供商token
* */
export async function request(url, opts = {}) {
opts.data = opts.data || {}
if (opts.needAuth) {
const userinfo = await store.dispatch('user/getUserInfo') // 获取用户信息
opts.data.openid = store.getters.openid
opts.data.token = store.getters.token
opts.data.unionid = store.getters.unionid
}
return handleRequest(url, opts)
}
/**
* 请求处理
*/
async function handleRequest(url, opts, isUpload) {
opts.data.platform = PLATFORM.platform
opts.data.appId = PLATFORM.appid
let err = null
let res = null
if (isUpload) {
[err, res = {}] = await uni.uploadFile({
url: url,
filePath: opts.file,
name: opts.name,
formData: opts.data
})
} else {
[err, res = {}] = await uni.request({
url: `${API_BASE}${url}`,
header: {
'Content-Type': 'application/x-www-form-urlencoded'
},
method: opts.method || 'POST',
timeout: opts.timeout || NETWORK_TIMEOUT,
data: opts.data || {}
})
}
err && console.warn(`${url}接口错误:`, err)
opts.debug && console.info('接口结果:', res)
return new Promise((resolve, reject) => {
if (err) {
if (opts.handleFail !== false) {
showToast(NETWORK_ERR_MESSAGE)
return reject(res)
}
} else {
// 404, 502等服务器错误信息
if (typeof res.data !== 'object') res.data = { status: res.statusCode, message: DEFAULT_ERR_MESSAGE, data: res.data }
if (res.data.status === '200') {
return resolve(res.data)
} else if (res.data.status === '403' && opts.handleLogin !== false) { // 未登录或者登录失效
showToast('请先登录', 1000)
return uni.navigateTo({
url: '/pages/login/login'
})
} else {
if (opts.handleFail !== false) {
showToast(res.data.message || DEFAULT_ERR_MESSAGE)
}
return reject(res)
}
}
})
}
说明,以上store为基于vuex的数据管理,下文会实现
第三步,实现get
,post
以及upload
方法
export function get(url, opts = {}) {
opts.method = 'GET'
return request(url, opts)
}
export function post(url, opts = {}) {
opts.method = 'POST'
return request(url, opts)
}
/**
* @param {url} 上传地址
* @param {file} 文件路径
* @param {name} 文件的表单名称
* @param {formData} 额外需要提交的字段
* */
export async function upload(url, file, name = 'file', formData = {}) {
if (!file) return
formData.openid = formData.openid || store.getters.openid
if (formData.needAuth) {
const userinfo = await store.dispatch('user/getUserInfo')
formData.openid = userinfo.openid || formData.openid
formData.token = userinfo.token
}
return handleRequest(url, {
file: file,
name: name,
data: formData
}, true)
}
第四步,导出响应的方法:
function install(Vue) {
Vue.prototype.$request = request
Vue.prototype.$get = get
Vue.prototype.$post = post
Vue.prototype.$upload = upload
}
export default { install }
第五步,安装方法
打开main.js
文件,安装对应的方法
import request from '@/utils/request'
Vue.use(request)
做完以上工作后,我们可以像下面的方式在页面上调用http相关的方法
export default {
onLoad(){
this.getOrders()
},
methods: {
getOrders(){
this.$get('orders', {
needAuth: true,
data: {
userid: this.$store.getters.userinfo.id
}
}).then(res => {
console.log(res)
}).catch(res => {
console.warn(res)
})
}
}
}
以上通过Vue.use
安装的方式,在Vue原型上扩展公共的方法,从而达到可以在页面(Vue实例)上调用对应的方法来实现调用接口等功能,如果需要扩展更多的方法和属性,均可以以该种方式进行扩展。
路由拦截几乎是每一个应用都需要用到的功能,比如页面跳转前的登录判断。在Vue框架开发的单页应用中,我们会结合Vuex-Router来实现响应的功能,Vue-Router
提供了丰富的路由守卫,实现路由的拦截十分便利。然而,在小程序上实现路由拦截是相对比较麻烦的。uniapp社区上也有一些解决方案,比如一个完全相似Vue-router的路由插件,该插件重写了uniapp的一些方法以及生命钩子,从而实现了绝大部分的Vue-Router的钩子,有兴趣的可以使用该插件。
实际情况是,我们往往不需要像Vue-Router那样完整的路由拦截,有时我们仅仅只需要登录前的一层简单的校验,这样如果引入一个完整的路由拦截解决方案会显得有点冗余,同时,也会让应用的包更加大(小程序均对单个包有大小的限制)。面对这种情况,我们仅需要做一些简单的扩展即可达到我们的需要。以下,以登录拦截为例,简单的实现一下路由的拦截。我们的需求是,对于需要登录才可以进入的页面,我们先做一下登录校验,如果未登录,则跳转前先跳转至登录页面。
在utils目录下创建extends.js
文件,该文件专门用来对uniapp框架做一些额外的扩展。添加如下代码:
import store from '@/store'
export default function() {
['navigateTo', 'redirectTo', 'switchTab', 'navigateBack'].map(item => {
const nativeFunc = uni[item]
uni[item] = function(opts, needAuth) {
if (needAuth) {
store.dispatch('user/getUserInfo').then((res) => {
nativeFunc.call(this, opts)
})
} else {
return nativeFunc.call(this, opts)
}
}
})
}
代码解读:对于路由相关的几个方法进行遍历并缓存原方法,在uni对象
上重写方法,每个方法增加needAuth
参数,表示跳转前是否需要登录
如果需要登录,则首先获取用户信息,如果获取不到用户信息,将会自动跳转到登录页(store里面的user模块做的),否则获取到信息就调用缓存起来的原方法,实现跳转
如果不需要登录,则直接调用原方法
打开main.js
,引入该文件并执行
import uniExtend from '@/utils/extends'
uniExtend()
通过一个简单的扩展,我们就实现了一个跳转前的登录拦截。除了登录拦截外,针对uniapp上提供原生方法,均可以以该方式进行扩展。
由于小程序的限制,拦截必须调用uniapp上对应的路由路由方法,通过其他途径跳转页面,无法拦截。包括以上社区上提供的拦截方案一样受这个限制
一个集中式的状态管理方案在一个应用中显得尤为重要,而uniapp本身是支持整合Vuex的。因此,我们也在该项目中,也集成了Vuex。
首先,在根目录下创建store
目录,专门用于存放状态管理相关的文件。为了让所有的状态结构更加清晰,更好维护,我们采用vuex的modules来管理各个模块的数据。我们在store目录
下创建modules目录
和index.js文件
,前者用于管理各个数据模块,后者用于导出相关的数据。此时,项目的目录结构如下:
接下来,我们以用户模块为例,来实现一个基于vuex的状态管理器。
打开store/index.js
文件,写入以下代码
import Vue from 'vue'
import Vuex from 'vuex'
const modules = require.context('./modules', true, /.js$/)
let moduleArr = []
modules.keys().map(key => {
moduleArr[key.replace(/(\.\/)|(\.js)/ig, '')] = modules(key).default
})
Vue.use(Vuex)
const store = new Vuex.Store({
modules: {
...moduleArr
}
})
export default store
说明:此处读取modules目录下的所有文件,获取所有的module,这要求module目录下的文件都是导出一个vuex模块。你可以按照实际情况,修改以上代码
接着,打开main.js
文件,添加如下代码:
import store from './store'
const app = new Vue({
store,
...App
})
到这里,已经简单的实现了在应用上使用Vuex。接下来,添加用户用户模块,在modules
目录下创建user.js
文件。通常一个module包含state
,mutations
和actions
。首先我们实现一下state,代码如下:
const KEYS = ['token', 'openid', 'unionid']
const getDefaultState = () => {
const result = {}
KEYS.map(key => {
result[key] = uni.getStorageSync(key) || ''
})
return result
}
const state = getDefaultState()
定义一个state函数,用于设置进入页面时默认的state,我们会优先读取本地缓存里的用户授权信息,每次登录授权后,会将授权信息存入本地缓存。在这里,我们授权的信息包含token,openid和unionid
三个信息(正常应该走小程序的授权等操作,这里仅作数据模拟)。接着实现一下mutations
,代码如下:
const mutations = {
SET_AUTH: (state, info) => { // 设置token等信息
KEYS.map(key => {
if (typeof info[key] !== 'undefined') {
state[key] = info[key]
uni.setStorageSync(key, info[key])
}
})
}
}
在这里,我只定义了一个设置授权信息的mutation,传入授权后的信息,如果对应的授权信息不为undefined
,则将信息设置到state
并且缓存在本地。然后再实现一下actions
。actions负责登录以及用户信息的获取。主要包括登录、登出、获取用户信息和授权信息等,实现分别如下:
如果已经授权过了,则直接返回授权信息,否则的话会跳转至登录页,进行登录
getAuth({ state }) { // 获取用户授权信息
return new Promise(resolve => {
if (state.token) { // 已授权,直接返回
resolve({
openid: state.openid,
token: state.token,
unionid: state.unionid
})
} else { // 未授权,跳转到登录页
uni.navigateTo({
url: `/pages/login/login`
})
}
})
}
login({state, commit, dispatch}, data) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if(data.name === 'test' && data.password === 'test') {
const data = {
token: "testToken",
openid: "testOpenId",
unionid: "testUnionId"
}
commit('SET_AUTH', data)
resolve(data)
} else {
reject({
message: "账号或密码错误",
code: '100401'
})
}
}, 1000)
})
},
logout({ commit, dispatch }) { // 退出登录
commit('SET_AUTH', {
openid: '',
token: '',
unionid: ''
})
return Promise.resolve()
}
以上模拟了一个账号登录,当登录后,返回token等授权信息,并调用commit('SET_AUTH', data)
更新state以及本地缓存;登出时则清空所有的登录授权信息。最后再实现用户信息获取的action:
getUserInfo({state, commit, dispatch}) { // 本地获取用户信息
return new Promise(resolve => {
dispatch('getAuth').then(res => {
resolve({
username: "test",
age: 18,
email: "[email protected]"
})
})
})
}
当获取用户信息时,首先会进行登录授权,授权成功后则模拟返回用户信息。到这里一个简单的与用户相关的vuex模块就完成了。最后就是导出模块供应用使用:
export default {
namespaced: true,
state,
mutations,
actions
}
为了方便页面访问state数据,通常我们还会创建一些getters
。在store
目录下新建文件getters.js
,添加如下代码:
const getters = {
openid: state => state.user.openid,
token: state => state.user.token,
unionid: state => state.user.unionid
}
export default getters
打开store/index.js
,修改代码如下:
import getters from './getters'
const store = new Vuex.Store({
modules: {
...moduleArr
},
getters
})
这样,就可以在页面上使用this.$store.getters.token
以及使用Vuex的mapGetters
方法来访问我们的getters了。
mapGetters
和mapState
等辅助函数均可以使用
到这里,一个简单的数据管理器就算完成了,如果需要添加更多的数据状态,可以按照用户模块的组织方式对数据进行管理。有了数据管理以及数据请求等基础模块,就可以实现具体的业务页面。下面我们以用户登录与信息获取为例,实现一个简单的业务页面。
(1)修改pages/index/index
为如下代码
<template>
<view class="container">
<view>用户名:{{userinfo.username}}view>
<view>年龄:{{userinfo.age}}view>
<view>邮箱:{{userinfo.email}}view>
<button @tap="logout" type="warn" style="margin-top: 30rpx;" v-if="userinfo.username">退出登录button>
<button @tap="getUserinfo" type="primary" style="margin-top: 30rpx;" v-else>获取用户信息button>
view>
template>
export default {
data() {
return {
userinfo: {
username: "",
email: "",
age: ""
}
}
},
methods: {
getUserinfo() {
this.$store.dispatch('user/getUserInfo').then(res => {
this.userinfo = res
})
},
logout() {
this.$store.dispatch('user/logout').then(() =>{
this.userinfo.username = ""
this.userinfo.age = ""
this.userinfo.email = ""
})
}
},
onUnload() {
uni.$off("loginSuccess", this.getUserinfo)
},
onLoad() {
uni.$on("loginSuccess", this.getUserinfo)
}
}
uni.$off
和uni.$on
为uniapp的通信方案,后文会讲述
加上样式后,效果如下:
接着实现登录页面。在pages
目录下新建login/login.vue
文件。添加如下代码:
<template>
<view class="wrap">
<view class="form-item">
<input v-model.trim="name" type="text" placeholder="请输入用户名" class="input">
view>
<view class="form-item">
<input v-model.trim="password" type="password" placeholder="请输入密码" class="input">
view>
<view class="button" @tap="submit">登录view>
view>
template>
export default {
data() {
return {
name: '',
password: ''
}
},
methods: {
valid() {
if (!this.name) {
return this.$toast('请输入用户名')
}
if (!this.password) {
return this.$toast('请输入密码')
}
return true
},
submit: function() {
if (!this.valid()) return
uni.showLoading({
title: "正在登录..."
})
this.$store.dispatch('user/login', {
name: this.name,
password: this.password
}).then(res => {
console.log(res)
uni.$emit("loginSuccess", res)
uni.navigateBack()
}).catch(res => {
this.$toast(res.message)
}).finally(() => {
uni.hideLoading()
})
}
},
onLoad() {
this.$store.dispatch('user/logout')
}
}
逻辑比较简单,进入页面后使用this.$store.dispathc('user/logout')
清空原有的登录状态,登录成功后发布事件loginSuccess
并返回上一页,效果如下:
以上两个页面完成后,完整的操作演示如下:
在uniapp中,数据的通信通常包含组件之间的通信以及页面之间的通信。同样,在小程序中一般也包含这两种通信。在这里我们只讨论页面之间的数据通信(组件之间的通信参照Vue官方的组件通信)。一般情况下,在uniapp中我们有以下的几种方式来实现页面之间的通信:
以上几种方案中,前4中方案在应用变得越来越复杂的时候,会使页面之间的耦合度变得越来越高,从而不利于应用的维护。因此,通常页面之间的数据通信我们会采用后三种方案。比如前面的登录操作的例子中,则是使用了uniapp自带的事件发布订阅通信的方案。其本质上也是一个eventBus。
当然,这不是说前4中方案就不能在应用中使用,某些场景使用前四种方案可能会更简洁、方便、甚至是必须的。比如通过url参数区分渠道,记住某些状态(本地缓存)等。在实际操作的过程中,应该灵活选择、灵活应用。
uniapp的事件发布订阅,请参考官方文档:页面通信
使用的时候需要注意以下几点:
uni.$off
uni.$off
建议传递第二个参数,取消当前页面的订阅,而不是取消所有的订阅。这需要在订阅的时候也传入第二个参数,两个回调应指向同一个引用组件化是Vue的一个核心之一,使用uniapp同样支持自定义组件。但是,自定义组件在各小程序中,由于实现的方案并不一致,所以自定义组件并不总是能完美的运行在各个端的小程序中。这需要在实现的过程做一些特殊的兼容处理。查看uniapp官方的组件库源码,同样也会为各个平台写一些兼容代码。本文不详细介绍如何一步一步的写一个自定义的组件。按照官方组件库的规范,实战中我们通常会遵循以下几个原则:
compoments
目录下,同时,需要放置在额外的目录,方便统一管理,如components/my-component
components
目录下,如pages/login/components/phoneLogin/phoneLogin.vue
my-dialog
components/my-components/my-dialog/my-dialog.vue
uni-
开头1. 使用@import
引入外部样式无效或者报错
uniapp官方给出的引用外部的样式方式为@import url('./common.scss')
,但应用时发现编译报错或者不生效,此时改为@import './common.scss'
即可。
2. placeholder-class不起作用
在小程序中,可以使用placeholder-class
来修改页面的输入框占位符的样色、字体大小等。使用时会发现自定义组件不起作用,页面样式如果加了scoped属性也会不起作用。解决办法为:
placeholder-style
替代App.vue
全局设置,并增加important
关键字(百度小程序无效)textarea的处理方法一样。
3. v-show
不起作用
使用v-show
指令时,某些情况下(如基于数组的length属性来判断),在小程序下不会正常工作,因为小程序的hidden属性是不响应该变化的,可使用v-if
代替
4. $refs
不起作用
uniapp中可以使用$refs
获取页面对应的组件,但是前提是在小程序中组件为自定义组件,原生的组件无法获取。另外,在支付宝小程序中,使用$refs
只能获取到一级的组件引用,嵌套的自定义组件无法获取,但是可以通过签到的$refs
来获取到,如下:
<template>
<popup ref="popup">
<view class="picker">
<region ref="picker" />
view>
popup>
template>
mounted() {
this.popup = this.$refs.popup
// 支持宝不支持嵌套的ref
this.picker = this.$refs.picker || this.popup.$refs.picker
}
5. 包大小过大,无法上传
小程序对包的大小有限制,不同的小程序限制不一样。一般我们会采用分包的方式来处理。但是在开发模式下,由于uniapp编译时会附带sourcemap,导致分包也会很大,从而调试时无法上传到真机进行调试,如QQ小程序。此时,我们在编译时可以选择压缩代码,同时,在小程序开发者工具也选择上传时自动压缩即可。如下:
运行 - 运行到小程序模拟器 - 勾选运行时压缩
详情 - 本地设置 - 勾选上传代码时自动压缩混淆
6. textarea穿透和错位问题
textarea穿透以及滚动的问题属于小程序的问题。textarea属于原生组件,在放置到具有fixed定位的弹窗之类的容器时就会出现这样那样的问题。通常我们有两种解决方案:
多端构建的解决方案让我们写一套代码运行到多个端成为了可能,大大减低了开发者负担,也节省了企业的资源。但由于平台不可避免的差异性,仍然存在不少问题需要开发者自行去解决。另外,快应用目前支持的程度非常弱,uniapp官方已经支持了基于webview渲染的快应用,但是依然存在不少问题,同时,该模式目前仅华为/OPPO/Vivo支持,其他联盟厂商并不支持。基于原生快应用模式的渲染,uniapp计划由社区来实现,目前仍旧没有好的解决方案。而从Taro官方文档来看,Taro的支持应该也比较有限(未测试,欢迎告知测评结果),可能不能用于比较复杂的应用。
无论是Taro也好,uniapp也罢,目前社区都在不断的完善和壮大。相信在国内小程序厂商、快应用联盟以及社区的努力下,多端构建的方案会日趋成熟,并能够形成一套更加统一的、优秀个解决方案。
Github
码云