整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

深入详解大佬用33行代码实现了React

深入详解大佬用33行代码实现了React


者: 李松峰

转发连接:https://mp.weixin.qq.com/s/dIG7QxHyP-EKWH0Fq98dGQ

自:coderwhy


前面说过,整个前端已经是组件化的天下,而CSS的设计就不是为组件化而生的,所以在目前组件化的框架中都在需要一种合适的CSS解决方案。

一. React中的css方案

1.1. react中的css

事实上,css一直是React的痛点,也是被很多开发者吐槽、诟病的一个点。

在组件化中选择合适的CSS解决方案应该符合以下条件:

  • 可以编写局部css:css具备自己的具备作用域,不会随意污染其他组件内的原生;
  • 可以编写动态的css:可以获取当前组件的一些状态,根据状态的变化生成不同的css样式;
  • 支持所有的css特性:伪类、动画、媒体查询等;
  • 编写起来简洁方便、最好符合一贯的css风格特点;
  • 等等...

在这一点上,Vue做的要远远好于React:

  • Vue通过在.vue文件中编写 <style><style> 标签来编写自己的样式;
  • 通过是否添加 scoped 属性来决定编写的样式是全局有效还是局部有效;
  • 通过 lang 属性来设置你喜欢的 less、sass等预处理器;
  • 通过内联样式风格的方式来根据最新状态设置和改变css;
  • 等等...

Vue在CSS上虽然不能称之为完美,但是已经足够简洁、自然、方便了,至少统一的样式风格不会出现多个开发人员、多个项目采用不一样的样式风格。

相比而言,React官方并没有给出在React中统一的样式风格:

  • 由此,从普通的css,到css modules,再到css in js,有几十种不同的解决方案,上百个不同的库;
  • 大家一直在寻找最好的或者说最适合自己的CSS方案,但是到目前为止也没有统一的方案;

在这篇文章中,我会介绍挑选四种解决方案来介绍:

  • 方案一:内联样式的写法;
  • 方案二:普通的css写法;
  • 方案三:css modules;
  • 方案四:css in js(styled-components);

1.2. 普通的解决方案

1.2.1. 内联样式

内联样式是官方推荐的一种css样式的写法:

  • style 接受一个采用小驼峰命名属性的 JavaScript 对象,,而不是 CSS 字符串;
  • 并且可以引用state中的状态来设置相关的样式;
export default class App extends PureComponent {
  constructor(props) {
    super(props);

    this.state = {
      titleColor: "red"
    }
  }

  render() {
    return (
      <div>
        <h2 style={{color: this.state.titleColor, fontSize: "20px"}}>我是App标题</h2>
        <p style={{color: "green", textDecoration: "underline"}}>我是一段文字描述</p>
      </div>
    )
  }
}

内联样式的优点:

  • 1.内联样式, 样式之间不会有冲突
  • 2.可以动态获取当前state中的状态

内联样式的缺点:

  • 1.写法上都需要使用驼峰标识
  • 2.某些样式没有提示
  • 3.大量的样式, 代码混乱
  • 4.某些样式无法编写(比如伪类/伪元素)

所以官方依然是希望内联合适和普通的css来结合编写;

1.2.2. 普通的css

普通的css我们通常会编写到一个单独的文件。

App.js中编写React逻辑代码:

import React, { PureComponent } from 'react';

import Home from './Home';

import './App.css';

export default class App extends PureComponent {
  render() {
    return (
      <div className="app">
        <h2 className="title">我是App的标题</h2>
        <p className="desc">我是App中的一段文字描述</p>
        <Home/>
      </div>
    )
  }
}

App.css中编写React样式代码:

.title {
  color: red;
  font-size: 20px;
}

.desc {
  color: green;
  text-decoration: underline;
}

这样的编写方式和普通的网页开发中编写方式是一致的:

  • 如果我们按照普通的网页标准去编写,那么也不会有太大的问题;
  • 但是组件化开发中我们总是希望组件是一个独立的模块,即便是样式也只是在自己内部生效,不会相互影响;
  • 但是普通的css都属于全局的css,样式之间会相互影响;

比如编写Home.js的逻辑代码:

import React, { PureComponent } from 'react';

import './Home.css';

export default class Home extends PureComponent {
  render() {
    return (
      <div className="home">
        <h2 className="title">我是Home标题</h2>
        <span className="desc">我是Home中的span段落</span>
      </div>
    )
  }
}

又编写了Home.css的样式代码:

.title {
  color: orange;
}

.desc {
  color: purple;
}

最终样式之间会相互层叠,只有一个样式会生效;

1.2.3. css modules

css modules并不是React特有的解决方案,而是所有使用了类似于webpack配置的环境下都可以使用的。

但是,如果在其他项目中使用,那么我们需要自己来进行配置,比如配置webpack.config.js中的modules: true等。

但是React的脚手架已经内置了css modules的配置:

  • .css/.less/.scss 等样式文件都修改成 .module.css/.module.less/.module.scss 等;
  • 之后就可以引用并且进行使用了;

使用的方式如下:

css modules用法

