公司开始涉及小程序的业务,由于我们部门以 React
技术栈为主,调研过后决定采用京东的 Taro 框架 。但实际开发还是遇到很多坑,于是总结了一些,也造了些轮子,决定分享出来
定制化 toast(或 modal) api
小程序虽然提供了 toast 和 modal 等 交互 api,Taro 中也同理。但产品希望定制化样式、图标、位置和文字长度,没办法自己搞吧
一、效果演示
-
通过全局的 api 调用 toast 的 show 和 hide
-
每个界面不手动引入组件的情况下,都可调用
二、监听界面路由
-
首先每个界面都可调用,维护一个全局 store 是最简单的。再动态读取当前界面,然后设置对应界面下的状态
-
全局 store 简单,读取当前界面呢,还好小程序提供了 getCurrentPages api
const pages = getCurrentPages()
const curPage = pages[pages.length - 1] || {}
- 那动态读取呢,说白了就是监听路由变化,注意这个是监听全局的路由,Taro 的 componentDidShow,componentDidHide 是 handle 不住的。找了很久在 微信开放社区一个讨论 搜到了
wx.onAppRoute
wx.onAppRoute(res => {
// 更新全局 store 的 currentPage
})
三、引入的偷懒:混合开发是真谛
- 由于不想手动在每个界面引入组件,还好小程序有 template 机制再配合
import
即可。那么搞个脚本,在 build 后把 string append 到最后就行
// pages/demo/index.tsx 打包出来的 pages/demo/index.wxml
<block wx:if="{{$taroCompReady}}">
<view class="demo">Demo page</view>
</block>
// 手动 append toast
<block wx:if="{{$taroCompReady}}">
<view class="demo">Demo page</view>
</block>
<import src="../../components/toast/index.wxml" />
<template is="toast" data="{{__toast__}}" />
- 关于数据的交互,手动注入模板到 build 后的文件中,那么想在 Taro Component 层面操作就别想了,索性刚提到的
getCurrentPages
中有原生的 setData api。那给模板中搞个变量即可:<template is="toast" data="{{__toast__}}" />
四、toast 实现
技术细节都梳理了,开始愉快的敲代码
- @/utils/page 简单封装了下
let _currentPage: Taro.Page = {}
export const $page = {
get() {
return _currentPage
},
update() {
// 更新当前的 page
const pages = getCurrentPages()
_currentPage = pages[pages.length - 1] || {}
},
setData(key: string, source: any, force = false) {
_currentPage.setData({ [key]: source }) // 原生的 setData
force && _currentPage.$component.forceUpdate() // taro 层面的 forceUpdate,按需使用
},
getData(key: string) {
return _currentPage.data[key]
},
}
- 撸个 Toast 类,由于是全局引用,用 static + 单例
const iconFactory = {
success: successSvg,
error: errorSvg,
}
export default class Toast {
static instance: Toast
page: Taro.Page
visible = false
static create() {
if (!this.instance) this.instance = new Toast()
return this.instance
}
// 定义便捷 api
static success(title: string, during?: number, config?: Omit<ToastConfig, 'title' | 'during'>) {
return Toast.show({ title, during, ...Object.assign({}, config, { icon: 'success' }) })
}
// 定义便捷 api
static error(title: string, during?: number, config?: Omit<ToastConfig, 'title' | 'during'>) {
return Toast.show({ title, during, ...Object.assign({}, config, { icon: 'error' }) })
}
// 定义便捷 api
static info(title: string, during?: number, config?: Omit<ToastConfig, 'title' | 'during'>) {
return Toast.show({ title, during, ...Object.assign({}, config, { icon: 'none' }) })
}
// 可自定义调用 api
static async show(config: ToastConfig) {
if (this.instance.visible) return
this.instance.visible = true
const { title, icon = 'none' } = config
// 这里开始操作数据给模板
$page.setData('__toast__', {
visible: true,
title,
icon: iconFactory[icon] || icon,
})
}
static async hide() {
if (!this.instance.visible) return
// 隐藏 toast
$page.setData('__toast__', {
visible: false,
})
this.instance.visible = false
}
}
- App 挂在时初始化,同时监听路由,然后就直接使用咯
import toast from '@/utils/toast'
import $page form '@/utils/page'
class App extends Component {
componentWillMount() {
toast.create()
wx.onAppRoute(res => {
toast.hide()
$page.update() // 上一个界面 toast 隐藏后再更新 page
})
}
render() {
return <Index />
}
}
class Index extends Component {
render() {
return (
<View className='index'>
<Button onClick={() => toast.success('成功提交请求')}>success</Button>
<Button onClick={() => toast.hide()}>hide</Button>
</View>
)
}
}
- toast wxml,这就很简单了,原生写法
<template name="toast">
<view class="toast{{__toast__.visible ? '' : ' hidden'}}">
<image class="toast-icon{{__toast__.icon !== 'none' ? '' : ' hidden'}}" src="{{__toast__.icon}}"></image>
<view class="toast-text">{{__toast__.title}}</view>
</view>
</template>
- 模板注入 script。哪里找到所有 pages 呢,其实 Taro 打包后会生成 app.json,里面记录了所有注册的 page,然后去取相应 index.wxml 即可
const fs = require('fs')
const path = require('path')
const outputDir = 'dist/'
const appJson = 'app.json'
const str = `
<import src="../../components/toast/index.wxml" />
<template is="toast" data="{{__toast__}}" />
`
let initPages = []
start()
async function start() {
// 获取所有的 page index.wxml
initPages = await getInjectPages()
// 模板写入进去
await injectAll(initPages, str)
}
function getInjectPages(jsonName = appJson) {
const appJsonPath = getAbsPath(outputDir, jsonName)
const suffix = '.wxml'
return new Promise((resolve, reject) => {
// check app.json
if (fs.existsSync(appJsonPath)) {
const pageJson = require(appJsonPath)
const pages = (pageJson.pages || []).map(p => outputDir + p + suffix)
// check all pages
if (!pages.some(p => !fs.existsSync(p))) resolve(pages)
else reject('did not find all pages')
}
})
}
async function injectAll(pages, template) {
const injectPromises = pages.map(p => {
return new Promise((resolve, reject) => {
fs.appendFileSync(p, template, 'utf8')
resolve()
})
})
await Promise.all(injectPromises)
}
- bootstrap
"scripts": {
"build:weapp": "rm -rf dist && taro build --type weapp",
"inject": "node scripts/import-toast.js"
}
yarn build:weapp
yarn inject
使用 canvas 加载 json 动画
一、效果演示
二、原生支持
-
小程序没有 svg 标签,有些复杂动画不好实现,还好有官网支持 lottie-miniprogram
-
用法
// wxml
<canvas id="canvas" type="2d"></canvas>
// js
import lottie from 'lottie-miniprogram'
wx.createSelectorQuery()
.selectAll('#loading') // canvas 标签的 id
.node(([res]) => {
const canvas = res.node
const context = canvas.getContext('2d')
lottie.setup(canvas)
lottie.loadAnimation({
animationData: jsonData, // 加下 json 文件
rendererSettings: { context },
})
}).exec()
三、api 调用 loading
-
需要注意的是,测试时发现如果把以上抽成一个组件然后在 page 中引用,
createSelectorQuery
方法会报错找不到该 canvas id,但放在 page 中就可以。初步断定是组件引用时 canvas 组件会出现在 shadow dom 中,而调用该 api 必须实写 canvas,有错望指正 -
那么组件调用方式不行,就用 api 吧。方法同上还是那几步:监听路由、全局 store 通过 setData 传递变量、在原生模板中使用、script 注入
更自由的 render props
使用 Taro render props 传组件并附带一些逻辑时,总是有各种问题和限制,很烦
一、效果演示
二、Taro 打包的研究
-
看了下 Taro 对于 render props 的打包处理,其实就是 slot +
template
嘛 -
index page 中引用 CustomRender 组件并向其传递
renderNormal={() => <View>render prop</View>}
打包如下:
// pages/index/index.wxml
<block wx:if="{{$taroCompReady}}">
<view class="index">
<custom-render compid="{{$compid__3}}">
// 重点在这,这是插槽的内容
<view slot="normal">
<view>
<template is="renderClosureNormalgzzzz" data="{{...anonymousState__temp}}"></template>
</view>
</view>
</custom-render>
</view>
</block>
// 打包生成的 template 不用管
<template name="renderClosureNormalgzzzz">
<block>
<view>render prop</view>
</block>
</template>
// components/custom-render/index.wxml
<block wx:if="{{$taroCompReady}}">
<view>
// 这是对应的插座
<slot name="normal"></slot>
</view>
</block>
- 面向 slot 编程! Taro 对于小写的组件是不编译的,会直接复制过去,所以我们可以进行“混合开发”
class Index extends Component {
render() {
return (
<View className='index'>
<CustomRender renderNormal={() => <View>normal render prop</View>}>
// 自定义的 render props
<view slot='gender'>
<View>this is my slot</View>
</view>
</CustomRender>
</View>
)
}
}
class CustomRender extends Component {
render() {
return (
<View>
// 申明插座
<slot name='gender' />
{this.props.renderNormal()}
</View>
)
}
}
三、动态 slot
- 还不够自由?不就是动态嘛!来!
场景:form 表单中,如果 data 中 custom: true 则渲染 slot,否则渲染常规 item
class Index extends Component {
render() {
return (
<View className='index'>
<CustomRender
data={[
{ name: 'name', label: '姓名', value: 'lawler' },
{ name: 'password', label: '密码', value: 'pwd...' },
{ name: 'gender', custom: true }, // 自定义渲染 gender item
]}
renderNormal={() => <View>normal render prop</View>}
>
// 小写申明 custom 的 slot 内容
<view slot='gender'>
<View>性别:男 radio ? 女 radio</View>
</view>
</CustomRender>
</View>
)
}
}
class CustomRender extends Component {
render() {
const { data } = this.props
return (
<View>
{data.map(item => {
const { name, label, value custom } = item
if (!custom) return <View>{`normal item, ${label}: ${value}`}</View>
// 动态插槽,快乐就完事!!
return <slot name={name} />
})}
{this.props.renderNormal()}
</View>
)
}
}
最后
-
源码获取:taro mini demo
-
关于 script 注入模板 在 taro watch 模式时,我们改变 pages 中的 index.tsx 它是会重新生成 index.wxml 的,所以必须再 yarn inject,但这不影响项目最后的 build
-
想在开发时不那么麻烦,就得用 fs.watch 监听 build 下的 index.wxml,如果改变了就自动 append 模板。当然这个脚本服务于公司内部,就不分享出来了,感兴趣的可以邮件私信我~
-
喜欢的小伙伴,记得留下你的小 ❤️ 哦~