Dive in React(未完待续)

函数式组件思考

关于函数式编程的各种概念一直没有总结一下,这里我根据我自己的理解把React函数式编程和Hooks的本质分析一下。

React选择函数式编程的原因

在面向对象编程中,一个好的设计架构应该将各种通过接口适当抽象出来隔离并分别实现,这样的设计Flexibility和Scalability都是非常高的。但是在一些情况下,不同的继承树内很难将其通用代码提取出来重用。

使用函数式编程的一个好处就是可以完全且方便的将各种逻辑抽象成独立函数并提供给不同的组件使用,每一部分代码逻辑都可以封装为函数后通过参数传递给其他函数使用。这种Composition模式在ReactMentalModel中也提及:

To achieve truly reusable features, it is not enough to simply reuse leaves and build new containers for them. You also need to be able to build abstractions from the containers that compose other abstractions. The way I think about “composition” is that they’re combining two or more different abstractions into a new one.

译文:为了实现真正的可重用性,仅仅依靠重用叶子函数并为他们构建新的容器函数是不够的。你也需要能够构建容纳其他的容器函数的Compose函数。我认为Composition应该是将多个抽象组合为一个新对象。

function FancyBox(children) {
  return {
    borderStyle: '1px solid blue',
    children: children
  };
}

function UserBox(user) {
  return FancyBox([
    'Name: ',
    NameBox(user.firstName + ' ' + user.lastName)
  ]);
}

函数(React使用场景下)

函数,从小我们都接触函数,数学中的函数是指将一组输入映射为另一组输出,且这种映射条件在输入确定时输出恒等。而这里,我们将这种内部不存储状态不会产生SideEffect的组件,即类似数学上的函数的函数组件成为纯函数组件(Pure functional component)。但是在实际应用下,大部分情况函数组件都是需要将一部分输入条件经过一定转换后存储到函数作用域内部或对函数外部对象状态进行改变(SideEffect),即函数式编程大部分情况需要拥有保存状态或改变域外对象状态的能力。在组件初始化后,函数组件应可以拥有初始状态,且该状态可在函数多次执行过程中发生改变,只有在该组件被解构时该状态才会销毁。

而除纯函数组件另外的组件拥有内部状态或会产生SideEffect,在常规情况下是无法直接通过函数组件不加API实现的。因为纯函数组件的局限性,React通过引入Hook,将以上两个问题通过一种非常巧妙的方式解决了。

Hooks相当于函数式编程领域内通往面向对象式编程的一个EscapeHatch(后门?)。通过Hooks系统,函数组件可以完成在面向对象式编程中大部分的能力。

React官方内置的Hooks都比较语义化,可以通过器名称非常清晰的了解到具体的用途,这也是定义Hook时需要注意的一点。React中的各种Hooks将面向对象编程中对象拥有的能力赋予函数式编程,让函数式编程真正可以大范围的实践。

渲染

React中render这个词是非常常用的,但在写完文章前半部分后我才突然意识到需要现在这里对React内的rerender和浏览器paint进行一下区分。

React Render

在日常使用React的过程中,我们通常会把一次函数组件执行成为函数组件进行了一次Render渲染。单纯从使用者的角度理解点击一次鼠标,受影响的函数组件应该只被渲染一次,达到我们最终需要渲染到屏幕上的状态。而我们常规情况下将一次函数执行就称为一次函数组件渲染,这里的渲染是指将数据绘制到屏幕上。但实际上React可能在一次绘制屏幕之前将函数组件执行了多次执行,因此我们需要将React函数组件的render和实际的浏览器paint进行一下区分。

React中的Render(渲染),在这里我们需要理解为对于VDOM(虚拟DOM)树的一次子树上的节点修改。我们再来细化一下这个”子树上的节点一次修改“,首先子树表明是一颗树上的更新,即从某一枝干节点开始向其子节点传播更新,一次修改则表明如果在本次更新中还产生了其他数据更新,则会再次触发rerender。

这里再细化一下,既然是VDOM树上一次修改,就应该有一个触发该修改的事件或源头,我们把由同一个事件触发的一次更新叫做一次render,例如点击按钮Counter中count状态更新,这就是一次render。而Counter中以count状态伪依赖的useEffect导致的另一次状态更新成为另一次render。

