React 的深入理解

2024-12-09

React 是如何工作的

写了这么多年的 React,知道通过写 JSX 来创建 React Components,并渲染到页面上。

const App = () => {
  return (
    <div>
      App Component
    </div>
  )
}
ReactDOM.render(
  <App />,
  document.getElementById('root')
);

并且还有一些其他功能,如状态管理和生命周期

const [state, setState] = useState();
useEffect(() => {
  // Do something
})

React 相对于 Vue 需要记的东西并不多,更多的是理解,新手现在写 React 基本上一周之内都能上手,但总是会出现一些奇奇怪怪的问题,这就需要理解 React 背后的原理了。

JSX

JSX (JavaScript XML)是一种语法扩展,这些代码浏览器并不能识别,需要通过 Babel 等工具将其转换为普通的 JavaScript 代码。

const App = () => {
  return (
    <div>
      App Component
    </div>
  )
}

对于上面这个函数组件,在 React17 版本之前 JSX 被转换为 React.createElement 调用,这意味着在 jsx 文件的头部需要引入 React。

babel 的 Try it out 中,可以将 React Runtime 设置为 Classic。

const App = () => {
  return /*#__PURE__*/React.createElement("div", null, "App Component");
};

而在 React 17 及以上版本中,引入了 react/jsx-runtime 这个模块,它用于优化 JSX 的编译过程。目的在于简化 JSX 的运行时需求,特别是对自动导入 React 的需求,以及创建 React 元素时的性能。

可以将 React Runtime 设置为 Automatic。

import { jsx as _jsx } from "react/jsx-runtime";
const App = () => {
  return /*#__PURE__*/_jsx("div", {
    children: "App Component"
  });
};

输出

虽然新版本引入了新的模块,但是对于输出两者都是相同的,都会创建 React 元素,且生成的元素本质上是相同的。

console.log(App());的输出为

Untitled

  1. type 对应一个 HTML 元素类型,在这里是 div

  2. key 和 ref 属性都是 null,因为没有赋值,并且都是特殊的属性,没有被包含到 props 中

  3. props 中包含所有的 children

如果对于稍微多了一些标签和属性的组件来说

export default function App() {
  return (
    <div className="123">
      <h1>App Component</h1>
      <span>hello</span>
    </div>
  );
}

输出的结果为

Untitled

可以看出会将所有的标签进行转换。

上图转换后就可以称为 elements tree

ReactDOM

在 React 项目依赖中,除了 React 这个库还会有 React-dom 这个库,并且在脚手架工具中都会有这段代码

ReactDOM.render(
  <App />,
  document.getElementById('root')
);

它的意思是将 App 这个组件渲染到 root 这个节点下。

因为 React 支持跨平台开发,所以 React 库会将 JSX 转换为普通的对象,而想将这部分用在网页还是移动端,就可以选择不同的库。网页就是 react-dom,而移动端则是 React Native 那一套,还有其他 VR 等。

那么 render 这个方法接受的第一个参数 <App /> 是什么,我们知道是个组件。

Untitled

它的结构看上去像一个 React 元素,但是 type 是一个 function,实际上就是 App 组件。

Reconciliation

Reconciliation 是 React 非常重要的一部分。它的主要作用是将 JSX 代码中描述的内容、样式等与其宿主环境(web、移动端等)协调一致。

Stack Reconciler

在 React16 版本之前使用的是 stack reconciler,使用是堆栈这种数据结构,也就是堆栈协调器。

这是一种后进先出的方式,它是顺序的、而且很难并行或者设置优先级。这对于 UI、用户 interface 是一个很大的问题,因为用户输入事件具有固有的优先级意识。比如用户在输入框的输入内容,这个是需要立刻响应的;而一些通知类的消息,延迟一秒响应并没有多大的问题。

stack reconciler 所做的一切都是实时的,就像它只是在屏幕上进行更新一样。

这就数据结构的设置就导致了 stack reconciler 是不可用的。

Fibre Reconciler

在 React16 开始,Fibre Reconciler 就应运而生了,它与 Stack Reconciler 完全是两种东西。

使用的是一种特定的树结构,而不是传统意义上的链表结构。

在 Fiber 中,每个节点代表一个组件或一个元素。Fiber 节点包含了组件的状态和相关信息

  • 类型(element type,组件类型)

  • 子节点(child,只想该节点的第一个子节点)

  • 兄弟节点(sibling,指向同一父节点的下一个兄弟节点)

  • 父节点(return,指向其父节点)

  • 更新状态(用于追踪状态和更新信息)

Untitled

上图就代表了 fiber 节点树

Fiber 和 React elements 类似吗

  • Fibers 通常都是从 React elements 创建的

  • 它们会共享 key 和 type 这个属性

  • React elements 每一次都会被重新创建,但是 Fibers 尽可能的重复使用

  • 大多数的 Fibers 会在初始挂载时被创建

工作原理

Fiber 的工作原理分为两个阶段:render 阶段commit 阶段

它在 render 阶段进行更新,并在屏幕外的内存中进行更新,当渲染的工作完成后,然后会进行 commit 阶段,将其提交到屏幕上,并将其放在浏览器中。

为了实现这两个阶段的方法,Fiber 需要维护两棵树,一棵是上图中的 current tree,另外一棵为备用树,叫做 workInProgress tree

Untitled

它会维护这两颗树来渲染和提交。

render 阶段

