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失效,则会丢弃其输出结果。
2. React如何去处理渲染操作?
2.1 渲染触发
- 函数式组件: useState的getter函数和useReducera里面的dispatch操作
- 类组件: this.setState()和this.forceUpdate()
- 其他: 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)。