rerender重渲染顾名思义,Re前缀是重复的意思,而在上面举例的这种情况,如果在一次浏览器绘制前,某一个组件发生多次渲染(执行),则该组件经历了rerender。

需要注意的是React使用schedule的方法执行render,因此在时会有将一个多次渲染任务分割为小任务,第一次进行两次render而后绘制的任务

这里我们总结一下就可以得出,一次最终的浏览器渲染(browser paint)结果之前会有由多次数据更新触发的多次rerender,因此一个函数组件在两次浏览器渲染(browser paint)间的React操作中可能执行多次rerender,从代码执行上来比较简单的说法就是函数组件将会多次执行,因此如果你在函数组件函数体内打印数据,就会发现可能会在一次browser paint中有多次rerender。这里以后文中useEffect例子为例。

为了减小复杂度,我们暂时以在首屏渲染结束后点击Add键为例:

点击Add键,点击事件在Counter函数组件内emit,而后会将Counter组件中count状态加一,因此Counter组件中状态发生了变化,应该Counter及其子组件重渲染。这里就可以看出React中的Render和浏览器Paint的区别,一个是更新VDOM树而另一个是绘制屏幕。

点击Add后控制台输出

分析:

首先执行了Count和Box函数体,然后将所有SideEffect清除函数由底向上执行。然后从受影响的树底部开始按函数体内定义顺序执行应该执行的useEffect函数,而又由于在 useEffect:In Counter dep=count中对effectUpdateCount状态进行更新,所以再次重复执行了一遍除该useEffect外的上述流程。

这里我们可以简单的把整个流程分为两个类似的部分和每个部分则有三个阶段。第一部分是更新点击事件影响的子树,即setCount影响的Counter及其子组件渲染,第二部分是受Counter useEffect deps=count中setEffectUpdateCount的Counter及其子组件渲染。每个部分可以分为以下三个阶段:

  • 函数组件执行阶段
  • Effect
    • 受影响SideEffect清除执行阶段
    • 受影响SideEffect重新执行阶段

可以看到Effect在函数组件函数体执行后执行,我们可以猜测在执行函数体时,useEffect等Hook会被存入一个顺序表内,这样React在执行玩函数体后可以从顺序表内取出这些useEffect Hooks,而后按顺序执行。

我们使用Chrome和ReactDevTools里Performance工具对上面这个例子的渲染进行记录,首先我们会发现从页面加载到首屏渲染,React经历了2次Render(不是paint而是修改VDOM树修改),但由于Performance工具只会记录耗时较长的函数调用所以一些HTML原生组件例如div的VDOM生成时间较短就可能会被忽略掉。但我们编写的函数组件由于其中有一些比较耗时的逻辑,比如console.log、useState等因此可以直接从Performance上的图表看到。

Chrome Profiler

由于React执行在直接渲染原始组件比如div等时速度较快,Performance Profiler在开启6倍CPU降速也难以捕捉,因此这里大部分显示的是其中比较耗时的用户逻辑和ReactDOM的操作逻辑。

Add点击事件React背后的执行栈
第一个执行栈

我们将左侧的执行栈放大,明显可以看到performSyncWorkOnRoot函数下有两个主要的部分,第一个工作循环WorkLoop,第二个是commitRoot。由于已经有上面关于render的介绍,我们可以猜测这里的WorkLoop中执行的是对VDOM树的修改,而commitRoot是将变化提交到真正的DOM树上。

我们可以看到整个点击事件导致了三个主要的执行栈,我们来一个一个理清楚:

第一个执行栈

这里解释一下,React中一次render是以一次completeUnitOfWork为基准,而一次Browser Paint简单点可以说是workLoop中的任务consume完后的数据commitRoot。

可以看到主要分为两个部分第一部分是左侧从runWithPriority$1开始的以dispatch为主的执行栈,右侧是以flushSyncCallbackQueue为主的flush执行栈。

左侧实际是React内部处理点击事件并将点击事件dispatch到对应触发的callback上的逻辑。下面是上图左侧react-click事件执行栈。其中的匿名函数实际是我在Counter的Add按钮上设置的onClick函数,他调用了setCount函数用于更新count的数值。