在 render 阶段,React 在幕后执行一些用户看不到的异步操作,React 可以确认任务的优先级,暂停某些工作甚至丢弃它等等。

每一个 Fiber 都是一个工作单元,React 在这个阶段会处理所有的 Fiber

在 render 阶段,会创建一个 workloop,那么什么东西是 work?

  • state 改变

  • 生命周期方法的调用

  • 当有更新导致 DOM 变化时

在这个阶段会调用 beginWork()completeWork()等内部函数。

beginWork 接受三个参数 beginWork(current, workInProgress, lanes),它就是将两棵树进行比较,从顶部到底部(这不是递归而是一种 while 循环)。比较的时候会设置一堆的 flag 来标记该组件是否需要更新。每一个节点都会如此的进行比较。到了最底部的元素后会向上返回,再去循环兄弟节点,但是在转到兄弟节点之前会调用completeWork()函数

completeWork()beginWork() 的参数一致。它的作用是构造一个 elements tree,不是可以看到的那种,是在内存中的。直到到达最顶部元素

可以视 beginWork 为向下设置 flag,completeWork 为向上,组成一个正在进行的元素树。

commit 阶段

当它到达顶部完成 render 阶段后,就到达了 commit 阶段。

在这个阶段会调用 commitWork() 函数,该阶段是同步的,不能被中断的

在 commit 阶段,workInProgress Tree 就是最新的 tree,需要将这个 tree 提交给宿主环境。为了实现这个效果会有一个 FiberRootNode,它是一个隐藏的但是真正存在的 Fiber tree 的根节点。当应用启动时,在任何更新之前,这个跟节点有一个指针会指向 current tree,在workInProgress Tree 完成工作后,这个指针会指向 workInProgress tree,这时最新的状态会立即在浏览器中显示,此时workInProgress Tree 转换为 Current TreeCurrent Tree 就会转换为 workInProgress Tree

至此这个 workloop 完成。

这些更新通常都是在屏幕外进行的,当有一个优先级更高的任务中断它时,你不会在屏幕看到任何变化。这样就比之前的 Stack Reconciler 更强。

是如何进行比较的

上面说到的 beginWork 进行比较时,React 是如何判断是否需要进行更新。

  • 在同一位置有两种不同元素的类型:React 会从头开始创建一棵新树,对于组件来说就是卸载之前的组件,重新挂载新的组件

    <div>
      <h1>Title1</h1>
    </div>
    
    <div>
      <h2>Title1</h2>
    </div>
    
  • 同一位置有两个具有相同类型但是属性不同的元素:React 只会更新这些属性,实例保持不变并且维持 state

    <div 
      className='first'  
    />
    
    <div 
      className='second'  
    />
    
  • 在一个列表的尾部添加一个元素:React 识别到前面的部分相同,在后面 insert 一个新的元素即可

    <ul>
      <li>John</li>
      <li>Martin</li>
    </ul>
    
    <ul>
      <li>John</li>
      <li>Martin</li>
      <li>Thomas</li>
    </ul>
    
  • 但是如果在列表的头部添加一个元素:React 比较后发现 li 中同一位置没有相同的元素,相当于重新生成一个新树,这就不是我们所期望的,如果有一万个列表,重新生成一万个元素,这些都是需要避免的部分。

所以就需要提供 key 这个属性,我们就可以告诉 React 哪些元素是相同的,哪些元素是新的。这也就是不能使用 index 作为 key 的原因

<ul>
  <li key='john'>John</li>
  <li key='martin'>Martin</li>
</ul>
<ul>
  <li key='thomas'>Thomas</li>
  <li key='john'>John</li>
  <li key='martin'>Martin</li>
</ul>

React Hooks 是如何工作的

React Hooks 最重要的两条规则

  • 只在 React 函数组件或自定义 Hooks 中调用 Hooks,不应在常规的函数、条件或循环中使用

  • 确保在组件的顶层调用 Hooks,保持调用顺序一致,以便 React 能正确管理状态和副作用

可以在函数组件中使用 Hooks 来代替类组件,可以使用 useStateuseEffect

但是也会有很多对 Hooks 的疑问

  • 为什么不能在循环或条件语句中使用 hooks

  • why isn't there a hook for this?

  • why isn't there a hook for that?

为什么不能在循环或条件语句中使用 hooks

换句话说就是为什么 hooks 依赖调用顺序,最重要的一点是 hooks 都是关于数组的

我们会使用 useState 创建很多的 state 状态,这些状态都被 React 通过数组存储,useState 会使用索引访问该数组。当函数组件被调用时,React 会使用初始化的 index 从 0 开始未每一个 useState 和 useEffect 调用顺序分配一个索引,并存储在数组中。每个 Hooks 的调用结果(如状态和副作用)会被存储在 Fiber 节点的对应位置,

如果我们在循环或条件内调用 useState,则会导致可能有些 hooks 没有被执行,index 就不会增加,Hooks 调用的顺序就会被打乱,导致 React 不能将状态与特定的 Hooks 关联起来,导致错误。

useState() 为什么会成为渲染的一部分

useState 是从 React 这个库中引入的,但是我们知道渲染是和 react-dom 这个库相关的,这是为什么

Untitled

Untitled

原因就是当 useState()被调用时,调用将被转发到一个称为 currentDispatcher 的一个特殊字段。在 react-dom 中使用了这个字段。

最后

只要能搞清楚了 React 背后的原理在面对项目或面试时,一般都不会有太大的问题。