likes
comments
collection
share

Taro小程序开发挑战

作者站长头像
站长
· 阅读数 13

这是一项挑战

原本,这个项目我是主要负责产品和沟通工作,由于业务方非常期望项目能以小程序的方式运作,就从第一版的H5重做成小程序。我也从产品经理角色变回开发工程师,哈。下面分享的就是这次小程序开发的经历。

这次项目是一个提供查询上市企业的科技创新投入和各地政策的具有服务性质的工具型应用。在功能上它包含一些图表以及富文本内容,还有打开三方网站、下载文件等诉求。

结合我之前调研的结论,使用Taro作为研发小程序的框架,能有为项目提供较好的架构基础,但对我来说依旧一项极具挑战的工作。

这主要是因为对React、React Hook、微信小程序及Taro-UI等事情都不太熟悉,摸清这几个事情将带来不少挑战。

火箭是怎样制作出来的?答案是各种零件

就像制作火箭一样,它是由几十上百的零件组合而成,制作小程序也是一样的。所以我们大概将他们分成了三大块React、Taro和微信小程序 三大知识板块。

文档

避免手忙脚乱,我先收集各类文档:

  1. Taro 文档
  2. Taro UI 文档
  3. 微信小程序开发文档
  4. 微信小程序UI组件文档
  5. React 官方中文文档

分析需求

需求本来就是我这边整理汇总的,大致有如下几个核心关键功能:

  • 生成海报(做了调研,但是后期移除了)
  • 显示富文本
  • 显示图表
  • 下载文件并打开(调研后发现可下载,但是不能打开)
  • 访问三方网站(没深入调研,让用户自己打开浏览器吧)

下面这些是在制作中遇到的难点:

  • 分包,微信首包2M限制导致需要面临的工程问题。
  • 路由设计,小程序的路由设计和网站的略有不同,需要换一种思维考虑。

理清现况

在已知的需求和难点已经排查出来,接下来就得理清现况。在大致阅读各官网文档后,下面是我未清楚的事情,需要学习和认识的:

  • React的使用
    • 数据运作机制
      • useState, useReducer, useEffect的使用
    • 组件的封装实践
    • 模板逻辑的可行方案
      • useContent和Context的使用
    • 调试
      • react-devtools
  • 微信小程序
    • 边界摸索
      • 能否下载文件并使用(能下载,不能使用)
      • 能否访问三方网站(能,限制较多)
    • 具体的
      • 压缩和分包
      • 路由设计
      • 私信分享和朋友圈分享
      • 如何简单地显示富文本
      • ECharts的引入和使用
  • UI
    • 微信小程序官方有哪些UI组件
    • Taro UI有哪些UI组件

React部分

搞懂基础

我学习并搞明白的内容并不多,但是并不影响我制作。首先useStateuseReducer 的使用。其次是组件封装的最佳实践,当然我这里的最佳只适用于我的有限理解和项目的情况。

//# 简单使用
import { useState, useReducer, useEffect } from 'react'

const ctx = {
  init(){
    return {
		expend:false
    }
  },
  reducer(state, {field, val, action='set'}){
    if(action == 'set'){
      return {...state, [field]:val}
    }
    switch (action) {
      case "reset":
        return ctx.init()
      default:
        throw new Error();
    }
  }
}
export default function({expend=false, title, onExpend){
	//单个变量是可以使用useState
	const [_title, setTitle] = useState(title)
	
	//承载多个变量时,建议使用useReducer
	const [state, dispatch] = useReducer(ctx.reducer, {data:null}, ctx.init);
	
	useEffect(()=>{
		//当title变化时进入
		console.log(title)
	},[title])

	useEffect(()=>{
		//组件初次化时,更新外部状态至内部状态
		dispatch({field: 'expend', val:expend})
		setTitle(expend?title:'')
	}, [expend])

	//组件内部状态更新时
	function onExpendChanged(val){
		dispatch({field: 'expend', val})
		setTitle(val?title:'')
		onExpend(val)
	}

	return <div
		className={state.expend?'expend':''}
		onClick={()=>onExpendChanged(!state.expend)}>{_title}</div>
}


如何调试

在学习useState钩子函数时,在微信小程序官方的工具中很难看出状态的变化。在搞清楚小程序调试并不合适Taro的调试下,我想起Vue框架中有vue-devtools,那么react应该也有叫react-devtools的工具。

# 全局安装
$ npm i -g react-devtools

# 直接运行
$ react-devtools

运行后你将得到下面界面:

Taro小程序开发挑战

接下来只需要将<script src="http://localhost:8097"></script>这段代码添加到src/index.html文件中便可以进行调试。只有你页面在运行并完成渲染,就能在上面界面看到组件树及组件状态。

封装useAsync函数

接下来为了方便管理加载状态,我封装了自己的首个hooks方法:useAsync。借助 async-library/react-async) 作者的智慧,让我能用较为简单的代码实现了我想要的效果。