右侧的执行栈则是setCount所造成的影响,下图中是其中关于用户代码的两部分,即由于count数据的更新,导致Counter组件的重渲染,而同时Counter的子组件也受到影响进行了重渲染(没有加memo)。这里是Counter和Box的第一次渲染,也是上面控制台输出重最前面两条。

第二个执行栈

这个执行栈非常复杂,但也是useEffect Hook开始起效的位置,我们将这个执行栈分为两部分,第一部分为左侧的useEffect执行栈,第二部分是右侧effected执行栈。

1.useEffect执行栈

我们可以看到一共有五个独立的小执行栈都调用了Log,这里我们可以通过控制台输出来判断其所处的逻辑:

可以看到前两个小执行栈是在调用Box和Counter内受影响的useEffect 清除回调函数(Counter中依赖列表为count的useEffect并没有返回SideEffect清除函数,所以没有调用。如果这里返回了一个类似的清除函数,在这里会在两个清除函数控制台打印中间出现对应的log),而后再将受影响的useEffect重新执行一次。

useEffect的SideEffect清除回调执行
受影响的useEffect重新执行

左边的执行栈为Box中useEffect执行,而中间为Counter中依赖列表为count的useEffect执行,同时内部由于setEffectUpdateCount,所以除了Log函数外还有一个这个statusSetter内部的dispatch执行(后面会介绍useState实际上是useReducer的一个简化版,这里实际是调用的useReducer的内部逻辑所以有dispatch字样)。这里同时标记节点树的状态不稳定,需要进行Reconciliation调谐。右侧执行栈是Counter中依赖列表为undefined的useEffect执行。

Counter中依赖列表为count的useEffect

到这里useEffect执行栈就结束了,但由于标记了节点树不稳定,所以后面React会开始进行Reconciliation(调谐),将节点树迁移为最新状态。

2.Effected执行栈

由于标记了节点树状态不稳定,所以需要通过调谐将其节点树更新。下面就是整个调谐的过程

调谐过程

可以看到首先是用updateFunctionComponent更新的Counter函数组件。而后还有一个被折叠updateHostComponent,这里实际上是Counter显示count的子组件。而后是Box子组件

首先我们来看Chrome Profiler。这里我们可以看到React内部是一个WorkLoop在不断consume一些UnitOfWork工作单元,我在写的一个项目LinkCat中Annotation模块也使用了这种架构,以后有时间写个博客。这里一共出现了三个beginWork$1,我们有理由猜测一个beginWork就是一个工作单元的主要执行体。

  • React应用根节点 div#root 节点
  • App组件渲染
  • App根节点 div.App 渲染
  • App组件第一个子组件:button 渲染
  • App组件第二个子组件:Counter 组件渲染
  • Counter组件根节点div渲染
  • Counter组件根节点第一个子节点渲染
  • 文本类型节点“You clicked Button ”渲染
  • 文本类型节点“{count}”渲染
  • 文本类型节点“ times”渲染
  • Counter组件根节点第二个子节点div渲染
  • div第一个子节点Box组件渲染
  • Box组件第一个子节点div
第二第三个工作单元:分别对Counter和Box进行挂载执行

第一个工作单元

第一个工作单元

第一个工作单元是React解析并挂载button原生组件的一个render。实际上是更新了挂载React应用的根节点(不是App的根节点,而是document.getElementById('root')节点)而后挂载了App函数组件。这里在updateHostRoot函数尾部其实还有一个很重要的函数,由于其执行事件太短Profier直接忽略掉了:reconcileChildren函数,这个函数可以看成对子组件的解析,即将子组件解析为VDOM节点。

调谐子组件

上图中reconcileChildren

而后创建了一个App的VDOM,然后将其渲染(执行)一次。这里的Log也对应着控制台第一条Log语句。其中后半部分以jsx开始的函数,实际上是对子组件的解析函数,由于后文会详细介绍,这里就不多拓展。这里也可以看到Console.log这类IO指令直接放到函数体内部对于整个渲染阶段的巨大坏处(BufferedLogger的优点就出来了)。

在这里看到一个beginWork$1结束之后还有一个completeUnitOfWork,下图左侧createInstance栈是App函数组件中button原生组件的DOM元素、Fiber的创建,这里的createElement实际是document.createElement。而后右侧finalizeInitialChildren函数是将initialProperties校验并赋给button DOM元素,这里的情况就是将 onClick函数 和 纯文本类型的children“Toggle” 赋给button 元素。

