原文地址

公司开始涉及小程序的业务,由于我们部门以 React 技术栈为主,调研过后决定采用京东的 Taro 框架 。但实际开发还是遇到很多坑,于是总结了一些,也造了些轮子,决定分享出来

定制化 toast(或 modal) api

小程序虽然提供了 toast 和 modal 等 交互 api,Taro 中也同理。但产品希望定制化样式、图标、位置和文字长度,没办法自己搞吧

一、效果演示

  1. 通过全局的 api 调用 toast 的 show 和 hide

  2. 每个界面不手动引入组件的情况下,都可调用

toast

二、监听界面路由

  1. 首先每个界面都可调用,维护一个全局 store 是最简单的。再动态读取当前界面,然后设置对应界面下的状态

  2. 全局 store 简单,读取当前界面呢,还好小程序提供了 getCurrentPages api

const pages = getCurrentPages()
const curPage = pages[pages.length - 1] || {}
  1. 那动态读取呢,说白了就是监听路由变化,注意这个是监听全局的路由,Taro 的 componentDidShow,componentDidHide 是 handle 不住的。找了很久在 微信开放社区一个讨论 搜到了 wx.onAppRoute
wx.onAppRoute(res => {
  // 更新全局 store 的 currentPage
})

三、引入的偷懒:混合开发是真谛

  1. 由于不想手动在每个界面引入组件,还好小程序有 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__}}" />
  1. 关于数据的交互,手动注入模板到 build 后的文件中,那么想在 Taro Component 层面操作就别想了,索性刚提到的 getCurrentPages 中有原生的 setData api。那给模板中搞个变量即可:<template is="toast" data="" />

四、toast 实现

技术细节都梳理了,开始愉快的敲代码

  1. @/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]
  },
}
  1. 撸个 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
  }
}
  1. 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>
    )
  }
}
  1. 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>
  1. 模板注入 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)
}
  1. bootstrap
"scripts": {
  "build:weapp": "rm -rf dist && taro build --type weapp",
  "inject": "node scripts/import-toast.js"
}

yarn build:weapp
yarn inject

使用 canvas 加载 json 动画

一、效果演示

loading

二、原生支持

  1. 小程序没有 svg 标签,有些复杂动画不好实现,还好有官网支持 lottie-miniprogram

  2. 用法

// 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

  1. 需要注意的是,测试时发现如果把以上抽成一个组件然后在 page 中引用,createSelectorQuery 方法会报错找不到该 canvas id,但放在 page 中就可以。初步断定是组件引用时 canvas 组件会出现在 shadow dom 中,而调用该 api 必须实写 canvas,有错望指正

  2. 那么组件调用方式不行,就用 api 吧。方法同上还是那几步:监听路由、全局 store 通过 setData 传递变量、在原生模板中使用、script 注入

更自由的 render props

使用 Taro render props 传组件并附带一些逻辑时,总是有各种问题和限制,很烦

一、效果演示

custom-render

二、Taro 打包的研究

  1. 看了下 Taro 对于 render props 的打包处理,其实就是 slot + template

  2. 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>
  1. 面向 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

  1. 还不够自由?不就是动态嘛!来!

场景: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>
    )
  }
}

最后

  1. 源码获取:taro mini demo

  2. 关于 script 注入模板 在 taro watch 模式时,我们改变 pages 中的 index.tsx 它是会重新生成 index.wxml 的,所以必须再 yarn inject,但这不影响项目最后的 build

  3. 想在开发时不那么麻烦,就得用 fs.watch 监听 build 下的 index.wxml,如果改变了就自动 append 模板。当然这个脚本服务于公司内部,就不分享出来了,感兴趣的可以邮件私信我~

  4. 喜欢的小伙伴,记得留下你的小 ❤️ 哦~

参考资料