// #utils/index.js
import { useReducer } from 'react'

const asyncCtx = {
  init({data}={}){
    return {
      mounted: false,
      loading: false,
      conditions: {},
      err:null,
      data:data||null
    }
  },
  reducer(state, {field, val, action='set'}){
    if(action == 'set'){
      return {...state, [field]:val}
    }
    switch (action) {
      case "mounted":
        return {...state, mounted:true}
      case "loading":
        return {...state, loading:true}
      case "loaded":
        return {...state, loading:false}
      case "reset":
        return asyncCtx.init()
      default:
        throw new Error();
    }
  }
}
export function useAsync({promise,promiseFun, defaultData, hook:setDataHook}={}){
  const [state, dispatch] = useReducer(asyncCtx.reducer, {data:defaultData}, asyncCtx.init);
  if(typeof promise == undefined) promiseFun=()=>promise

  const mounted = ()=>dispatch({action:'mounted'})
  const loading = ()=>dispatch({action:'loading'})
  const loaded = ()=>dispatch({action:'loaded'})
  const setError = err=>dispatch({field:'err', val:err})
  const reset = ()=>dispatch({action:'reset'})
  const setConditions = val=>!!val&&dispatch({field:'conditions', val})
  const updateData = (data=>{
    dispatch({field:'data', val:data})
    return data
  })
  const setData = setDataHook ? (data)=>setDataHook(data, {dispatch, setData:updateData}) : updateData
  const resetData = ()=>updateData(defaultData)

  const run = (query, opts={})=>new Promise(async (resolve,reject)=>{
    const {
      mounted,
      loading,
      loaded,
      setData,
      setError,
      setConditions
    } = opts
    setConditions(query)
    mounted()
    loading()
    const [err, result] = await awaitTo(promiseFun(query))
    try{
      let data = result
      if(!!err){
        setError(err)
      }else{
        data = setData(data)
      }
      resolve([err, data])
    }catch(e){
      setError(e)
      resolve([e, null])
    }
    loaded()
  })

  return Object.assign(state,{
    dispatch,
    run:(query,opts={})=>run(query,{
      mounted:opts.mounted||mounted,
      loading:opts.loading||loading,
      loaded:opts.loaded||loaded,
      setData:opts.setData||setData,
      setError:opts.setError||setError,
      setConditions:opts.setConditions||setConditions
    }),
    reset,
    resetData
  })
}
//# useAsync使用例子
import { useAsync } from "@/utils/";

const $$user = useAsync({
	promiseFun:api.user, //请求API的函数,返回Promise对象
	hook:(res,{setData})=>setData(res.data), //处理数据前的钩子函数
	defaultData:[] //默认数据
})

//请求数据
$$user.run() // 等于api.classification()

//返回数据
console.log($$user.data)
// [{...},{...},{...},{...}]

//加载态
console.log("loading::%s",$$user.loading)
// loading::true or loading::false

封装usePagination函数

并基于useAsync增加对分页项的管理,封装成usePagination的钩子函数。