这里我们理所应当的想到,既然已经解析了button,下一个就应该是他的兄弟节点<Counter />了,因此这里也先简单看一下React怎么进行这个当前节点转移的,下面是completeUnitOfWork函数中在执行完completeWork后的操作,先拿到兄弟节点,然后把WorkInProcess(一个用于存储当前正在处理的VDOM节点的指针)设置为该sibling。

转移当前处理节点指针

在这样操作后程序的控制权转交会workLoopSync(这里因为是在Development模式下,所以为Sync,Production下的React启用Concurrent模式):

然后

在这里JSX被映射为ReactElement,实际内部执行的是React.createElement的逻辑

这里可以看到App函数执行中出现useState,而其内部竟然是代理给了一个叫mountState的函数,这里我们就可以猜测一下既然有mount挂载也应该有update更新和unmount取消挂载。

第二个工作单元

第二个工作单元,即第二个beginWork执行栈,这里是在App根节点的子组件解析完后执行的挂载操作,即将Box挂载到App对应的VDOM的子树上。因此这里会将Box内部函数体执行一遍,所以这里是第二条log出现的位置。

Browser Paint

Browser Paint浏览器渲染,很明显,这是一个将VDOM刷到实际DOM的过程,实际上也是将React分析新节点树与已渲染节点树得出的最小迁移操作执行的过程(不是真正意义上的Flush,但又Flush的迅速的意思)。

useState 状态保存

/**
     * Returns a stateful value, and a function to update it.
     *
     * @version 16.8.0
     * @see https://reactjs.org/docs/hooks-reference.html#usestate
     */
    function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
    type Dispatch<A> = (value: A) => void;
    type SetStateAction<S> = S | ((prevState: S) => S);

useState是用于组件在挂载过程中保存其状态的Hook。通过传入参数确定初始状态,同时返回[status,statusSetter]。其中比较重要的是statusSetter是稳定的(不依赖其创建的作用域),可以将statusSetter通过参数传递给子组件。其实从返回值可以看出,useState其实内部使用的是useReducer,statusSetter实际是一个简化的Dispatcher。

statusSetter中入参为SetStateAction类型,可以看出statusSetter可以直接传入状态值或者一个Reducer函数。

useEffect 执行SideEffect

/**
     * Accepts a function that contains imperative, possibly effectful code.
     *
     * @param effect Imperative function that can return a cleanup function
     * @param deps If present, effect will only activate if the values in the list change.
     *
     * @version 16.8.0
     * @see https://reactjs.org/docs/hooks-reference.html#useeffect
     */
    function useEffect(effect: EffectCallback, deps?: DependencyList): void;
    type EffectCallback = () => (void | Destructor);
    type Destructor = () => void | { [UNDEFINED_VOID_ONLY]: never };
    type DependencyList = ReadonlyArray<any>;

useEffect是用于在函数组件中完成SideEffect,比如订阅数据、获取数据等等。第一个参数是一个无参函数,其返回值可以是这个SideEffect的清除函数。第二个参数是依赖列表。

useEffect的依赖列表可以有以下几种情况:

  • 不传入:每次rerender都执行
  • 传入空数组 [ ] :只有在组件Mount和Unmount时才执行
  • 传入具体依赖列表:只有在依赖列表中对象发送变化时才执行

这里我们通过一个复合案例来考虑下这这几种实际的使用

忽略依赖列表(不传入)

忽略依赖列表,从语义上也可以很明显知道,忽略就是没有限制,即在每次重渲染时都执行effect函数,而且需要注意的是这里执行不是常规理解上的browser paint阶段执行,而是在每一次组件由于输入或其依赖的外部状态发生改变时React执行该函数的一次数据刷新叫“重渲染”。

以React官方的例子来看:

import React, { useState, useEffect } from 'react';

function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);//当订阅的状态发生变化时重置isOnline
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);//这里从ChatAPI中订阅了FriendStatus
    //定义SideEffect清除函数,这里就是取消订阅
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

useMemo useCallback

