React 渲染(外文博客翻译)

332 阅读8分钟

1. 什么是渲染

渲染本质上是React依据当前的props以及state去描述UI“样式”的过程。

1.1 渲染过程概述

在渲染过程中,React会从根组件开始,向下遍历找出所有需要更新的节点。在函数式组件中,React会调用FunctionComponent(props) 函数,render函数的返回值会作为下一次渲染过程的被保存下来。

React代码使用JSX编写,在编译以及部署前转换成React.createElement() 调用。createElement函数返回的是一个描述UI预期结构的普通对象。

// JSX
return <MyComponent a={42} b="testing">Text here</MyComponent>

// 转化成如下调用
return React.createElement(MyComponent, {a: 42, b: "testing"}, "Text Here")

// 变成元素对象
{type: MyComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}

// React内部使用函数调用去做
let elements = MyComponent({...props, children})

// HTML自带元素过程如下
return <button onClick={() => {}}>Click Me</button>
React.createElement("button", {onClick}, "Click Me")
{type: "button", props: {onClick}, children: ["Click me"]}

拿到整个组件渲染函数返回值后,React会得到新的对象树,我们大多数人一般将其称之为虚拟DOM,通过diffing算法以及计算,得到一个页面变化差异的列表。React会以同步序列的方式将修改应用于真实DOM。

其实在近几年, RReact官方一直在不断淡化“虚拟DOM“这一概念,虚拟DOM产生的本意是让大众能够理解
React并不会在每一次都创建出新的DOM,对于现在的开发者来说,这一点早已深入人心。
React的核心是将UI看做普通的变量,它和string number没有任何区别,我们可以使用变量去存储它,
也可以使用Javascript去控制和传递它。
比如常见的<MyComponent propsId={10} />,这里并不是一个真实的DOM,它本质上是一个惰性函数调用:
MyComponent.bind(null, {propsId: 10})

1.2 Render 和 Commit 阶段

Render阶段调用函数获取最新的渲染结果并且计算差异,Commit阶段则将这些差异化更新至DOM上面。(这里其实可以将Render理解成函数调用,而不是字面翻译的渲染意思,函数调用后得到页面的差异化变化,在Commit阶段将其应用在真实DOM)在每一次DOM节点更新以后,其绑定的ref也会相应更新,可以使用useLayoutEffect()钩子处理一些页面布局的副作用。这一过程结束后,React会设置一个很短的timeout,该timeout结束后,则会调用所有的useEffect()钩子。

由于Render的计算较为复杂耗时较长,React18推出了一个新的钩子useTransition,该钩子用于实现所谓的“同步渲染”。这个钩子的作用机理是可以中断rendering阶段的工作,让浏览器去处理其他事件处理程序,处理完后会在适当时机继续执行rendering操作并在完成后进入commit阶段(仍然是在同一个step里面的同步操作)。

这里的关键在于要把render和其字面翻译相区分开,render并不是更新DOM,同理组件可视区域没有变化不代表没有发生render,当React去渲染一个组件时,主要分为两部分:1.如果组件的render函数返回值与上一次的值一致,则不需要进行更改;2. React可能去多次获取组件的render的返回值,但是当其他操作使得当前render失效,则会丢弃其输出结果。

截屏2022-12-02 下午11.26.30.png

2. React如何去处理渲染操作?

2.1 渲染触发

  1. 函数式组件: useState的getter函数和useReducera里面的dispatch操作
  2. 类组件: this.setState()和this.forceUpdate()
  3. 其他: render(<APP>)相当于对根组件调用forceUpdate()方法和useSyncExternalStore钩子 注意在函数式组件(也就是16.8之后)中并没有forceUpdata(),但是可以可是useReducer实现类似的功能。
const [, forceRender] = useReducer((c) => c + 1, 0)

2.2 渲染流程

在父组件更新时,React默认会重新渲染所有子组件,以A>B>C>D组件树为例:

  • 点击组件B中按钮,调用setState()更改其状态
  • React从根组件开始render
  • A组件未被标记更新,跳过
  • B组件被标记为需要更新,执行其render返回C,同上次结果一致
  • C虽然未被标记为需要更新,但是由于其父组件B render了,向下遍历渲染C,同理返回D
  • 同理D被重新渲染

默认情况下,只要父组件中状态更改,子组件均会重绘, React并不会去关注props是不是做了改变。极端情况下,当我在根组件<APP>中setState时,会触发整个组件树的重新渲染。

2.3 渲染规则

  • 不可修改已有变量或者对象
  • 不可产生随机值 Math.random()以及Date.now()
  • 不可发起网络请求
  • 不可更新已有状态
  • 可以修改在render阶段新创建的对象
  • 抛出错误
  • 惰性初始化未创建的值 如缓存的值

