小程序多端适配实践
前言
为减少多端代码维护成本,提升开发体验,实现一套代码适配支付宝小程序、微信小程序,当前小程序框架使用的是Taro,本质上它已经帮助开发者做了很多多端适配的事情,但是由于不同的平台之间还是存在一些无法消除的差异,如编译时和运行时代码需要开发者进行处理。
适配思路和方案
环境变量
用于判断当前的编译平台类型。 取值如:weapp/alipay/h5可以通过这个process.env.TARO_ENV变量来区分不同环境,从而使用不同的逻辑。在编译阶段,会移除不属于当前编译类型的代码,只保留当前编译类型下的代码。
在微信小程序和支付宝端分别引用第三方富文本文件
javascript
// 如mpHtml富文本组件
let mpUrl = '../component/mpHtml/index'
if (process.env.TARO_ENV === 'alipay') {
mpUrl = '../component/mpAlipay/index'
}
export default {
navigationBarTitleText: 'mpHtml多端适配例子',
usingComponents: {
// 定义需要引入的第三方组件
// 1. key 值指定第三方组件名字,以小写开头
// 2. value 值指定第三方组件 js 文件的相对路径
'mp-html': mpUrl,
},
}
决定不同端要加载的组件
JavaScript
{process.env.TARO_ENV === 'weapp' && <View>这是在微信上运行的组件<View>}
{process.env.TARO_ENV === 'alipay' && <View>这是在支付宝上运行的组件<View>}
组件文件中跨平台支持
为了方便书写样式跨端的组件代码,目前在.sass或.less文件中支持条件编译的特性。
例如,希望某段模板内容只在 支付宝小程序中 生效,可以这么写:
SCSS
/* #ifdef alipay /
.taroify-tabs__tab { min-width: 24%; }
/ #endif */
多端组件
假如有一个 Test
组件存在微信小程序、支付宝小程序两个不同版本,那么就可以像如下组织代码:
├── test.js Test 组件默认的形式,编译到微信小程序支付宝小程序之外的端使用的版本
├── test.weapp.js Test 组件的微信小程序版本
├── test.alipay.js Test 组件的支付宝小程序版本
三个文件,对外暴露的是统一的接口,它们接受一致的参数,只是内部有针对各自平台的代码实现。
而使用 Test
组件的时候,引用的方式依然和之前保持一致。import
的是不带端类型的文件名,在编译的时候Taro会自动识别并添加端类型后缀:
javascript
import Test from '../../components/test'
<Test argA={1} argA={2} />
多端页面路由
可以根据不同平台,设置不同的路由规则。例如
javascript
// app.config.js
let pages = []
if (process.env.TARO_ENV === 'weapp') {
pages = ['/pages/index/index']
}
if (process.env.TARO_ENV === 'alipay') {
pages = ['/pages/indexAlipay/indexAlipay']
}
export default {
pages,
}
处理编译后的小程序原生文件
如果你不想在业务代码里增加额外的代码,可以开发一个自定义的webpack plugin,原理是改变编译后的小程序原生代码(如wxml、wxss、js、json为后缀名的文件),实现对原生小程序代码文件增删改。例如:
javascript
// 删除支付宝端暂时不需要的pages模块
const path = require('path')
const fs = require('fs')
class AdapterAlipayWebpackPluginForPackage {
constructor(options) {
this.options = options
}
deleteFolderRecursive(folderPath) {
if (fs.existsSync(folderPath)) {
fs.readdirSync(folderPath).forEach((file) => {
const curPath = path.join(folderPath, file)
if (fs.lstatSync(curPath).isDirectory()) {
this.deleteFolderRecursive(curPath)
} else {
fs.unlinkSync(curPath)
}
})
fs.rmdirSync(folderPath)
}
}
apply(compiler) {
compiler.hooks.done.tap('AdapterAlipayWebpackPluginForPackage', (stats) => {
if (this.options.env === 'alipay') {
const outputPath = stats.compilation.outputOptions.path // 输出目录路径
// 删除文件夹
this.options.confUnnecessaryModulesForAlipay.forEach((folder) => {
let folderPath = path.resolve(outputPath, 'pages', folder.split('/')[1])
this.deleteFolderRecursive(folderPath)
})
}
})
}
}
module.exports = AdapterAlipayWebpackPluginForPackage
支付宝支付
业务架构图
需要先在支付宝管理后台完成开发设置 、产品绑定 和绑定商家账号 ,后续如果需要迭代维护,开发者只需要关心用户授权、获取交易号 、唤起收银台 和判断支付是否成功这四步。
前端逻辑
通过用户授权获取
比如用户点击页面上某个支付按钮,用户端有授权弹窗
-
前端通过 my.getAuthCode 方法获取到用户的授权 authCode;
-
将 authCode 传到后端通过 alipay.system.oauth.token 接口获取 access_token 参数;
-
后端用 access_token 参数传入alipay.user.info.share 接口中获取到 user_id(openid);
javascript
my.getAuthCode({
scopes: 'auth_user',
success: (res) => {
const authCode = res.authCode
const params = {
code: authCode,
clientType: 'alipay_mini_app',
appId: 'xxx'
}
// 把params这些参数传给后端
},
fail: (err) => {
console.log('my.getAuthCode 调用失败', err)
},
})
获取交易号
user_id信息获取到之后,将订单id通过后端提供的接口传给服务端
JavaScript
// 例子
my.request({
url: '商家服务端地址',// 须加httpRequest域白名单
method: 'POST',
data: {
//data里的key、value是开发者自定义的
payClient: '1', // 支付宝
paymentType: '2', // 小程序支付
payChannel: '1', // 移动端
orderId: 'xxx',//订单id
},
dataType: 'json',
success: function(res) {
// 拿到 trade_no(支付宝交易号)
},
fail: function(res) {
//;
}
});
唤起收银台
拿到 trade_no(支付宝交易号),通过支付宝官方提供的接口my.tradePay唤起收银台
javascript
my.tradePay({
// 传入支付宝交易号trade_no,唤起收银台
tradeNO: trade_no,
success: (res) => {
// 成功回调
},
fail: (res) => {
// 失败回调
}
});
判断支付是否成功回调
javascript
// 因支付结果是异步的,时间取决于银行,最好是通过后端提供的接口查询是否已支付成功
if (res.resultCode === '9000') {
// 订单处理成功
resolve({ ...res })
} else if (res.resultCode === '8000' || res.resultCode === '6004') {
// 6004 处理结果未知(有可能已经成功)
// 8000 正在处理中。支付结果未知(有可能已经支付成功)
resolve(res)
} else {
reject(res)
showToast({
title: "支付失败" + res.message, // 支付失败
icon: 'none',
duration: 2000,
})
}
支付宝小程序登录
业务架构图
前端逻辑
小程序获取authCode
可以在登录页面初始化时获取,会弹出授权弹窗
javascript
my.getAuthCode({
scopes: 'auth_user',
success: (res) => {
const authCode = res.authCode
const params = {
code: authCode,
clientType: 'alipay_mini_app',// 客户端标识
appId: "支付宝小程序的appId"
}
},
fail: (err) => {
console.log('my.getAuthCode 调用失败', err)
},
})
带上authCode,调用服务端api进行授权认证,生成ALI_USER_ID
javascriptreact
// 后端接口:/user/...
my.request({
url: 'xxx', // 后端api地址,实现的功能是服务端拿到authCode去开放平台进行token验证
data: {
code: authCode,
clientType: 'alipay_mini_app',// 客户端标识
appId: "支付宝小程序的appId"
},
success: (res) => {
// 拿到后端返回的ALI_USER_ID
const ALI_USER_ID = res.data.ALI_USER_ID
},
fail: () => {
// 根据自己的业务场景来进行错误处理
},
});
前端声明授权登录组件
javascript
<Button
block
loading={loading}
color="primary"
shape="round"
size="large"
open-type="getAuthorize"
scope="phoneNumber"
onGetAuthorize={onGetPhoneNumber}
onError={handleAuthError}
>
<Text>一键授权登录</Text>
</Button>
用户信息(手机号)授权
JavaScript
my.getPhoneNumber({
success: (res) => {
const encryptedData = res.response
// 带上encryptedData加密字符串调用后端auth接口
},
fail: (err) => {
console.log('授权失败', err)
},
})
}
调用后端auth接口,生成accountId和code
JavaScript
// 后端接口:/user/auth
// 前端请求参数
const param = {
encryptedData,
openId: ALI_USER_ID,
appId:"xxxxxxx",
clientType: 'alipay_mini_app',
}
带上accountId和code,请求服务端获取access_token
javascript
// 后端接口:/user/oauth/token
// 前端请求参数
const params = {
accountId: "xxxx",
code: "xxxx",
grant_type: "alipay_mini_app",
scope: "server"
}
// 接口成功返回后,表示登录已成功
{
"code": 1,
"message": "操作成功",
"traceId": "xxxxx",
"requestId": null,
"data": {
"access_token": "xxxxx",
"token_type": "bearer",
"refresh_token": "xxxxx",
"expires_in": 11119,
"scope": "server",
"license": "",
"user_info": {
"username": "xxxxx",
"openId": "xxx",
"unionId": null,
"phone": "xxx",
"userType": "",
"identityId": null,
"realName": null,
"engName": null,
"nickname": null,
"gender": null,
"birthday": null
},
"clientId": "alipay_mini_app"
}
}
webpack plugin适配
原理
自定义plugin本质是 JavaScript 类,该类需要实现 apply
方法,这个方法将会在安装插件时被 webpack 调用。在 apply
方法中,可以使用 webpack 提供的各种钩子函数来注册自定义逻辑,这些钩子函数代表了 webpack 在打包过程中的不同阶段。
在注册的钩子函数中,可以编写自定义的逻辑代码来实现想要的功能,例如对代码进行注入、修改、删除等。
当自定义plugin开发完成之后,使用 webpack 的插件 API 将自定义插件添加到 webpack 的chain链式调用中。这样,在 webpack 运行过程中,就会按照插件的注册顺序执行相应的逻辑。
背景
Taro对多端的适配能力有限,除了Taro官方提供的多端适配方案,我自研了plugin的方案。利用webpack提供的编译时事件流机制,对输出的小程序原生文件进行修改。
例如微信小程序和支付宝小程序的应用配置文件app.json的配置项会略显不同,子包命名在微信小程序下是小写subpackages,在支付宝小程序下是驼峰subPackages,又比如window配置项下面的很多配置微信和支付宝很多字段不一样。下面就这些情况对app.json配置文件做适配。
好处
-
减少多套代码维护,开发者只需关心需求迭代上业务代码编写,多端适配交给webpack或者工具自动化来做;
-
因为plugin做的事情只是修改了编译后的小程序原生文件,而且这部分是通过Taro编译生成的,避免每次修改本地配置文件生成新git修改记录,对开发者来说,修改多端适配上的配置文件应该是无感知;
-
对上层业务代码零侵入,通过自定义webpack plugin写好的脚本,对编译后的文件进行自动化修改。
用法
自定义plugin
javascript
// 修改app.json文件
class AdapterAlipayWebpackPlugin {
constructor(options) {
this.options = options
}
apply(compiler) {
compiler.hooks.emit.tap('AdapterAlipayWebpackPlugin', (compilation) => {
if (this.options.env === 'alipay') {
Object.keys(compilation.assets).forEach((fileName) => {
if (fileName.endsWith('app.json')) {
let asset = compilation.assets[fileName]
let source = asset.source()
source = source.replaceAll('subpackages', 'subPackages')
let appJsonObject = JSON.parse(source)
delete appJsonObject.window['navigationBarBackgroundColor']
delete appJsonObject.window['backgroundTextStyle']
delete appJsonObject.window['navigationBarTextStyle']
appJsonObject.window['titleBarColor'] = '#FFFFFF' // 导航栏背景色
appJsonObject.window['navigationBarFrontColor'] = 'black' // 导航栏前景色,只支持配置黑色或者白色。
source = JSON.stringify(appJsonObject, null, 2)
compilation.assets[fileName] = {
source() {
return source
},
size() {
return source.length
},
}
}
})
}
})
}
}
module.exports = AdapterAlipayWebpackPlugin
注册plugin
javascript
const AdapterAlipayWebpackPlugin = require('./AdapterAlipayWebpackPlugin');
const config = {
// ...
// 自定义 Webpack 配置
webpackChain(chain, webpack) {
chain.plugin('AdapterAlipayWebpackPlugin').use(AdapterAlipayWebpackPlugin, [
{
env: process.env.TARO_ENV, // 小程序环境变量
},
])
}
}
}
module.export = config
组件适配
原因
这属于运行时的范畴,本人有考虑过用编译时webpack plugin的方案,但效果不好,需要对组件的各种属性进行babel解析、转换、判断等操作,难点在于这些组件的属性并不是固定的,开发者后续维护属性的增删改会变得难以理解和维护,所以使用环境变量process.env.TARO_ENV在业务代码层做适配。
登录组件
javascript
<>
{process.env.TARO_ENV === 'wechat' &&
<Button
block
loading={loading}
color="primary"
shape="round"
openType="getPhoneNumber"
onGetPhoneNumber={onGetPhoneNumber}
size="large"
>
{/* 一键授权登录 */}
<Text>一键授权登录</Text>
</Button>
}
{process.env.TARO_ENV === 'alipay' &&
<Button
block
loading={loading}
color="primary"
shape="round"
size="large"
open-type="getAuthorize"
scope="phoneNumber"
onGetAuthorize={onGetPhoneNumber}
onError={handleAuthError}
>
<Text>一键授权登录</Text>
</Button>}
</>
JS代码运行时适配
基本思路还是用环境变量process.env.TARO_ENV,把微信和支付的请求参数、请求的jsapi、错误异常统一处理都封装在payment.js文件中,对外暴露接口。
支付例子
javascript
import Taro from '@tarojs/taro'
export function adapterRequestPayment(params) {
return new Promise((resolve, reject) => {
if (process.env.TARO_ENV === 'alipay') {
const { aliTradeNo } = params
my.tradePay({
// 调用统一收单交易创建接口(alipay.trade.create),获得返回字段支付宝交易号trade_no
tradeNO: aliTradeNo,
success: (res) => {
if (res.resultCode === '9000') {
// 订单处理成功
resolve({ ...res, payFlag: 'success' })
} else if (res.resultCode === '8000' || res.resultCode === '6004') {
// 6004 处理结果未知(有可能已经成功)
// 8000 正在处理中。支付结果未知(有可能已经支付成功)
resolve(res)
} else {
reject(res)
}
},
fail: (err) => {
reject(err)
},
})
} else {
Taro.requestPayment({
timeStamp,
nonceStr,
package,
paySign,
signType,
success: (res) => {
resolve(res)
},
fail: (err) => {
reject(err)
},
})
}
})
}
小程序打包体积优化
为什么
第一次打测试包上传后发现包过大,支付宝对小程序包大小限制,导致上传不了支付宝小程序服务器
打包体积分析
利用小程序开发者工具提供的包依赖分析能力
方案
暂时删除在支付宝端无需上线的功能模块,删除编译后的小程序原生文件,从而减少整包的体积。想法很简单,就是利用webpack删除编译后的pages下面的小程序原生文件,难点在于递归遍历要删除的文件路径,需要考虑边界,同时也需要把app.json配置文件里对应的文件删除,不然会报文件不存在的错。
配置需要删除的文件
见项目根目录下config/envConf.js
javascript
// 因上传包大小限制,删除支付宝不需要的模块
const confUnnecessaryModulesForAlipay = [
'pages/...',
]
module.exports = {
confUnnecessaryModulesForAlipay
}
开发plugin
javascript
// 删除支付宝端暂时不需要的pages模块
const path = require('path')
const fs = require('fs')
class AdapterAlipayWebpackPluginForPackage {
constructor(options) {
this.options = options
}
deleteFolderRecursive(folderPath) {
if (fs.existsSync(folderPath)) {
fs.readdirSync(folderPath).forEach((file) => {
const curPath = path.join(folderPath, file)
if (fs.lstatSync(curPath).isDirectory()) {
this.deleteFolderRecursive(curPath)
} else {
fs.unlinkSync(curPath)
}
})
fs.rmdirSync(folderPath)
}
}
apply(compiler) {
compiler.hooks.done.tap('AdapterAlipayWebpackPluginForPackage', (stats) => {
if (this.options.env === 'alipay') {
const outputPath = stats.compilation.outputOptions.path // 输出目录路径
// 删除文件夹
this.options.confUnnecessaryModulesForAlipay.forEach((folder) => {
let folderPath = ''
// 支持删除pages/大模块/子模块/...这样的文件
if (folder.indexOf('文件名/') > -1) {
folderPath = path.resolve(outputPath, 'pages', 'pages下面的某文件名', folder.split('/')[0])
} else {
folderPath = path.resolve(outputPath, 'pages', folder.split('/')[1])
}
this.deleteFolderRecursive(folderPath)
})
}
})
}
}
module.exports = AdapterAlipayWebpackPluginForPackage
修改app.json
javascript
function deleteUnnecessaryModulesForAlipay(arr, source) {
source.subPackages = source.subPackages.filter((item) => {
// 如果需要特别处理
if (item.pagesPath === 'pages/...') {
item.pages = item.pages.filter((p) => !arr.includes(p))
return true
} else {
return !arr.includes(item.pagesPath)
}
})
return source
}
修改打包配置
找到webpack配置文件,如在config/index.js:
javascript
const AdapterAlipayWebpackPluginForPackage = require('./AdapterAlipayWebpackPluginForPackage')
const { confUnnecessaryModulesForAlipay } = require('./envConf')
const config = {
// ...
mini: {
// 自定义 Webpack 配置
webpackChain(chain, webpack) {
chain
.plugin('AdapterAlipayWebpackPluginForPackage')
.use(AdapterAlipayWebpackPluginForPackage, [
{ env: process.env.TARO_ENV, confUnnecessaryModulesForAlipay },
])
}
},
}
module.esport = config
注入全局变量
痛点
因历史代码原因,小程序的请求头应用主体标识appKey是写死在代码里,这种方式不好的点是当每次切换小程序主体时,开发者都需要在对应文件修改请求头,虽然gulp工具也能处理这种情况,但还是会对业务代码进行浸入式更改,而且每次都会生成几条新的git记录(虽然这个是不必要上传的)
解决方案
方案很简单,就是利用webpack提供的defineConstants机制,注入appKey全局变量。
配置项目全局变量
config/envConf.js:
javascript
// 项目全局变量配置
const requstHeaderForAppKey = {
alipay: 'alipay', // 支付宝
weapp: 'weapp', // 微信
}
module.exports = {
requstHeaderForAppKey
}
修改webpack,config/index.js配置文件,增加defineConstants配置项
javascript
const config = {
defineConstants: {
// 接口请求头
__requstHeaderForAppKey: JSON.stringify(requstHeaderForAppKey[process.env.TARO_ENV]),
},
}
修改业务代码
javascript
payload: { appKey: __requstHeaderForAppKey }
项目配置文件
在 Taro 小程序的根项目中,project.config.json
是项目的配置文件,用于指定小程序的相关配置信息,用于设置开发者工具的默认行为。
如果是微信小程序默认使用project.config.json,如果是支付宝小程序,用的project.alipay.json,该配置文件开发者只用关注里面的配置项,Taro会根据运行环境自动处理。
报错难点问题处理
useDebounceFn在支付宝下报TypeError: Cannot read property 'now' of undefine
方案
路径src/adapter/lodashFix这个文件是处理ahooks的api:useDebounceFn在支付宝小程序下报Date.now错
javascript
import { validCurrentEnv } from './runtimeEnv'
/**
* 修复支付宝小程序中 lodash/ahooks 的运行环境
*/
export function initLodashRuntime() {
const env = validCurrentEnv()
if (env === 'alipay') {
if (global) {
global.Date = Date
}
}
}
// 在app.js入口文件初始化
initLodashRuntime()
TypeError: Function(...) is not a function
原因
项目中的dva库依赖的@babel/runtime版本是7.23,而@babel/runtime@7.23版本所依赖的regenerator-runtime的版本高于 0.11 版本的话它内部实现使用了 Function() 构造函数,在支付宝小程序环境里不被支持。dva在19年之后就不更新了,导致编译到支付宝报错:TypeError: Function(...) is not a function at Object.node_modulesDvaCoreNode_modulesBabelRuntimeRegeneratorIndexJs
方案
修改yarn.lock中dva-core和dva-loading对应的@babel/runtime版本,固定为7.7.7版本,然后在webpack配置文件里引入该插件,删除依赖node_modules,重新yarn install安装
javascript
const fs = require('fs')
class AdapterAlipayYarnLockPlugin {
constructor(options) {
this.options = options
}
apply(compiler) {
compiler.hooks.beforeCompile.tapAsync('AdapterAlipayYarnLockPlugin', (params, callback) => {
if (this.options.env === 'alipay') {
// 读取yarn.lock文件内容
const yarnLockContent = fs.readFileSync('./yarn.lock', 'utf-8')
// 替换目标字符串
const modifiedContent = yarnLockContent.replaceAll(
'"@babel/runtime" "^7.0.0"',
'"@babel/runtime" "7.7.7"',
)
// 将修改后的内容写回到yarn.lock文件
fs.writeFileSync('./yarn.lock', modifiedContent, 'utf-8')
// 完成插件逻辑
callback()
}
})
}
}
module.exports = AdapterAlipayYarnLockPlugin
启用小程序基础库2.0构建,1.x有些api在最新版本的支付宝不支持
方案
项目根目录下配置config.alipay.json文件{
json
{
"format": 2, // 启用基础库2.0构建
"compileOptions": {
"component2": true
}
}
const { userName, cardNo } = user,如果user为undefined,在支付宝下报错
方案
const { userName, cardNo } = user || {}
input组件在支付宝下不支持onClick事件
方案
在input的父节点绑定事件
支付宝小程序PickerView在popup组件中无法滚动
原因
支付宝小程序对picker-view二次渲染的处理有问题
方案
popup隐藏的时候把picker-view也销毁掉,开启的时候再重新渲染
支付宝下在当前页面同时设置frontColor和backgroundColor一起设置导航栏标题颜色和图标才会生效
Taro.login不支持支付宝
原因
支付宝小程序的登录接口与微信小程序的登录接口是不同的。
方案
在支付宝小程序中,通过my.getAuthCode()方法获取用户的登录授权码
支付宝小程序获取手机号登录报错:应用内无授权 方案
用户信息未申请: 获取会员手机号,需要到支付宝后台设置小程序的主营类目
mp-html不支持npm依赖包的方式引入
原因
mp-html本身是基于小程序原生开发的文件,需要引入各平台的mp-html
方案
javascript
let mpUrl = '../component/mpHtml/index'
if (process.env.TARO_ENV === 'alipay') {
mpUrl = '../component/mpAlipay/index'
}
export default {
navigationBarTitleText: '文章',
usingComponents: {
// 定义需要引入的第三方组件
// 1. key 值指定第三方组件名字,以小写开头
// 2. value 值指定第三方组件 js 文件的相对路径
'mp-html': mpUrl,
},
}