实现一个Taro3小程序框架原型

1,177 阅读7分钟

项目信息

mini-taro项目开源地址: github.com/55utah/mini…

欢迎大家踊跃Star 👏👏👏。

说明:  本项目是一个学习项目,实现了Taro3的基础运行时能力,支持将React语法的多个页面渲染到小程序上,支持React类组件和函数组件,支持css;以不足千行的代码量,实现了一套Taro3运行时原型,旨在让大家更好得理解Taro3小程序框架运行时的原理。

效果:

项目可以将如下的React组件页面demo渲染到小程序上:

import React, { FC, useState } from 'react'
import { View, Text, Button, Input } from '@/index'

import './index.css'

export const EntryPage: FC = () => {

  const [name, setName] = useState('')
  const [count, setCount] = useState(0)
  const [list, setList] = useState([
    { key: 1, value: '这是第1个' },
    { key: 2, value: '这是第2个' },
    { key: 3, value: '这是第3个' },
  ])

  const increment = () => {
    setCount((val) => val + 1)
  }
  
  // ... 省略

  return (
    <View className="wrapper">
      <View style={{ color: 'blue' }}>
        <Text style={{ fontSize: '25px', fontWeight: '600' }}>style样式</Text>
        <Text className="class-sample">css样式</Text>
      </View>
      <!-- ...省略 -->
      <View style={{ margin: '20px 0' }}>
        <Text>count: {count}</Text>
        <Button onClick={() => increment()} type="primary">
          数字加一
        </Button>
      </View>
      <View>
        <Text>name: {name}</Text>
        <Input
          style={{ border: '1px solid blue' }}
          onInput={(e) => changeName(e)}
        />
      </View>
    </View>
  )
}

渲染效果:

Taro3背景知识

  1. 起源

小程序原生框架语言能力不足以及与前端生态的割裂,使其逐渐成为“编译目标语言”,出现了众多小程序开发框架来提升开发者体验,如Taro、mpvue、Remax、uniapp等等,Taro3是Taro项目的3.0版本。

  1. 小程序顶层对象

小程序原生框架,提供了两个顶层对象构建应用和页面实例:

image.png

只需要在逻辑层调用对应的 App()、Page() 方法,在方法参数对象内处理 data、提供生命周期/事件函数等,同时在视图层提供对应的模版及样式供渲染不就能控制运行小程序了,Taro3和众多小程序框架都是基于这个思想完成的。

Taro3运行时原理概览

一句话总结: Taro将react/vue组件与一棵虚拟dom树对应,再将虚拟dom树以及其每次变更后diff的结果通过调用setData映射到小程序上,最终达到在小程序上跑react/vue应用的效果。

特点: (1)采取template模版动态渲染方案;(2)实现了自己的BOM/DOM API从而摆脱了DSL的限制,可以同时支持react、vue等开发框架,相当于与DSL剥离,react等框架的新特性可以随意引入,前端的生态又回来了。

流程图:

image.png 可以看到更新的粒度是 DOM 级别,只有最终发生改变的 DOM 才会被更新过去,data 级别的更新更加精准,性能更好。

1. 事件的处理:

image.png 在小程序模版(base.ttml)中,将所有的事件都收归到eh方法上,在这个方法内部进行事件的收集和处理,触发事件时会查询发生事件的虚拟节点(通过每个节点的id去查找),触发节点上对应的事件(Taro实现了自己的一套事件机制,封装了新的事件对象,这样可以自由管理冒泡和捕获事件)。

2. 虚拟DOM树

Taro在内部建立了一套DOM/BOM API,让react这样的开发框架可以跟进这些API创建出如下数据结构对象组成的DOM树,类似TaroNode的节点对应web下的节点。每个Node节点具备操作子元素、修改属性的方法。

image.png

Taro分别为React/Vue框架实现DOM节点diff渲染逻辑,我们以React为例:

image.png

react-reconcilor作用是维护virtualDom树,内部实现了Fiber/Diff算法,决定什么时候更新虚拟dom树,怎么更新虚拟dom树。react-dom是处理实际渲染dom节点工作,小程序无法使用,所以Taro实现了一个包为taro-react来实现类似react-dom负责的工作,将虚拟dom树转换为一个渲染树,然后依靠小程序的setData将渲染内容更新到小程序页面内,后续template模版根据页面的data将想要绘制的内容绘制出来。

3. 动态模版渲染

Taro使用小程序模版template递归调用实现渲染,原理如下:

// 小程序页面: page.ttml
<import src="../../base.ttml"/>
<template is="taro_tmpl" data="{{root:root}}" />


// Taro的模版文件: base.ttml
<template name="taro_tmpl">
  <block tt:for="{{root.cn}}" tt:key="uid">
    <template is="tmpl_0_container" data="{{i:item}}" />
  </block>
</template>

<template name="tmpl_0_container">
  <template is="{{'tmpl_0_' + i.nn}}" data="{{i:i}}" />
</template>