这种css使用方式最终生成的class名称会全局唯一:

生成的代码结构

css modules确实解决了局部作用域的问题,也是很多人喜欢在React中使用的一种方案。

但是这种方案也有自己的缺陷:

  • 引用的类名,不能使用连接符(.home-title),在JavaScript中是不识别的;
  • 所有的className都必须使用{style.className} 的形式来编写;
  • 不方便动态来修改某些样式,依然需要使用内联样式的方式;

如果你觉得上面的缺陷还算OK,那么你在开发中完全可以选择使用css modules来编写,并且也是在React中很受欢迎的一种方式。

二. CSS in JS

2.1. 认识CSS in JS

实际上,官方文档也有提到过CSS in JS这种方案:

  • “CSS-in-JS” 是指一种模式,其中 CSS 由 JavaScript 生成而不是在外部文件中定义;
  • 注意此功能并不是 React 的一部分,而是由第三方库提供。 React 对样式如何定义并没有明确态度;

在传统的前端开发中,我们通常会将结构(HTML)、样式(CSS)、逻辑(JavaScript)进行分离。

  • 但是在前面的学习中,我们就提到过,React的思想中认为逻辑本身和UI是无法分离的,所以才会有了JSX的语法。
  • 样式呢?样式也是属于UI的一部分;
  • 事实上CSS-in-JS的模式就是一种将样式(CSS)也写入到JavaScript中的方式,并且可以方便的使用JavaScript的状态;
  • 所以React有被人称之为 All in JS;

当然,这种开发的方式也受到了很多的批评:

  • Stop using CSS in JavaScript for web development
  • https://hackernoon.com/stop-using-css-in-javascript-for-web-development-fa32fb873dcc

批评声音虽然有,但是在我们看来很多优秀的CSS-in-JS的库依然非常强大、方便:

  • CSS-in-JS通过JavaScript来为CSS赋予一些能力,包括类似于CSS预处理器一样的样式嵌套、函数定义、逻辑复用、动态修改状态等等;
  • 依然CSS预处理器也具备某些能力,但是获取动态状态依然是一个不好处理的点;
  • 所以,目前可以说CSS-in-JS是React编写CSS最为受欢迎的一种解决方案;

目前比较流行的CSS-in-JS的库有哪些呢?

  • styled-components
  • emotion
  • glamorous

目前可以说styled-components依然是社区最流行的CSS-in-JS库,所以我们以styled-components的讲解为主;

安装styled-components:

yarn add styled-components

2.2. styled-components

2.2.1. 标签模板字符串

ES6中增加了模板字符串的语法,这个对于很多人来说都会使用。

但是模板字符串还有另外一种用法:标签模板字符串(Tagged Template Literals)。

我们一起来看一个普通的JavaScript的函数:

function foo(...args) {
  console.log(args);
}

foo("Hello World");

正常情况下,我们都是通过 函数名() 方式来进行调用的,其实函数还有另外一种调用方式:

foo`Hello World`; // [["Hello World"]]

如果我们在调用的时候插入其他的变量:

  • 模板字符串被拆分了;
  • 第一个元素是数组,是被模块字符串拆分的字符串组合;
  • 后面的元素是一个个模块字符串传入的内容;
foo`Hello ${name}`; // [["Hello ", ""], "kobe"];

在styled component中,就是通过这种方式来解析模块字符串,最终生成我们想要的样式的

2.2.2. styled基本使用

styled-components的本质是通过函数的调用,最终创建出一个组件:

  • 这个组件会被自动添加上一个不重复的class;
  • styled-components会给该class添加相关的样式;

比如我们正常开发出来的Home组件是这样的格式:

<div>
  <h2>我是Home标题</h2>
  <ul>
    <li>我是列表1</li>
    <li>我是列表2</li>
    <li>我是列表3</li>
  </ul>
</div>

我们希望给外层的div添加一个特殊的class,并且添加相关的样式:

styled-components基本使用

另外,它支持类似于CSS预处理器一样的样式嵌套:

  • 支持直接子代选择器或后代选择器,并且直接编写样式;
  • 可以通过&符号获取当前元素;
  • 直接伪类选择器、伪元素等;
const HomeWrapper = styled.div`
  color: purple;

  h2 {
    font-size: 50px;
  }

  ul > li {
    color: orange;

    &.active {
      color: red;
    }

    &:hover {
      background: #aaa;
    }

    &::after {
      content: "abc"
    }
  }
`

最终效果如下

2.2.3. props、attrs属性

props可以穿透

定义一个styled组件:

const HYInput = styled.input`
  border-color: red;

  &:focus {
    outline-color: orange;
  }
`

使用styled的组件:

<HYInput type="password"/>

props可以被传递给styled组件

<HomeWrapper color="blue">
</HomeWrapper>

使用时可以获取到传入的color:

  • 获取props需要通过${}传入一个插值函数,props会作为该函数的参数;
  • 这种方式可以有效的解决动态样式的问题;
