1. 前言
使用 Taro 开发有一段时间了,但之前一直只是基于 Taro 使用 React 快速开发微信小程序,并没有考虑到其他端的运行情况。
最近有一个需求,为增大访问流量,需要把微信小程序端的某个子模块输出到 H5 端,并以 Hybrid 的形式嵌入到其他小程序和 APP 上。
本文主要总结如何使用 Taro 兼容运行H5与微信小程序,以便日后兼容其他端有法可依。
2. 方案分析
最开始跟小伙伴讨论过一个方案是,打算另起一个 H5 项目,并改造原来的微信小程序,使用这个 H5 项目替代原来的子模块。
这个方案的缺点:
- 微信小程序需要改造。
- 以后该模块要在字节 APP、百度 APP 中运行,必须先创建一个壳子小程序,而不能直接编译输出字节小程序、百度小程序。
- 后续要输出 RN 模块,又得改造一番。
考虑后续其他模块也可能需要处理为 h5,所以,基于 Taro 编译输出 h5 是目前最好的解决方案,在用户体验不差的情况下,「Write Once, Run Everywhere」 给予开发者极大的开发体验,极大的节省公司的开发成本。
当然使用 Taro 进行多端适配,要求也高,需要考虑一处编写,多端填坑的情况。
3. 多端开发策略
多端开发要解决的核心问题有两个:
- 代码转换:使代码可以在不同平台上运行。
- 运行时适配:使代码在不同平台上有相同表现。
Taro 编译让代码在各个平台上能够运行起来,就是对输入的源代码进行语法分析,语法树构建,随后对语法树进行转换操作再解析生成目标代码的过程。
但是纯靠编译是不行的,这是因为小程序和 Web 端上组件标准与 API 标准有很大差异,这些差异仅仅通过代码编译手段是无法抹平的。
例如你不能直接在编译时将小程序的 <view/>
直接编译成 <div/>
,因为他们虽然看上去有些类似,但是他们的组件属性有很大不同的。比如 hover-start-time
、hover-stay-time
等属性在常规 Web 开发中并不存在。
针对这样的情况,Taro 采用了定制一套运行时标准来抹平不同平台之间的差异。这一套标准主要以三个部分组成,包括标准运行时框架、标准基础组件库、标准端能力 API。
其中运行时框架和端能力 API 对应 @taro/taro
,组件库对应 @tarojs/components
,通过在不同端实现这些标准,从而实现差异化。
- 基础框架(生命周期、组件 API):以 React 的生命周期、组件 api 为基础,小程序的特性 作为补充。
- 标准组件库(View、Button):以微信小程序组件为标准,各端模拟实现
- 标准API(request、setState):扩展的小程序标准 Api,隔断模拟实现。
即使 Taro 做了编译时转换、运行时适配,还是无法完全满足开发者的需求,比如一些特殊的端能力、端插件、端组件,这些都需要开发者自行抹平差异,还有不同平台显示的页面可能不同,也需要差异化的配置。
比如一些 API 是 Web 端无论如何无法实现的,wx.login
,又或者wx.scanCode
,虽然可以通过 Taro.xxx 获取 api 方式,但是 Taro 本身没有做 h5 的兼容处理。
Taro 维护了一个 API 实现情况的列表,在开发中应该尽可能避免使用它们。
3.1 多端同步调试
多端开发,需要编译多个版本,这里输出 H5 和微信小程序版本。
Taro 提供了内置环境变量 process.env.Taro_ENV
来区分不同编译环境:weapp / swan / alipay / tt / qq / jd / h5 / rn
首先在 config/index.js
文件中改写输出配置:
// before 只输出微信小程序
outputRoot: `dist`
// after 输出多份目标代码
outputRoot: `dist/${process.env.TARO_ENV}`
然后打开两个命令行终端,分别运行 npm run dev:h5
和 npm run dev:weapp
打包命令
"scripts": {
"dev:h5": "taro build --type h5 --watch",
"dev:weapp": "taro build --type weapp --watch",
},
因为基于现有项目改造,最初都无法成功编译运行 H5 页面,大量的报错,只能先把一些适配小程序端的能力注释掉,比如:
- 插件:异常上报插件、语音识别
- 组件:地图组件、画布 Canvas、movableView 等
- 埋点上报:神策sdk
- api:获取图片信息、图片识别编辑相关的 canvas 元素获取
最终在 dist 目录输出以下文件,并成功编译运行 h5 页面:
dist
- h5
- weapp # 这里打开微信小程序开发者工具,需要定位到该文件夹
3.1.1 h5 跨域问题
如何解决本地开发 h5 跨域问题呢?在 Taro 项目中可以设置代理服务器:
// config/index.js
const config = {
h5: {
devServer: {
port: 10086,
proxy: [
{
context: ['/freight-retail', '/restApi'],
target: 'https://jecyu.sit.com', // 服务端地址
changeOrigin: true
}
]
}
}
}
然后在请求接口路径或静态资源路径,更改请求前缀为:本地电脑 ip 地址,比如 http://xxx:10086/
,即可解决跨域问题。
业务接口有测试环境域名和生产环境域名两种,比如:
虽然 Taro 针对 npm run build 命令
和 npm run build --watch
时分别赋予 NODE_ENV
production
和 development
,但无法区分三种构建环境:
- 本地开发环境
- 线上测试环境
- 线上生产环境
这里我新增一个 DEPLOY_ENV
环境变量,取值为:local/sit/prod,分别创建 local
、sit
、prod
配置文件。
// config/local.js
module.exports = {
env: {
DEPLOY_ENV: '"local"', // sit、prod 配置类似
},
defineConstants: {},
mini: {},
h5: {}
}
然后在 config/index.js 入口文件这样处理:
const config = { /**/};
module.exports = function (merge) {
switch(process.env.DEPLOY_ENV) {
case 'local':
return merge({}, config, require('./local'));
case 'sit':
return merge({}, config, require('./sit'));
case 'prod':
return merge({}, config, require('./prod'));
default:
return merge({}, config, require('./local'));
}
}
最后更改打包命令:
{
"build:weapp": "cross-env DEPLOY_ENV=\"prod\" taro build --type weapp",
"buildSit:weapp": "cross-env DEPLOY_ENV=\"sit\" taro build --type weapp",
"dev:h5": "cross-env DEPLOY_ENV=\"local\" taro build --type h5 --watch",
}
经过上面的配置后,在请求路径就可以更改为这样:
export function isH5() {
return process.env.TARO_ENV === 'h5';
}
export const isDev = () => ['local', 'sit'].includes(process.env.DEPLOY_ENV || "");
export const isLocal = () => ['local'].includes(process.env.DEPLOY_ENV || "");
let host = 'https://jecyu.com';
if (isDev()) {
host = 'https://jecyu.sit.com';
}
if (isDev() && isH5()) {
// 本地 ip 路径
host = 'http://xxx:10086';
}
除了手工更改外,还可以把个人电脑 ip 注入为常量,这样就不用每次都手动改了。
// config/index.js
const path = require('path')
const os = require('os');
function getNetworkIp() {
let needHost = ''; // 打开的host
try {
// 获得网络接口列表
let network = os.networkInterfaces();
for (let dev in network) {
let iface = network[dev];
for (let i = 0; i < iface.length; i++) {
let alias = iface[i];
if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal && !needHost) {
needHost = alias.address;
}
}
}
} catch (e) {
needHost = 'localhost';
}
return needHost;
}
const config = {
// ...
defineConstants: { // 注入常量
LOCAL_IP: JSON.stringify(getNetworkIp())
},
}
3.1.2 h5 添加 vconsole
在 h5 环境下,添加 vconsole 查看网络日志和控制台很有必要。
// app.ts
if (isDev() &&isH5()) {
const vConsole = new VConsole();
vConsole.show();
}
3.3 多端脚本编写
taro api 有些能力在 h5 端不兼容,或者 h5 会嵌入并调用其他 app 的能力,比如 Taro.getLocation
获取用户定位。
为了更快改写涉及到这个 api 的调用的页面,减少对原页面的改动,解决方案是对 taro api 劫持,也就是重写,并让输入参数与输出结果一致,后续官方支持后直接把之前的逻辑去掉即可。
import Taro from '@tarojs/taro';
if (isH5()) {
Taro.getLocation = async () => ({
data: '重写该函数',
errMsg: 'getLocation:ok'
});
}
但实践后发现在 h5 端无法重写 Taro.getLocation
的方法,其他 taro 的 api 也不行,只能另寻方法。
官方推荐这样编写多端脚本:
方式一,通过 process.env.TARO_ENV
判断环境
if (process.env.TARO_ENV === 'weapp') {
// 加载不同资源
require('path/to/weapp/name')
// weapp 脚本
} else if (process.env.TARO_ENV === 'h5') {
require('path/to/h5/name')
// h5 脚本
}
方式二,通过文件后缀名编译时加载对应的文件
// set_title.weapp.ts
import Taro from '@tarojs/taro'
export default function setTitle (title) {
Taro.setNavigationBarTitle({title})
}
// set_title.h5.ts
export default function setTitle (title) {
document.title = title
}
考虑到每个目标环境一个文件太过冗余,而且 h5 会嵌入到一些原生 APP 上,一些特殊的能力需要调用一些原生 APP 的方法,最终我采用这样的方式:
// 使用 Taro Api 的类型声明
interface MyTaro {
getLocation(option: Taro.getLocation.Option): Promise<Taro.getLocation.SuccessCallbackResult>;
}
// 默认为 Taro api
export const myTaro: MyTaro = {
getLocation: Taro.getLocation,
}
function initMyTaroAPI() {
if (isH5()) {
myTaro.setNavigationBarTitle = setNavigationBarTitle;
} else if (isSyApp()) {
//
}
}
initMyTaroAPI();
外部使用
import { myTaro } from '@/utils/natives'
myTaro.setNavigationBarTitle('jecyu');
3.4 多端样式编写
在一个样式文件编写:
/* #ifdef h5 */
* {
box-sizing: border-box;
}
taro-view-core,
taro-text-core,
div {
font-size: 32px;
}
/* #endif */
/* #ifdef weapp */
view,
text {
box-sizing: border-box;
}
/* #endif */
多个样式文件:
- app.scss
- app.h5.scss
- app.tsx
在 app.tsx 中引入:
import './app.scss';
3.5 多端 UI 组件适配
跟多端脚本一样,多端 UI 组件适配也可以这样做:
方式一,根据环境变量加载
<View>
{process.env.TARO_ENV === 'weapp' && <ScrollViewWeapp />}
{process.env.TARO_ENV === 'h5' && <ScrollViewH5 />}
</View>
方式二,声明多个平台文件
├── test.tsx Test 组件默认的形式,编译到微信小程序、百度小程序和 H5 之外的端使用的版本
├── test.weapp.tsx Test 组件的微信小程序版本
├── test.swan.tsx Test 组件的百度小程序版本
└── test.h5.tsx Test 组件的 H5 版本
外部使用:
import Test from '@/components/test'
<Test />
3.6 多端鉴权
多端鉴权也是一个需要注意的地方,这次要兼容 H5 与微信小程序鉴权:
- 以 h5 嵌入 jecyuAPP 鉴权
- 以 h5 嵌入 jecyu 微信小程序鉴权
- 微信小程序一键登录鉴权
1.首先定义一个鉴权高阶组件,用来包裹需要鉴权的页面
//**
* 防止用户越过正常流程,例如说直接通过连接访问,或者尝试越权访问
* taro 目前不支持路由中间件,需开发者在需要鉴权的页面包装多一层
* <Auth>
* <Order/>
* </Auth>
*/
import { View } from "@tarojs/components";
import Taro from '@tarojs/taro';
import React, { useEffect, useState } from 'react';
import { appAuth } from "@/utils";
/**
* 状态:
* 刚进来页面:加载中(后台获取授权)
* 加载后:授权通过、未授权不能访问,会由 appAuth 跳转到登录页面
*/
export default props => {
const [hasAuth, setHasAuth] = useState(false);
const [loading, setLoading] = useState(true);
const { children, ...params } = props;
useEffect(() => {
Taro.showLoading({ title: '页面加载中' });
appAuth(params)
.then(({ success }) => {
setHasAuth(success);
})
.catch(() => { setHasAuth(false) })
.finally(() => {
Taro.hideLoading();
setLoading(false);
});
}, []);
if (loading) {
return null;
}
return <View>
{hasAuth ? children : '当前页面未授权,不能访问'}
</View>;
};
2.获取当前应用运行环境,然后返回鉴权函数。
// utils/auth.ts
interface AuthFn {
(params?: any): Promise<{ success: boolean }>
}
export const appAuth = (function () {
const appEnv = getAppEnv(); // 获取 h5、weapp、其他 APP 环境
let authFn: AuthFn = async function () {
alert(`没有找到对应环境${appEnv}的环境`);
return { success: false };
};
if (checkLogin()) {
authFn = async function () {
return { success: true };
}
return authFn;
}
switch (appEnv) {
case APP_ENV.WEAPP:
authFn = async () => {
const { success } = await wxLogin();
return { success };
};
return authFn;
case APP_ENV.H5:
authFn = async () => {
return h5Login();
};
return authFn;
case APP_ENV.JECYU_APP:
// auth
// return authFn;
default:
return authFn;
}
})();
3.7 多端线上化部署
3.7.1 微信小程序构建部署
之前为了多端同步调试,构建出来的文件是这样的:
dist
- weapp
- h5
这样的话,为了保持跟之前的部署一致,需要这样处理:
"build:weapp": "cross-env DEPLOY_ENV=\"prod\" taro build --type weapp && cp -r dist/weapp ./ && rm -rf dist && mv weapp dist"
3.7.2 h5 部署构建部署
之所以 h5 要单独说明,是因为之前的 h5 构建流水线都使用了公司统一的 oss 打包脚本,同时打包测试环境 sit 和生产环境 prod 文件。
脚本需要读取 prodDist 文件夹,命令行工具会自动添加输出路径参数 outputPath。
但 Taro 构建不支持这样动态更改 webpack 配置
最终的处理方法,在 config/index.js 里进行特殊的处理:
if (process.env.DEPLOY_ENV === 'prod' && process.env.DEPLOY_TYPE === 'h5') {
outputRoot = 'prodDist';
}
4. 多端开发规范
4.1 样式管理
4.1.1 选择器
虽然在业务页面上不推荐 BEM 写法,BEM 写法更适合写 UI 组件库。
但是因为 Taro 在 React Native 端仅支持类选择器,且不支持组合器,推荐使用 BEM 写法,通过连接前缀避免样式冲突,兼容 H5、小程序和 React Native。
以下选择器的写法都是不支持的,在样式转换时会自动忽略。
.button.button_theme_islands {
font-style: bold;
}
img + p {
font-style: bold;
}
p ~ span {
color: red;
}
div > span {
background-color: DodgerBlue;
}
div span {
background-color: DodgerBlue;
}
若我们基于 scss 等预编译语言开发,则可基于 BEM 写样式,如:
<View className="block">
<Text className="block__elem">文本</Text>
</View>
.block: {
background-color: DodgerBlue;
&__elem {
color: yellow;
}
}
不要使用 Image
、View
等 taro 标签选择器,因为转换 h5 时生成的标签名不是这个,导致样式失效。
4.1.2 css 单位
在 Taro 中尺寸单位建议使用 px、 百分比 %,Taro 默认会对所有单位进行转换。在 h5 中会转为 rem,在小程序中转为 rpx。
在编译时,Taro 会帮你对样式做尺寸转换操作,但是如果是在 JS 中书写了行内样式,那么编译时就无法做替换了,针对这种情况,Taro 提供了 API Taro.pxTransform 来做运行时的尺寸转换。
Taro.pxTransform(10) // 小程序:rpx,H5:rem
注意:默认配置会对所有的 px 单位进行转换,有大写字母的 Px 或 PX 则会被忽略。更多看taro-docs.jd.com/docs/size
4.2 其他
- 慎用第三方组件,注意跨端兼容性。比如我这里使用到
taro-ui
的tabs
组件,它们依赖了 taro 的 ScrollView 组件,在 h5 有兼容问题。 - 尽量页面都放到分包里,静态资源都托管在 CDN上,避免打包体积超出微信小程序最小限制。
5. 小结
实现 Taro 多端开发,主要包括以下几步。
多端同步调试,通过打包配置输出多个平台的目标文件,使用本地代理服务器解决 h5 的跨域问题。
通过注入目标编译环境变量按需编译多端脚本、多端 UI 组件。
通过策略模式,判断多端环境从而实现对应的鉴权。
本次只是实现 h5 与微信小程序,Taro 还支持RN 端、其他小程序,赶紧着手实践吧。