function useMemo<T>(factory: () => T, deps: DependencyList | undefined): T;
function useCallback<T extends (...args: any[]) => any>(callback: T, deps: DependencyList): T;
type DependencyList = ReadonlyArray<any>;

useCallback和useMemo是语义相近的两的Hook,他们都是只有在Deps发送变化时才变化第一个参数。因为React中用户从界面看到的渲染(paint)结果实际上是多次React内部rerender的结果。例如如果我有一个需求,在鼠标点击两个Add按钮其中一个时Counter组件内部计数器状态+1,而后将这个数据log到控制台。首先不使用useCallback:

点击其中一个Add按钮后,(下图)可以看到没有emit事件的另一个Add按钮也进行了重渲染,进行重渲染只有两个原因:内部状态变化、输入变化,而Button内部没有存储任何状态,直接将props给了button元素。因此只有可能是输入发生了变化。

这里我们再仔细看clickCb回调函数,它实际上在每次Counter组件重渲染时都会recreate一个,因此这里Button组件输入参数onClick发生了变化,触发了其重渲染。一个比较简单的办法就是将clickCb缓存起来,比如将Counter组件Mount时传入Button的clickCb存储起来,而后面都不发生改变,但这样又有一个问题,这个被存储起来的clickCb可以访问的Counter组件数据实际是存储在它的闭包内,而这个闭包是不会因为Counter组件数据更新就发生变动的,因此我们需要在每次clickCb依赖的Counter组件的数据发生变化时将缓存的clickCb刷新为拥有新闭包的clickCb,然后传递给子组件。这样我们就避免了无用的子组件重渲染。

上面说了这么多,实际上就是useCallback背后的实现逻辑,将依赖列表对象缓存起来,而后在下次重渲染时对依赖列表进行diff,如果有数据变化,则返回新的callback,如果没有数据变动,则返回缓存的callback。保持在依赖不变化是子组件入参稳定。而useMemo也是同样的逻辑,不过多赘述。

这里如果想要将useCallback的返回的回调函数用于子组件,需要对子组件进行优化(让子组件拥有分辨引用是否相等的能力,可以理解函数指针地址)。

这里这个Optimized Child实际是将需要使用useCallback能力的子组件使用 React.mem(组件)进行包装一次。让子组件拥有Reference Equality的能力。因此最终的代码:

export function Log(target: string, color: string = "red") {
  console.log(`%c In ${target}`, `background: ${color};color:black`);
}
export function Button({
  onClick,
  children,
  id
}: {
  id: number;
  children: ReactNode;
  onClick: () => void;
}) {
  Log(`Button ${id}`, "#FFD365");
  return (
    <button
      onClick={() => {
        console.log(
          `%c OptimizedButton[${id}]: State Change Emit Rerender`,
          "background:green"
        );
        onClick();
      }}
    >
      {children}
    </button>
  );
}
const OptimizedButton = React.memo(Button);
export function Counter() {
  const [count, setCount] = useState(0);
  Log("Counter", "#FDFFA9");
  useEffect(() => {
    console.log(`Counter:${count}`);
    Log("Counter dep[count] useEffect", "#00C897");
  }, [count]);
  const clickCb = useCallback(() => setCount((c) => c + 1), []);
  return (
    <div>
      <div>You clicked Button {count} times</div>
      {/* statusSetter is stable,so we can pass to children */}
      <div>
        <OptimizedButton id={0} onClick={clickCb}>
          Add
        </OptimizedButton>
        <OptimizedButton id={1} onClick={clickCb}>
          Add
        </OptimizedButton>
      </div>
    </div>
  );
}

export default function App() {
  Log("App", "#019267");
  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
      <Counter />
    </div>
  );
}

然后点击其中一个OptimizedButton,这时OptimizedButton[0]中触发了一个State Change,而这个State在Counter组件中,因此首先Counter开始重渲染,而在这里OptimizedButton由于入参 id,clickCb,children 都没有发生变化,因此直接跳过渲染。这样useCallback的作用就很明显了,当子组件重渲染成本较高时可以采用该方法,但需要注意的是,如果子组件入参数量多或是一个巨大的列表,由于每一个子组件、属性都需要进行diff可能对性能有影响,可以尝试其他优化方式。