const HomeWrapper = styled.div`
  color: ${props => props.color};
}

添加attrs属性

const HYInput = styled.input.attrs({
  placeholder: "请填写密码",
  paddingLeft: props => props.left || "5px"
})`
  border-color: red;
  padding-left: ${props => props.paddingLeft};

  &:focus {
    outline-color: orange;
  }
`

2.2.4. styled高级特性

支持样式的继承

编写styled组件

const HYButton = styled.button`
  padding: 8px 30px;
  border-radius: 5px;
`

const HYWarnButton = styled(HYButton)`
  background-color: red;
  color: #fff;
`

const HYPrimaryButton = styled(HYButton)`
  background-color: green;
  color: #fff;
`

按钮的使用

<HYButton>我是普通按钮</HYButton>
<HYWarnButton>我是警告按钮</HYWarnButton>
<HYPrimaryButton>我是主要按钮</HYPrimaryButton>

styled设置主题

在全局定制自己的主题,通过Provider进行共享:

import { ThemeProvider } from 'styled-components';

<ThemeProvider theme={{color: "red", fontSize: "30px"}}>
  <Home />
  <Profile />
</ThemeProvider>

在styled组件中可以获取到主题的内容:

const ProfileWrapper = styled.div`
  color: ${props => props.theme.color};
  font-size: ${props => props.theme.fontSize};