<template name="tmpl_0_view">
  <view bindlongtap="eh" bindanimationstart="eh" bindanimationend="eh" style="{{i.st}}" 
  class="{{i.cl}}" bindtap="eh" id="{{i.uid}}">
    <block tt:for="{{i.cn}}" tt:key="uid">
      <template is="tmpl_0_container" data="{{i:item}}" />
    </block>
  </view>
</template>

// ...

这样只要给小程序页面的data设置为如下的数据结构,小程序就可以按结构渲染出整个页面了:

{
    data: {
        root: {
            cn: [{
            //组件名称、className、style、id等属性、children等信息
                cn: [],
                nn: 'view',
                cl: '',
                st: '',
                uid: 'u-8'
            }],
            uid: 'pages/xxx/index'
        }
    }
}

cn是children缩写,nn是NodeName缩写,st是style,cl是className。

4. 跑起来

实现了虚拟DOM树之后,将虚拟DOM树初始值或diff转换为上面结构的渲染树,然后执行小程序的setData,小程序data更新后,就会按照base.ttml模版将对应的内容内容绘制出来。

mini-taro的实现

mini-taro就是按Taro的原理实现出来的原型,区别是没有实现复杂的DOM/BOM API,只考虑支持React,所以使用react-reconciler构建自己的虚拟DOM树,后面的逻辑就比较接近了。本项目作为一个学习项目,希望可以帮助更大同学学习Taro3的运行原理,能窥探框架的底层原理,学习完本项目后,你甚至可以开发出自己的“小程序框架”。下面针对部分核心场景对mini-taro项目的代码作展示和说明。

项目层级

/* 核心文件夹 */
-dist  // 产物文件夹
-demo  // demo页面,包含React组件、应用配置等文件
-src  // 本项目真正的Taro原型运行时核心代码

/* 其他文件夹 */
-build   // 构建辅助文件夹(相当于loader)
-scripts  // 打包辅助脚本/模版文件

运行入口

mini-taro只在src/index.ts导出了createPageConfig, createAppConfig两个核心方法和View、Text等基础组件,除此之外src内未导出其他内容。

React组件和mini-taro的联结在build文件夹。

-build
    -app.ts // 对应小程序的app.js入口
    -page-entry.ts // 对应demo文件夹中page-entry页面
    -page-second.ts // 对应demo文件夹中page-second页面

在app入口文件中

// AppComp是构建的app顶层React class组件;App则是小程序的顶层方法。
App(createAppConfig(AppComp))

在页面文件中

// SecondPage是对应的React组件;Page是小程序页面的顶层方法;
Page(createPageConfig(SecondPage, { root: { nn: 'root' } }, { path: 'pages/page-second/index' }))

虚拟dom树

  1. 我们只关注react,虚拟dom树则应该通过 react-reconciler 实现,具体参考代码,要传一个名叫host-config的配置给其方法生成实例。
  2. 虚拟dom树由节点构成,节点实现在 src/taro-element.ts 中,实现了TaroElement/TaroText/TaroRootElement 三个节点类型,分别对应普通节点、文本节点、页面顶层根节点,节点具有children、属性、文本等,通过对这些内容的修改,就可以维护出一颗虚拟节点树,这个过程有上面的react-reconciler作为引擎来完成。
  3. 我们创建的react-reconciler实例具有render方法,实际就是我们熟悉的ReactDOM.render(),将我们封装过的react组件传入即可。

页面的注册

页面onLoad后会将本页面React组件注册到app实例上,app实例这个component放在children,调用react.createElement的chidlren参数渲染即可。

class AppWrapper extends React.Component {
    public pages: PageComponent[] = []
    public elements: PageElement[] = []
    public mount (component: ComponentClass<Props>, id: string, cb: () => void) { 
        const key = id const page = () => h(component, { key, tid: id }) 
        this.pages.push(page) 
        // 强制更新一次
        this.forceUpdate(cb) 
    }
    public render () { 
        while (this.pages.length > 0) { 
            const page = this.pages.pop()!
            this.elements.push(page())
        } 
        const props: Record<string, unknown> | null = null 
        return React.createElement( App, props, this.elements.slice() ) 
    }
}

// 这个config内的方法通过getApp()可以拿到
const config = Object.create({ 
    mount: function(component: ReactPageComponent, id: string, cb: () => void) {
        // 这个wrapper就是上面AppWrapper实例
        const page = connectReactPage(id)(component) wrapper.mount(page, id, cb) 
    },
//...
},

虚拟dom树渲染到小程序

  1. payload 更新虚拟节点的属性、children、文本都要认为是变更,生成一份payload收集到当前页面的TaroRootElement中,payload结构:
type Payload = {
    path: string,
    value: unknown
}

这些payload可以经过计算直接传给小程序的setData方法,比如:

setData('root.cn[0].cn[1].v', '内容')
  1. 初始化如何处理 初始化时,使用hydrate函数转为”渲染树“,就可以直接交给setData更新页面了。

后记

一个有趣的事实:Taro、Remax等大部分的小程序框架核心都是在运行时又创建一套虚拟dom树,这么算起来加上react、vue框架自己的虚拟dom树,小程序原生框架内部可能存在的虚拟dom树,那就是有三套功能相近的虚拟dom树存在。。