到这里关于useCallback和useMemo的介绍就告一段落了。

useContext

// This will technically work if you give a Consumer<T> or Provider<T> but it's deprecated and warns
    /**
     * Accepts a context object (the value returned from `React.createContext`) and returns the current
     * context value, as given by the nearest context provider for the given context.
     *
     * @version 16.8.0
     * @see https://reactjs.org/docs/hooks-reference.html#usecontext
     */
    function useContext<T>(context: Context<T>/*, (not public API) observedBits?: number|boolean */): T;

useContext顾名思义,是在当前组件中使用一个上下文,这个上下文通过该组件或其父组件使用对应Context的Provider提供,如果该上下文值法师变动,则其所有使用该上下文的子对象(Consumer)都会被通知并重新渲染:

import React, { useContext, useState } from "react";

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee"
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222"
  }
};

type Themes = {
  themes: { [theme: string]: { [key: string]: string | number } };
  theme: string;
  setTheme: (theme: string) => void;
};
const ThemeContext = React.createContext<Themes>((null as unknown) as Themes);

function ThemedButton() {
  const themeCtx = useContext(ThemeContext);//通过祖父组件传递下来的Context
  return (
    <button style={themeCtx.themes[themeCtx.theme]}>
      I am styled by theme context!
    </button>
  );
}