//# utils/index.js
const paginationCtx = {
  init(opts={}){
    return {
      limit:5,
      offset:0,
      total:0,
      page:1,
      lastPage:1,
      nextLoading: false,
      ...opts
    }
  },
  reducer(state, {field, val, action='set'}){
    if(action == 'set'){
      return !!field ? {...state, [field]:val} : {...state,...val}
    }
    switch (action) {
      case "loading":
        return {...state, nextLoading:true}
      case "loaded":
        return {...state, nextLoading:false}
      case "reset":
          return paginationCtx.init()
      default:
        throw new Error();
    }
  }
}
export function usePagination(options={}){
  let {pagination:defaultOpts,...opts} = options
  const [pagination, dispatch] = useReducer(paginationCtx.reducer, defaultOpts, paginationCtx.init);
  const setPagination = opts=>dispatch({val:opts})
  const setNextLoading = val=>dispatch({field:'nextLoading', val})
  const async = useAsync({...opts})

  const { reset:asyncReset, run:asyncRun } = async

  function reset(){
    asyncReset()
    dispatch({action:'reset'})
  }

  async function run(query, opts={}){
    opts = { setData:data=>data, ...opts}
    const setData = res=>{
      const limit = query.limit || pagination.limit
      const offset = query.offset || pagination.offset
      const page = offset ? Math.ceil(offset / limit + 1) : 1;
      const lastPage = Math.ceil(res.total / limit);
      setPagination({
        total:res.total,
        page,
        lastPage,
        limit,
        offset
      })
      const data = opts.setData(res.data)
      async.dispatch({field:'data', val:data})
      return data
    }
    let [err, data] = await asyncRun(query, {...opts, setData})
    return Promise.resolve([err, data])
  }

  async function next(query={}){
    const setData = val=>[...async.data,...val]
    const loading = ()=>setNextLoading(true)
    const loaded = ()=>setNextLoading(false)

    const limit = query.limit || pagination.limit
    let offset = query.offset || pagination.offset
    offset = offset+limit
    return run({...query, offset, limit}, {setData,loading,loaded})
  }

  const ctx = Object.assign(async, {
    pagination,
    nextLoading: pagination.nextLoading,
    setPagination,
    setNextLoading,
    reset,
    next,
    run,
  })
  return ctx
}
//# usePagination使用例子
const $$userPagination = usePagination({
	promiseFun:api.user, //请求函数
	pagination:{ offset:0,limit:5,total:0 }, //默认分页设置
	defaultData:[], //默认数据
})

//请求数据
$$userPagination.run() // 等于api.classification()

//返回数据
console.log($$userPagination.data)
// [{...},{...},{...},{...}]

//加载态
console.log("loading::%s",$$userPagination.loading)
// loading::true or loading::false

//分页数据
console.log($$userPagination.pagination)
// { limit:5, offset:0, total:10, page:1, lastPage:2 }

//请求下一页数据并加到列表
$$userPagination.next()  //当然可以传入参数条件

//分页数据
console.log($$userPagination.pagination)
// { limit:5, offset:5, total:10, page:2, lastPage:2 }

封装Async组件

然后为了接近自己理解(比较还在用Vue2),期望通过更具陈述性的方式来组织HTML部分的代码。我又学习到能基于Context达到类似效果,下面是名为Async组件的实现和使用。

//# utils/useAsyncEl.js
import { useContext, createContext } from 'react'

export default function useAsyncEl(ctx){
  const $context = createContext(ctx)

  function Async({context, children}){
    const {Provider} = $context
    const _ctx = typeof context === "undefined" ? ctx : context
    return <Provider value={_ctx}>{children}</Provider>
  }

  function AsyncEmpty({contextEl, children, view }){
    view = typeof view == 'undefined' ? (ctx)=>!ctx.loading&&!ctx.data : view
    const _contextEl = typeof contextEl === "undefined" ? $context : contextEl
    const ctx = useContext(_contextEl)
    return <>{ (!ctx.mounted||ctx.mounted&&view(ctx))&&children }</>
  }

  function AsyncLoading({contextEl, children}){
    const _contextEl = typeof contextEl === "undefined" ? $context : contextEl
    const ctx = useContext(_contextEl)
    return <>{ ctx.loading&&children }</>
  }

  function AsyncBe({contextEl, children}){
    const _contextEl = typeof contextEl === "undefined" ? $context : contextEl
    const ctx = useContext(_contextEl)
    return <>{ (ctx.mounted&&!ctx.loading&&!!ctx.data)&&children }</>
  }

  return {
    AsyncCtx:$context,
    Async,
    AsyncEmpty,
    AsyncLoading,
    AsyncBe
  }
}

使用

import useAsyncEl from '@/utils/useAsyncEl'
import { useAsync } from "@/utils/";

const { Async, AsyncLoading, AsyncBe, AsyncEmpty } = useAsyncEl()
export function UserList(){
	
	const $$user = useAsync({
		promiseFun:api.user, //请求API的函数,返回Promise对象
		hook:(res,{setData})=>setData(res.data), //处理数据前的钩子函数
		defaultData:[] //默认数据
	})

	const renderUserList = (data)=>{...}
	
	return (
		<Async context={$$user}>
			<AsyncEmpty>
				<div>暂无内容</div>
			</AsyncEmpty>
			<AsyncLoading>
				<div className="ui-loading">加载中...</div>
			</AsyncLoading>
			<AsyncBe>
				{ renderUserList($$user.data) }
			</AsyncBe>
		</Async>
	)
}

小程序部分

显示富文本

只是显示富文本,方案有很多,比如官网自带的富文本显示组件rich-text,使用起来没啥大问题。