`

2.3. classnames

vue中添加class

在vue中给一个元素添加动态的class是一件非常简单的事情:

你可以通过传入一个对象:

<div
  class="static"
  v-bind:class="{ active: isActive, 'text-danger': hasError }"
></div>

你也可以传入一个数组:

<div v-bind:class="[activeClass, errorClass]"></div>

甚至是对象和数组混合使用:

<div v-bind:class="[{ active: isActive }, errorClass]"></div>

react中添加class

React在JSX给了我们开发者足够多的灵活性,你可以像编写JavaScript代码一样,通过一些逻辑来决定是否添加某些class:

import React, { PureComponent } from 'react'

export default class App extends PureComponent {
  constructor(props) {
    super(props);

    this.state = {
      isActive: true
    }
  }

  render() {
    const {isActive} = this.state; 

    return (
      <div>
        <h2 className={"title " + (isActive ? "active": "")}>我是标题</h2>
        <h2 className={["title", (isActive ? "active": "")].join(" ")}>我是标题</h2>
      </div>
    )
  }
}

这个时候我们可以借助于一个第三方的库:classnames

  • 很明显,这是一个用于动态添加classnames的一个库。

我们来使用一下最常见的使用案例:

classNames('foo', 'bar'); // => 'foo bar'
classNames('foo', { bar: true }); // => 'foo bar'
classNames({ 'foo-bar': true }); // => 'foo-bar'
classNames({ 'foo-bar': false }); // => ''
classNames({ foo: true }, { bar: true }); // => 'foo bar'
classNames({ foo: true, bar: true }); // => 'foo bar'

// lots of arguments of various types
classNames('foo', { bar: true, duck: false }, 'baz', { quux: true }); // => 'foo bar baz quux'

// other falsy values are just ignored
classNames(null, false, 'bar', undefined, 0, 1, { baz: null }, ''); // => 'bar 1'

我是@半糖学前端 ,专注前端技术领域分享,关注我和我一起学习,共同进步!

题中我们提出一个问题:react 代码如何跑在小程序上?目前看来大致两种思路:


1. 把 react 代码编译成小程序代码,这样我们可以开发用 react,然后跑起来还是小程序原生代码,结果很完美,但是把 react 代码编译成各个端的小程序代码是一个力气活,而且如果想用 vue 来开发的话,那么还需要做一遍 vue 代码的编译,这是 taro 1/2 的思路。


2. 我们可以换个问题思考,react 代码是如何跑在浏览器里的?

  • 站在浏览器的角度来思考:无论开发用的是什么框架,React 也好,Vue 也罢,最终代码经过运行之后都是调用了浏览器的那几个 BOM/DOM 的 API ,如:createElement、appendChild、removeChild 等。
  • Taro 3 主要通过在小程序端模拟实现 DOM、BOM API 来让前端框架直接运行在小程序环境中。


下面我们具体看看各自的实现。


Taro 1/2

Taro 1/2 的架构主要分为:编译时 和 运行时。


其中编译时主要是将 Taro 代码通过 Babel 转换成 小程序的代码,如:JS、WXML、WXSS、JSON。


运行时主要是进行一些:生命周期、事件、data 等部分的处理和对接。


Taro 编译时

Taro 的编译,使用 babel-parser 将 Taro 代码解析成抽象语法树,然后通过 babel-types 对抽象语法树进行一系列修改、转换操作,最后再通过 babel-generate 生成对应的目标代码。

整个编译时最复杂的部分在于 JSX 编译。


我们都知道 JSX 是一个 JavaScript 的语法扩展,它的写法千变万化,十分灵活。这里我们是采用 穷举 的方式对 JSX 可能的写法进行了一一适配,这一部分工作量很大,实际上 Taro 有大量的 Commit 都是为了更完善的支持 JSX 的各种写法。


Taro 运行时

接下来,我们可以对比一下编译后的代码,可以发现,编译后的代码中,React 的核心 render 方法 没有了。同时代码里增加了 BaseComponent 和 createComponent ,它们是 Taro 运行时的核心。

// 编译前
import Taro, { Component } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import './index.scss'


export default class Index extends Component {


  config={
    navigationBarTitleText: '首页'
  }


  componentDidMount () { }


  render () {
    return (
      <View className=‘index' onClick={this.onClick}>
        <Text>Hello world!</Text>
      </View>
    )
  }
}


// 编译后
import {BaseComponent, createComponent} from '@tarojs/taro-weapp'


class Index extends BaseComponent {


// ...


  _createDate(){
    //process state and props
  }
}


export default createComponent(Index)


BaseComponent 主要是对 React 的一些核心方法:setState、forceUpdate 等进行了替换和重写,结合前面编译后 render 方法被替换,大家不难猜出:Taro 当前架构只是在开发时遵循了 React 的语法,在代码编译之后实际运行时,和 React 并没有关系。


而 createComponent 主要作用是调用 Component() 构建页面;对接事件、生命周期等;进行 Diff Data 并调用 setData 方法更新数据。


这样的实现过程有三?缺点:

  • JSX ?持程度不完美。Taro 对 JSX 的?持是通过编译时的适配去实现的,但 JSX ??常之灵活,因此还不能做到 100% ?持所有的 JSX 语法。JSX 是一个 JavaScript 的语法扩展,它的写法千变万化,十分灵活。之前Taro团队是采用穷举的方式对 JSX 可能的写法进行了一一适配,这一部分工作量很大。
  • 不?持 source-map。Taro 对源代码进?了?系列的转换操作之后,就不?持 source-map 了,?户 调试、使?这个项?就会不?便。
  • 维护和迭代?分困难。Taro 编译时代码?常的复杂且离散,维护迭代都?常的困难。


Taro 3

Taro 3 则可以大致理解为解释型架构(相对于 Taro 1/2 而言),主要通过在小程序端模拟实现 DOM、BOM API 来让前端框架直接运行在小程序环境中,从而达到小程序和 H5 统一的目的。


而对于生命周期、组件库、API、路由等差异,依然可以通过定义统一标准,各端负责各自实现的方式来进行抹平。


而正因为 Taro 3 的原理,在 Taro 3 中同时支持 React、Vue 等框架,甚至还支持了 jQuery,还能支持让开发者自定义地去拓展其他框架的支持,比如 Angular,Taro 3 整体架构如下:



模拟实现 DOM、BOM API

Taro 3 创建了 taro-runtime 的包,然后在这个包中实现了 一套 高效、精简版的 DOM/BOM API(下面的 UML 图只是反映了几个主要的类的结构和关系):



  • TaroEventTarget类,实现addEventListener和removeEventListener。
  • TaroNode类继承TaroEventTarget类,主要实现insertBefore、appendChild等操作 Dom 节点的方法。下面在页面渲染我们会具体看这几个方法的实现。
  • TaroElement类继承TaroNode类,主要是节点属性相关的方法和dispatchEvent方法,dispatchEvent方法在下面讲事件触发的时候也会涉及到。
  • TaroRootElement类继承TaroElement类,其中最主要是enqueueUpdate和performUpdate,把虚拟 DOM setData 成小程序 data 的操作就是这两个函数。


然后,我们通过 Webpack 的 ProvidePlugin 插件,注入到小程序的逻辑层。


Webpack ProvidePlugin 是一个 webpack 自带的插件,用于在每个模块中自动加载模块,而无需使用 import/require 调用。该插件可以将全局变量注入到每个模块中,避免在每个模块中重复引用相同的依赖。

// trao-mini-runner/src/webpack/build.conf.ts
plugin.providerPlugin=getProviderPlugin({
  window: ['@tarojs/runtime', 'window'],
  document: ['@tarojs/runtime', 'document'],
  navigator: ['@tarojs/runtime', 'navigator'],
  requestAnimationFrame: ['@tarojs/runtime', 'requestAnimationFrame'],
  cancelAnimationFrame: ['@tarojs/runtime', 'cancelAnimationFrame'],
  Element: ['@tarojs/runtime', 'TaroElement'],
  SVGElement: ['@tarojs/runtime', 'SVGElement'],
  MutationObserver: ['@tarojs/runtime', 'MutationObserver'],
  history: ['@tarojs/runtime', 'history'],
  location: ['@tarojs/runtime', 'location'],
  URLSearchParams: ['@tarojs/runtime', 'URLSearchParams'],
  URL: ['@tarojs/runtime', 'URL'],
})


// trao-mini-runner/src/webpack/chain.ts
export const getProviderPlugin=args=> {
  return partial(getPlugin, webpack.ProvidePlugin)([args])
}


这样,在小程序的运行时,就有了 一套高效、精简版的 DOM/BOM API。


taro-react:小程序版的 react-dom

在 DOM/BOM 注入之后,理论上来说,react 就可以直接运行了。


但是因为 React-DOM 包含大量浏览器兼容类的代码,导致包太大。Taro 自己实现了 react 的自定义渲染器,代码在taro-react包里。


在 React 16+ ,React 的架构如下:


最上层是 React 的核心部分 react-core ,中间是 react-reconciler,其的职责是维护 VirtualDOM 树,内部实现了 Diff/Fiber 算法,决定什么时候更新、以及要更新什么。


而 Renderer 负责具体平台的渲染工作,它会提供宿主组件、处理事件等等。例如 React-DOM 就是一个渲染器,负责 DOM 节点的渲染和 DOM 事件处理。


Taro实现了taro-react 包,用来连接 react-reconciler 和 taro-runtime 的 BOM/DOM API。是基于 react-reconciler 的小程序专用 React 渲染器,连接 @tarojs/runtime的DOM 实例,相当于小程序版的react-dom,暴露的 API 也和react-dom 保持一致。


这里涉及到一个问题:如何自定义 React 渲染器?


第一步: 实现宿主配置( 实现react-reconciler的hostConfig配置)

这是react-reconciler要求宿主提供的一些适配器方法和配置项。这些配置项定义了如何创建节点实例、构建节点树、提交和更新等操作。即在 hostConfig 的方法中调用对应的 Taro BOM/DOM 的 API。


1. 创建形操作

createInstance(type,newProps,rootContainerInstance,_currentHostContext,workInProgress)。


react-reconciler 使用该方法可以创建对应目标平台的UI Element实例。比如 document.createElement 根据不同类型来创建 div、img、h2等DOM节点,并使用 newProps参数给创建的节点赋予属性。而在 Taro 中:

import { document } from '@tarojs/runtime'
// 在 ReactDOM 中会调用 document.createElement 来生成 dom,
// 而在小程序环境中 Taro 中模拟了 document,
// 直接返回 `document.createElement(type)` 即可
createInstance (type, props: Props, _rootContainerInstance: any, _hostContext: any, internalInstanceHandle: Fiber) {
  const element=document.createElement(type)


  precacheFiberNode(internalInstanceHandle, element)
  updateFiberProps(element, props)


  return element
},


createTextInstance

如果目标平台允许创建纯文本节点。那么这个方法就是用来创建目标平台的文本节点。

import { document } from '@tarojs/runtime'
// Taro: 模拟的 document 支持创建 text 节点, 返回 `document.createTextNode(text)` 即可.
createTextInstance (text: string, _rootContainerInstance: any, _hostContext: any, internalInstanceHandle: Fiber) {
  const textNode=document.createTextNode(text)


  precacheFiberNode(internalInstanceHandle, textNode)


  return textNode
},


2. UI树操作

appendInitialChild(parent, child)

初始化UI树创建。

// Taro: 直接 parentInstance.appendChild(child) 即可
appendInitialChild (parent, child) {
  parent.appendChild(child)
},


appendChild(parent, child)

此方法映射为 domElement.appendChild 。

appendChild (parent, child) {
  parent.appendChild(child)
},


3. 更新prop操作

finalizeInitialChildren

finalizeInitialChildren 在组件挂载到页面中前调用,更新时不会调用。


这个方法我们下面事件注册时还会提到。

finalizeInitialChildren (dom, type: string, props: any) {
  updateProps(dom, {}, props) 
  // 提前执行更新属性操作,Taro 在 Page 初始化后会立即从 dom 读取必要信息


  // ....
},


prepareUpdate(domElement, oldProps, newProps)

这里是比较oldProps,newProps的不同,用来判断是否要更新节点。

prepareUpdate (instance, _, oldProps, newProps) {
  return getUpdatePayload(instance, oldProps, newProps)
},


// ./props.ts
export function getUpdatePayload (dom: TaroElement, oldProps: Props, newProps: Props){
  let i: string
  let updatePayload: any[] | null=null


  for (i in oldProps) {
    if (!(i in newProps)) {
      (updatePayload=updatePayload || []).push(i, null)
    }
  }
  const isFormElement=dom instanceof FormElement
  for (i in newProps) {
    if (oldProps[i] !==newProps[i] || (isFormElement && i==='value')) {
      (updatePayload=updatePayload || []).push(i, newProps[i])
    }
  }


  return updatePayload
}


commitUpdate(domElement, updatePayload, type, oldProps, newProps)

此函数用于更新domElement属性,下文要讲的事件注册就是在这个方法里。

// Taro: 根据 updatePayload,将属性更新到 instance 中,
// 此时 updatePayload 是一个类似 `[prop1, value1, prop2, value2, ...]` 的数组


commitUpdate (dom, updatePayload, _, oldProps, newProps) {
  updatePropsByPayload(dom, oldProps, updatePayload)
  updateFiberProps(dom, newProps)
},


export function updatePropsByPayload (dom: TaroElement, oldProps: Props, updatePayload: any[]){
  for(let i=0; i < updatePayload.length; i +=2){ // key, value 成对出现
    const key=updatePayload[i]; 
    const newProp=updatePayload[i+1]; 
    const oldProp=oldProps[key]
    setProperty(dom, key, newProp, oldProp)
  }
}
function setProperty (dom: TaroElement, name: string, value: unknown, oldValue?: unknown) {
  name=name==='className' ? 'class' : name


  if (
    name==='key' ||
    name==='children' ||
    name==='ref'
) {
    // skip
  } else if (name==='style') {
    const style=dom.style
    if (isString(value)) {
      style.cssText=value
    } else {
      if (isString(oldValue)) {
        style.cssText=''
        oldValue=null
      }


      if (isObject<StyleValue>(oldValue)) {
        for (const i in oldValue) {
          if (!(value && i in (value as StyleValue))) {
            setStyle(style, i, '')
          }
        }
      }


      if (isObject<StyleValue>(value)) {
        for (const i in value) {
          if (!oldValue || value[i] !==(oldValue as StyleValue)[i]) {
            setStyle(style, i, value[i])
          }
        }
      }
    }
  } else if (isEventName(name)) {
    setEvent(dom, name, value, oldValue)
  } else if (name==='dangerouslySetInnerHTML') {
    const newHtml=(value as DangerouslySetInnerHTML)?.__html ?? ''
    const oldHtml=(oldValue as DangerouslySetInnerHTML)?.__html ?? ''
    if (newHtml || oldHtml) {
      if (oldHtml !==newHtml) {
        dom.innerHTML=newHtml
      }
    }
  } else if (!isFunction(value)) {
    if (value==null) {
      dom.removeAttribute(name)
    } else {
      dom.setAttribute(name, value as string)
    }
  }
}


上面是hostConfig里必要的回调函数的实现,源码里还有很多回调函数的实现,详见trao-react源码。


第二步:实现渲染函数,类似于ReactDOM.render() 方法。可以看成是创建 Taro DOM Tree 容器的方法。

源码实现详见trao-react/src/render.ts。

export function render (element: ReactNode, domContainer: TaroElement, cb: Callback) {
  const root=new Root(TaroReconciler, domContainer)
  return root.render(element, cb)
}
export function createRoot (domContainer: TaroElement, options: CreateRootOptions={}) {
  // options should be an object
  const root=new Root(TaroReconciler, domContainer, options)
  // ......
  return root
}
class Root {
  public constructor (renderer: Renderer, domContainer: TaroElement, options?: CreateRootOptions) {
    this.renderer=renderer
    this.initInternalRoot(renderer, domContainer, options)
  }
  private initInternalRoot (renderer: Renderer, domContainer: TaroElement, options?: CreateRootOptions) {
    // .....
    this.internalRoot=renderer.createContainer(
      containerInfo,
      tag,
      null, // hydrationCallbacks
      isStrictMode,
      concurrentUpdatesByDefaultOverride,
      identifierPrefix,
      onRecoverableError,
      transitionCallbacks
    )
  }
  public render (children: ReactNode, cb: Callback) {
    const { renderer, internalRoot }=this
    renderer.updateContainer(children, internalRoot, null, cb)
    return renderer.getPublicRootInstance(internalRoot)
  }
}


而 Root 类最后调用TaroReconciler的createContainr``updateContainer和 getPublicRootInstance 方法,实际上就是react-reconciler包里面对应的方法。


渲染函数是在什么时候被调用的呢?

在编译时,会引入插件taro-plugin-react, 插件内会调用 modifyMiniWebpackChain=> setAlias。

// taro-plugin-react/src/webpack.mini.ts
function setAlias (ctx: IPluginContext, framework: Frameworks, chain) {
  if (framework==='react') {
    alias.set('react-dom$', '@tarojs/react')
  }
}


这样ReactDOM.createRoot和ReactDOM.render实际上调用的就是trao-react的createRoot和render方法。


经过上面的步骤,React 代码实际上就可以在小程序的运行时正常运行了,并且会生成 Taro DOM Tree。那么偌大的 Taro DOM Tree 怎样更新到页面呢?


从虚拟 Dom 到小程序页面渲染

因为?程序并没有提供动态创建节点的能?,需要考虑如何使?相对静态的 wxml 来渲染相对动态的 Taro DOM 树。Taro使?了模板拼接的?式,根据运?时提供的 DOM 树数据结构,各 templates 递归地 相互引?,最终可以渲染出对应的动态 DOM 树。


模版化处理

首先,将小程序的所有组件挨个进行模版化处理,从而得到小程序组件对应的模版。如下图就是小程序的 view 组件模版经过模版化处理后的样子。?先需要在 template ??写?个 view,把它所有的属性全部列出来(把所有的属性都列出来是因为?程序??不能去动态地添加属性)。


模板化处理的核心代码在 packages/shared/src/template.ts 文件中。会在编译工程中生成 base.wxml文件,这是我们打包产物之一。

// base.wxml
<wxs module="xs" src="./utils.wxs" />
<template name="taro_tmpl">
  <block wx:for="{{root.cn}}" wx:key="sid">
    // tmpl_' + 0 + '_' + 2
    <template is="{{xs.a(0, item.nn, '')}}" data="{{i:item,c:1,l:''}}" />
  </block>
</template>
....
<template name="tmpl_0_2">
  <view style="{{i.st}}" class="{{i.cl}}"  id="{{i.uid||i.sid}}" data-sid="{{i.sid}}">
    <block wx:for="{{i.cn}}" wx:key="sid">
      <template is="{{xs.a(c, item.nn, l)}}" data="{{i:item,c:c+1,l:xs.f(l,item.nn)}}" />
    </block>
  </view>
</template>


打包产生的页面代码是这样的:

// pages/index/index.wxml
<import src="../../base.wxml"/>
<template is="taro_tmpl" data="{{root:root}}" />


接下来是遍历渲染所有?节点,基于组件的 template,动态 “递归” 渲染整棵树。


具体流程为先去遍历 Taro DOM Tree 根节点的子元素,再根据每个子元素的类型选择对应的模板来渲染子元素,然后在每个模板中我们又会去遍历当前元素的子元素,以此把整个节点树递归遍历出来。


hydrate Data

而动态递归时需要获取到我们的 data,也就是 root。


首先,在 createPageConfig 中会对 config.data 进行初始化,赋值 {root:{cn:[]}}。

export function createPageConfig (component: any, pageName?: string, data?: Record<string, unknown>, pageConfig?: PageConfig) {
  // .......
  if (!isUndefined(data)) {
    config.data=data
  }
  // .......
}


React在commit阶段会调用HostConfig里的appendInitialChild方法完成页面挂载,在Taro中则继续调用:appendInitialChild —> appendChild —> insertBefore —> enqueueUpdate。

// taro-react/src/reconciler.ts
appendInitialChild (parent, child) {
  parent.appendChild(child)
},
appendChild (parent, child) {
  parent.appendChild(child)
},
// taro-runtime/src/dom/node.ts
public appendChild (newChild: TaroNode) {
  return this.insertBefore(newChild)
}
public insertBefore<T extends TaroNode> (newChild: T, refChild?: TaroNode | null, isReplace?: boolean): T {
  // 忽略了大部分代码
  this.enqueueUpdate({
    path: newChild._path,
    value: this.hydrate(newChild)
  })


  return newChild
}


这里看到最终调用enqueueUpdate方法,传入一个对象,值为 path 和 value,而 value 值是hydrate方法的结果。


hydrate方法我们可以翻译成“注水”,函数 hydrate 用于将虚拟 DOM(TaroElement 或 TaroText)转换为小程序组件渲染所需的数据格式(MiniData)。


回想一下小程序员生的 data 里都是我们页面需要的 state,而 taro 的hydrate方法返回的 miniData 是把 state 外面在包裹上我们页面的 node 结构值。举例来看,我们一个 helloword 代码所hydrate的 miniData 如下(可以在小程序IDE中的 ”AppData“ 标签栏中查看到完整的data数据结构):

{
  "root": {
    "cn": [
      {
        "cl": "index",
        "cn": [
          {
            "cn": [
              {
                "nn": "8",
                "v": "Hello world!"
              }
            ],
            "nn": "4",
            "sid": "_AH"
          },
          {
            "cn": [
              {
                "nn": "8",
                "v": "HHHHHH"
              }
            ],
            "nn": "2",
            "sid": "_AJ"
          },
          {
            "cl": "blue",
            "cn": [
              {
                "nn": "8",
                "v": "Page bar: "
              },
              {
                "cl": "red",
                "cn": [
                  {
                    "nn": "8",
                    "v": "red"
                  }
                ],
                "nn": "4",
                "sid": "_AM"
              }
            ],
            "nn": "4",
            "sid": "_AN"
          }
        ],
        "nn": "2",
        "sid": "_AO"
      }
    ],
    "uid": "pages/index/index?$taroTimestamp=1691064929701"
  },
  "__webviewId__": 1
}


这里的字段含义解释一下 :(我想这里缩写是可能尽可能让每一次setData的内容更小。)

Container='container',
Childnodes='cn',
Text='v',
NodeType='nt',
NodeName='nn',


// Attrtibutes
Style='st',
Class='cl',
Src='src


我们获取到以上的 data 数据,去执行enqueueUpdate函数,enqueueUpdate函数内部执行performUpdate函数,performUpdate函数最终执行 ctx.setData,ctx 是小程序的实例,也就是执行我们熟悉的 setData 方法把上面hydrate的 miniData赋值给 root,这样就渲染了小程序的页面数据。

// taro-runtime/src/dom/root.ts
public enqueueUpdate (payload: UpdatePayload): void {
  this.updatePayloads.push(payload)


  if (!this.pendingUpdate && this.ctx) {
    this.performUpdate()
  }
}


public performUpdate (initRender=false, prerender?: Func) {
  // .....
  while (this.updatePayloads.length > 0) {
    const { path, value }=this.updatePayloads.shift()!
    if (path.endsWith(Shortcuts.Childnodes)) {
      resetPaths.add(path)
    }
    data[path]=value
  }
  // .......
  if (initRender) {
    // 初次渲染,使用页面级别的 setData
    normalUpdate=data
  }
  // ........
  ctx.setData(normalUpdate, cb)
}


整体流程可以概括为:当在React中调用 this.setState 时,React内部会执行reconciler,进而触发 enqueueUpdate 方法,如下图:



事件处理

事件注册

在HostConfig接口中,有一个方法 finalizeInitialChildren,在这个方法里会调用updateProps。这是挂载页面阶段时间的注册时机。updateProps 会调用 updatePropsByPayload 方法。

finalizeInitialChildren (dom, type: string, props: any) {
  updateProps(dom, {}, props)


  //....
},


在HostConfig接口中,有一个方法 commitUpdate,用于在react的commit阶段更新属性:

commitUpdate (dom, updatePayload, _, oldProps, newProps) {
  updatePropsByPayload(dom, oldProps, updatePayload)
  updateFiberProps(dom, newProps)
},


进一步的调用方法:updatePropsByPayload=> setProperty=> setEvent。

// taro-react/src/props.ts
function setEvent (dom: TaroElement, name: string, value: unknown, oldValue?: unknown) {
  const isCapture=name.endsWith('Capture')
  let eventName=name.toLowerCase().slice(2)
  if (isCapture) {
    eventName=eventName.slice(0, -7)
  }


  const compName=capitalize(toCamelCase(dom.tagName.toLowerCase()))


  if (eventName==='click' && compName in internalComponents) {
    eventName='tap'
  }
  // 通过addEventListener将事件注册到dom中
  if (isFunction(value)) {
    if (oldValue) {
      dom.removeEventListener(eventName, oldValue as any, false)
      dom.addEventListener(eventName, value, { isCapture, sideEffect: false })
    } else {
      dom.addEventListener(eventName, value, isCapture)
    }
  } else {
    dom.removeEventListener(eventName, oldValue as any)
  }
}


进一步的看看dom.addEventListener做了什么?addEventListener是类TaroEventTarget的方法:

export class TaroEventTarget {
  public __handlers: Record<string, EventHandler[]>={}


  public addEventListener (type: string, handler: EventHandler, options?: boolean | AddEventListenerOptions) {
    type=type.toLowerCase()


    // 省略很多代码


    const handlers=this.__handlers[type]
    if (isArray(handlers)) {
      handlers.push(handler)
    } else {
      this.__handlers[type]=[handler]
    }
  }
}


可以看到事件会注册到dom对象上,最终会放入到 dom 内部变量 __handlers 中保存。


事件触发

// base.wxml
<template name="tmpl_0_7">
  <view
    hover-class="{{xs.b(i.p1,'none')}}"
    hover-stop-propagation="{{xs.b(i.p4,!1)}}"
    hover-start-time="{{xs.b(i.p2,50)}}"
    hover-stay-time="{{xs.b(i.p3,400)}}"
    bindtouchstart="eh"
    bindtouchmove="eh"
    bindtouchend="eh"
    bindtouchcancel="eh"
    bindlongpress="eh"
    animation="{{i.p0}}"
    bindanimationstart="eh"
    bindanimationiteration="eh"
    bindanimationend="eh"
    bindtransitionend="eh"
    style="{{i.st}}"
    class="{{i.cl}}"
    bindtap="eh"
    id="{{i.uid||i.sid}}"
    data-sid="{{i.sid}}"
  >
    <block wx:for="{{i.cn}}" wx:key="sid">
      <template is="{{xs.a(c, item.nn, l)}}" data="{{i:item,c:c+1,l:xs.f(l,item.nn)}}" />
    </block>
  </view>
</template>


上面是base.wxml其中的一个模板,可以看到,所有组件中的事件都会由 eh 代理。在createPageConfig时,会将 config.eh 赋值为 eventHandler。

// taro-runtime/src/dsl/common.ts
function createPageConfig(){
    const config={...} // config会作为小程序 Page() 的入参
    config.eh=eventHandler
    config.data={root:{cn:[]}}
    return config
}


eventHandler 最终会触发 dom.dispatchEvent(e)。

// taro-runtime/src/dom/element.ts
class TaroElement extends TaroNode {
    dispatchEvent(event){
        const listeners=this.__handlers[event.type]  // 取出回调函数数组
        for (let i=listeners.length; i--;) {
            result=listener.call(this, event)  // event是TaroEvent实例
        }
    }
}


至此,react 代码终于是可以完美运行在小程序环境中。


还要提到一点的是,Taro3 在 h5 端的实现也很有意思,Taro在 H5 端实现一套基于小程序规范的组件库和 API 库,在这里就不展开说了。


总结

Taro 3从之前的重编译时,到现在的重运行时,解决了架构问题,可以用 react、vue 甚至 jQuery 来写小程序,但也带来了一些性能问题。


为了解决性能问题,Taro 3 也提供了预渲染和虚拟列表等功能和组件。


但从长远来看,计算机硬件的性能越来越冗余,如果在牺牲一点可以容忍的性能的情况下换来整个框架更大的灵活性和更好的适配性,并且能够极大的提升开发体验,也是值得的。

作者:孟祥辉

来源:微信公众号:哈啰技术

出处:https://mp.weixin.qq.com/s/134VAXPJczElvdYzNFcHhA