function Toolbar() {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

export default function App() {
  const [theme, setTheme] = useState("light");
  return (
    <ThemeContext.Provider value={{ themes, theme, setTheme }}>//上下文Provider
      <Toolbar />
      <Toolbar />
      <button
        onClick={() => {
          setTheme(theme === "light" ? "dark" : "light");
        }}
      >
        Change Theme
      </button>
    </ThemeContext.Provider>
  );
}

useRef 引用

/**
     * `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument
     * (`initialValue`). The returned object will persist for the full lifetime of the component.
     *
     * Note that `useRef()` is useful for more than the `ref` attribute. It’s handy for keeping any mutable
     * value around similar to how you’d use instance fields in classes.
     *
     * @version 16.8.0
     * @see https://reactjs.org/docs/hooks-reference.html#useref
     */
    function useRef<T>(initialValue: T): MutableRefObject<T>;

useRef用于存储引用,最常用的做法是存储原生Dom元素和存储某状态的前一个值,另外还可以直接存储对象在内部。

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // `current` points to the mounted text input element
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

注意:如果使用useRef存储DOM元素,则第一次执行组件函数时是无法拿到该引用的,需要在useEffect中获取该引用,这涉及到React的内部执行,文章后半部分会重点解释。

总结

以上就是React中比较常用的几个Hooks,还有一些其他Hook比如useReduceruseImperativeHandler等。在本文后半部分将会从react-reconciler的角度解释以上Hook的原理和执行流程。

JSX魔法

在React日常使用中,我们只需要将写好的组件以JSX的形式使用,React就能魔法般的识别出组件同时帮助我们管理组件的各种状态和刷新。

这样的操作看起来像是魔法一样,实际上React通过Babel等编译器实现了这样的能力,大部分时候React将

<div><Speaker sentence={"HelloWorld!"}><VolumeBar /></Speaker><div></div></div>

通过Babel @babel/plugin-syntax-jsx @babel/plugin-transform-react-jsx 两个插件转换为(可以在这里尝试:Babel’s TryItOut Online)

/*#__PURE__*/
React.createElement("div", null, /*#__PURE__*/React.createElement(Speaker, {
  sentence: "HelloWorld!"
}, /*#__PURE__*/React.createElement(VolumeBar, null)), /*#__PURE__*/React.createElement("div", null));

可以看到实际上所有的JSX元素都转换成了嵌套的React.createElement函数执行,而createElement是React中的非常核心的方法,下面是React官方对于该API的描述:

Create and return a new React element of the given type. The type argument can be either a tag name string (such as 'div' or 'span'), a React component type (a class or a function), or a React fragment type.

Code written with JSX will be converted to use React.createElement(). You will not typically invoke React.createElement() directly if you are using JSX. See React Without JSX to learn more.

createElement将创建一个给定类型的React元素,这个类型可以是一个标签名也可以是React组件类型(Class组件或Function组件),或者是React 片段类型。

使用JSX编写的代码将会被转换为React.createElement()。在使用JSX时用户通常不会直接调用该API。

这个描述也可以用来解释为什么在使用JSX编写时需要将看似没有直接使用的 React 引入代码,因为所有JSX都会被转换为React.createElement方法执行。

import React from 'react';

在Babel Github仓库中packages/babel-plugin-transform-react-jsx/create-plugin.ts 中我们可以找到产生这种转换的源码:

JSXFragment/JSXElement visitor
buildCreateElementCall

由于Babel插件编写的文档资料太少,很多API只能猜个大概,但是通过API名也可以比较清晰的了解到整个插件转换JSX为React.createElement的流程。

React Reconciler

React 通过其内部使用的react-reconciler库实现了Diff和VirtualDom,首先我们要知道这个包名中reconciler的含义。这里引用下React官方对于Reconciliation的描述:

Reconciliation(我更倾向于翻译为调协)

The algorithm React uses to diff one tree with another to determine which parts need to be changed.updateA change in the data used to render a React app. Usually the result of `setState`. Eventually results in a re-render.

Reconciliation算法在React中寻找一个树(Fiber树)与另一树的差异用于决定需要被更新的元素(Virtual DOM)。数据更新意味着React应用的重渲染。通常“setState”是导致重渲染的主要原因。

The central idea of React’s API is to think of updates as if they cause the entire app to re-render. This allows the developer to reason declaratively, rather than worry about how to efficiently transition the app from any particular state to another (A to B, B to C, C to A, and so on).

React API的核心思想是将updates视为整个应用重渲染的原因。这样允许开发者reason declaratively(从使用上将每次更新当成全局重渲染)。而不需要担心应用该怎样高效的从一个状态转变到另一个状态。

Actually re-rendering the entire app on each change only works for the most trivial apps; in a real-world app, it’s prohibitively costly in terms of performance. React has optimizations which create the appearance of whole app re-rendering while maintaining great performance. The bulk of these optimizations are part of a process called reconciliation.

实际上,每次数据变化重渲染整个应用的模式只能在极少部分应用上使用。在实际应用场景下,这种模式几乎是高性能的反义词。React拥有为整个应用渲染UI的同时保持高效的代码优化。这些优化是reconciliation的一部分。

Reconciliation is the algorithm behind what is popularly understood as the “virtual DOM.” A high-level description goes something like this: when you render a React application, a tree of nodes that describes the app is generated and saved in memory. This tree is then flushed to the rendering environment — for example, in the case of a browser application, it’s translated to a set of DOM operations. When the app is updated (usually via setState), a new tree is generated. The new tree is diffed with the previous tree to compute which operations are needed to update the rendered app.

Reconcililiation是通常被理解为“virtual DOM”的算法的实际实现。一个高度抽象的解释可能想这样:当你渲染一个React应用时,React将生成一个描述应用的节点树并保存在内存中。这个树而后会flush(刷入)用于渲染整个环境。在浏览器应用的环境下,节点树被解析为一系列DOM操作。当App被更新时将会生成一个新的节点树,这个新节点树将会与当前已渲染的节点树进行比较,并计算从当前节点树迁移到新节点树需要的最小操作。

Although Fiber is a ground-up rewrite of the reconciler, the high-level algorithm described in the React docs will be largely the same. The key points are:

  • Different component types are assumed to generate substantially different trees. React will not attempt to diff them, but rather replace the old tree completely.
  • Diffing of lists is performed using keys. Keys should be “stable, predictable, and unique.”

尽管Fiber是从底层到顶层对reconciler(原React中的diff算法)的重写,但顶层算法与React docs中描述的大体上相同。其中核心要点为:

  • 假设不同组件类型被产生不同的树。React将不会diff不同组件,而是直接将旧节点树替换。
  • 列表的diff通过使用keys提高效率。Key应该是“稳定的,可预见的,在本列表内唯一的“

Reconciliation 总结

Reconciliation 从表现上就是一个diff算法,用于diff两个不同节点树并寻找从A树到B树迁移的最小操作。实际上就是将新状态同步到已渲染状态。因此我将 Reconciliation 翻译为调谐,调整树的状态到和谐的状态。

Fiber概述

上文描述了React Reconciler从某种意义上是一种用于diff不同节点树的算法,实际上它也是React中Hook的实现原理。同时将JSX的魔法破除了,所有现在我们可以将所有JSX元素都看成是Reat.createElement函数调用。

Reconciliatiion 用于diff不同节点树,而此时还没有渲染新节点树,因此Reconciliation所处的阶段实际应该是 vdom render phase,而不是广义上的browser render phase(browser paint)。因此React官方中一直被称为render phase 的实际不是在进行渲染,而是在对vdom进行创建和更新。真正的UI render react实际交由了react-dom(browser环境下),因此在渲染根节点时需要将应用根节点传入ReacDOM的render函数委托ReactDOM进行render:

ReactDOM.render(<App />);

“React生来并不是为了React而是为了Schedule,因此React从其目标上应该叫做Schedul”,这句话可以从某种角度猜测一下React内部的实现。首先Schedule的基础是需要将代码行为从某种层级上分割开来,成为独立的执行单元,因此React至少应该有一种用于执行的UnitWork。然后React想要实现的是一种对UI渲染、操作、管理可以Schedule的能力,因此不同类型的UnitWork应该有不同的priority,而React可以在高优先级密集执行的时段推迟低优先级的事件,例如在UIAnimation事件密集执行时将DataFetch事件推迟到下一次Loop执行。(但实际上React现在还没有实现,所有Render操作都是在single tick实现)

Reconciler中实际实现了一个函数执行栈,将每一个挂载的组件实例都封装为了一个Fiber(可以看做轻量级线程),而函数组件中的所有被保存的State、Effect实际上都是通过Fiber来保存。Fiber从计算机原理的角度来说就是一个StackFrame。而一个StackFrame应该存储有其返回地址,这里引入Fiber中的return属性,该属性存储有函数组件实例的父组件实例。但是我不是很懂描述内的可能有多个parents,是一个实例直接挂载到多个位置?

Fiber存储有定义函数组件的对应的函数或者原生DOM元素的标签名:

仅仅有一个存储当前状态的Fiber还不足以用于diff,还需要一个存储新状态的Fiber,因此引入同一个函数组件实例将拥有两个Fiber(新Fiber懒加载),他们相互之间通过alternative属性链接。

由于Fiber是函数实例的一个抽象,而React函数组件可以通过Composition组合在一起,因此一个Fiber拥有多个child,但在Fiber中,Children并不是通过数组的形式存储,而是通过链表的形式存储,由此Fiber有child、sibling属性。

Fiber同时也要存储函数的输入,即每个函数组件实例的props,而这里Fiber拥有两个用于存储函数组件props的属性,pendingProps存储在渲染结束后到下一次渲染前被等待更新的props,而memorizedProps存储上一次渲染的props。

Fiber还需要存储当前环境下对应的状态,在浏览器环境下是其对应的DOM元素。

我认为到这里(没有实际实验),一个最基础的纯函数组件类型的Fiber可以被构建出来。Fiber还有很多用于校验、标记、Flaggable的属性。同时以上属性还没有包括关于Hook实现的属性。Hook相关的属性将在下一部分出现。

React Hooks 实现

React Hook就像魔法一样将面向对象编程的状态存储等特效引入到函数式编程中,Hook 钩子,将某种feature 勾入函数组件中。但实际上Hook的实现并不是什么魔法,而是以上文介绍的Fiber为基础加上几个Fiber的属性实现。

React中所有的Hooks在函数组件实例对应的Fiber中以一种顺序的结构存储起来,就像单链表一样(实际是循环单链表),这也说明为什么Hook只能在函数体内使用,而不能在if等块级作用域中使用。因为在块级作用域中使用Hook可能会导致多次函数执行会有不同的Hook顺序和数量,导致本来通过顺序对应的Hook发生错误。

未完待续

拾遗

Why Passive Effect & Layout Effect

Why Discrete Event

What’s Hydration

Hydration is a SSR Scope Definition.

Batching

Automatic Batching

Suspense

Server-Side Rendering

Flush Sync

由于本人能力、精力有限,且React架构庞大且精细,如本文中出现理解错误或问题,请通过评论告知。


0.1wep 写到ReactHooks实现

发表评论

您的电子邮箱地址不会被公开。 必填项已用 * 标注