import { RichText } from "@tarojs/components"
export function Page(){
	const html = `
<div class="div_class">
  <h1>Title</h1>
  <p class="p">
    Life is&nbsp;<i>like</i>&nbsp;a box of
    <b>&nbsp;chocolates</b>.
  </p>
</div>`
	return <RichText nodes={html} />
}

出于考虑后续扩展,作为保险还是用上了 mp-html 这样的三方库。

在Taro中使用也是非常简单,下载指定小程序版本,放至目录然后在页面设置上注册组件应用就能马上使用了。

# 直接安装然后复制到目录
$ npm i mp-html

# 先建好目录
$ mkdir ./src/components/mp-html

# 复制过去
$ cp -R ./node_modules/mp-html/dist/mp-weixin/** ./src/components/mp-html
//# article.config.js
export default definePageConfig({
  navigationBarTitleText: '例子',
  usingComponents: {
    'mp-html':'../../components/mp-html/index'
  },
})

//# article.jsx
//这里就可以直接使用了。
export default Page(){
	const html = `
	<div class="div_class">
	  <h1>Title</h1>
	  <p class="p">
	    Life is&nbsp;<i>like</i>&nbsp;a box of
	    <b>&nbsp;chocolates</b>.
	  </p>
	</div>`
	return <mp-html content={html} />
}

显示图表

刚开始找到的是Echart官网对小程序的支持方案:ecomfe/echarts-for-weixin), 在当时我没太搞明白React的组件和小程序组件间的关系。所以后来我选用了开箱即用的 qiuweikangdev/taro-react-echarts 。除了刚用就遇到白屏的情况外(isPage改为false就好了),基本没其他大碍。

import * as echarts from '@/utils/echarts' // 自己去官网下载
import Echarts from 'taro-react-echarts'

export default function Page(){
	//原数据
	const data = [...]

	function barOpts(data){
		//转换数据为图表合适使用的格式
		return {...}
	}

	return <Echarts
	  isPage={false}
	  echarts={echarts}
	  option={barOpts(data})}
	  style={({height:'200px'})} />
}

分包和路由

在完成上面两项功能后,手机开始无法预览,首包已超过2M。这种情况就要解决分包和路由设计问题。首先项目页面不多,功能也没多复杂,分包选择较为简单的方案:根据业务划分。

export default defineAppConfig({
  pages: [
    'pages/index/index',
  ],
  subpackages: [
    {
      "root": "company", //企业科创力查询
      "pages": [...]
    },
    {
      "root": "policy", //政策库查询
      "pages": [...]
    },
  ],
  window: {
    backgroundTextStyle: 'light',
    navigationBarBackgroundColor: '#fff',
    navigationBarTitleText: 'WeChat',
    navigationBarTextStyle: 'black'
  }
})

小程序关于页面就两种模式 盖一层界面 还是 重绘新界面,与Web开发有所不同,思路的改变一下。

  • navigateTo 和 navigateBack 是在当前界面加一个新界面或是回到其他界面。
  • redirectTo, reLaunch, switchTab 均属于重绘新界面

页面不多,但是有循环跳转的情况,所以跳转个别节点页面均限定为redirectTo就能解决了。因为页面不多,不考虑复杂的实现。当然也有其他满足复杂情况的实现方式,比如虚实结合的 智能路由 方案。

动画

做到最后让我想不到的是微信小程序竟然不支持css动画。动画需要额外调用animate函数着实是没想到的。下面是代码例子。

const [page, setPage] = useState(null)
useReady(() => {
    const pages = Taro.getCurrentPages()
    setPage(pages[pages.length-1])
    
	page.animate(
		".btnFilter", // css选择器
		[ //效果
	        { width: "70rpx", ease: 'ease-out' },
	        { width: "184rpx", ease: 'ease-out' },
		],
		350 //执行动画时长
	)
})

准备进一步学习React和Vue3

这次研发工作对我来说十分深刻。一个是对微信小程序的支持和限制有了深刻的了解,某些功能被单独处理维护并支持,看似功能丰富实则可能性过于单一,而至实现费事费力无法获得社区的支撑,可选方案非常有限。

另一个是React+Hooks使用起来感觉非常良好,“自由”两字在React得到非凡的体现。一直在使用Vue2开发的我,习惯于固定结构的框架下开发,可能就迷失在这份“自由”之中。当然现在这份“自由”也让我回味无穷,所以我接下来想再深入学习一下React和Vue3的应用和思想,寻找一种简洁的且易于阅读的代码结构,这是我所期望的。

如果我的这分享给到你一些帮助,帮忙点赞收藏喔!ο(=•ω<=)ρ⌒☆