2.4 组件metadata以及fiber

fiber的本质是一个javascript对象,其用于存储React内部的数据结构,并且可以追踪整个应用的数据变化,只要包含以下:

  • 当前节点可渲染组件的类型
  • 与当前组件相关联的参数以及状态
  • 当前组件的父子组件 兄弟组件
  • 其他被用来追踪渲染进程的metadata 一个fiber的简单示例
export type Fiber = {
  // 定义fiber类型
  tag: WorkTag;

  // 唯一标识
  key: null | string;

  // 当前组件关联的类型 div span MyComponent
  type: any;

  // 链表结构
  child: Fiber | null;
  sibling: Fiber | null;
  index: number;

  // Input is the data coming into this fiber (arguments/props)
  pendingProps: any;
  memoizedProps: any; // The props used to create the output.

  // A queue of state updates and callbacks.
  updateQueue: Array<State | StateUpdaters>;

  // The state used to create the output
  memoizedState: any;

  // Dependencies (contexts, events) for this fiber, if any
  dependencies: Dependencies | null;
};

在渲染阶段,React会不断迭代整个fiber对象,在计算出新的render结果时更新其结构。注意:fiber中存的是真正的props以及state,我们使用的则是React传递出来的引用。 另外,hooks的顺序执行也是基于fiber实现,在fiber内部存储了一个组件内hooks的链表,上一个hook执行后返回的其执行修改状态的结果以及下一个hook的引用。

2.5 组件类型以及协调(diffing)

React(包括Vue)都是希望通过复用部分DOM的手段来提高重新渲染的效率。当我们要求React去在同一位置重新渲染同一组件,它只会去更新掉内部的值(如果可以的话),也就是说React会尽可能延长当前组件的生命周期。

通过全等比较type (===)若为false 销毁old(包含其子组件)渲染new。由于是先全亮对比组件的“type”,这代表我们在rendering阶段不能去创建新的type,因为新的type创建会被React认为是新的组件,这样React会对子组件树进行反复的销毁与创建。

// 每一次均会创建一个新的 ChildComponent 引用
function ParentComponent() {
  function ChildComponent() {
    return <div>Hi</div>;
  }

  return <ChildComponent />;
}

// 可以改成这样 只创建一次
function ParentComponent() {
  return <ChildComponent />
}
function ChildComponent() {
  return <h1>Hi</h1>
}

2.6 key的作用

在进行列表渲染时,不管React还是vue都会要求我们传递一个key值给子组件,其实key时一个伪参数,子组件内访问props.key的值时是undefined。但是它的作用是至关重要的,通过key,React可以快速定位到数据的变化,举例说明:

  • 渲染一个10项数据的列表
  • 删除其中下标为4的数据
  • 有key时会直接移除该节点 后续节点移动
  • 无key时则会卸载掉后续所有节点 重新创建并渲染
  • 简单说来就是key的存在可以告诉React哪里的数据变了 怎么变了 哪些没有变
  • key的变化也就是告诉React当前组件发生了改变 需要重新渲染

2.7 批量更新

默认每一次setState()均会触发组件的重绘,但是React也进行了一些批量更新的优化。但是在React18之前,仅会在React事件处理函数中运用,而在非React事件处理程序(setTimeout() await之后 普通的JS事件处理程序)仍然不会进行批量更新。在React18,只要是在同一事件循环中排队的所有数据更新,都将批量更新。 一个例子:

const [counter, setCounter] = useState(0);

const onClick = async () => {
  setCounter(0);
  setCounter(1);

  const data = await fetchSomeData();

  setCounter(2);
  setCounter(3);
};

React17中,上述代码将会更新三次。第一次批量处理 setCounter(0)以及setCounter(1)。但是,对 setCounter(2)的调用在await之后,这表明之前的React同步事件处理任务队列被done掉了,并且函数的后半部分在完全独立的事件循环调用堆栈中运行得更晚。因此,React 将在 setCounter(2)调用中的最后一步同步执行整个render,完成commit后从 setCounter (2)返回。然后,setCounter(3)也会发生同样的情况,因为它也在原本的事件处理程序之外运行(这里我的理解是await破坏/结束了原本的事件处理程序,使得后续的setCounter同普通的JS事件处理程序相同)。而在React18中,会进行两次更新,第一次批量处理 setCounter(0)以及setCounter(1),第二次批量处理 setCounter(2)以及setCounter(3)。

3. 原文链接

blog.isquaredsoftware.com/2020/05/blo…