React源码系列(十三) -- 源码解析终结与展望

477 阅读14分钟

前言

当你看到这一章的时候,也就表明React17.0.2的源码解析系列文章圆满完成。从2021年3月开始,我就着手着React源码系列的工作,距今已经有一年多了,在这一年多的时间里做的只有工作生活学习三件事啊。可能大部分同学的人生轨迹就是这样,好了废话少说,这一章将会整理一下React的面试题和下一步的计划。

常见的面试题

一、React17之前的版本中jsx文件必须要引入React,为什么之后版本不需要引入了?

react17版本之前解析编译是用的是React.createElement方法,React17之后改变了编译方式采用jsxRuntime.jsx的方式解析jsx文件,babel集成了jsxRuntime,所以不需要再手动引入了。

const App = () => {
  return <h1>Hello World</h1>;
}
//React17之前转换后
const App = () => {
  return /*#__PURE__*/React.createElement("h1", null, "Hello World");
};
---------------------------------- 分界线 --------------------------------------
//React17转换后   
var _jsxRuntime = require("react/jsx-runtime");

const App = () => {
  return /*#__PURE__*/(0, _jsxRuntime.jsx)("h1", {
    children: "Hello World"
  });
};

二、你了解fiber吗,说说为什么React要引入fiber架构,他解决了什么问题。

Fiber是一个js对象,能承载节点信息、优先级、updateQueue等。

  • Fiber双缓存可以在构建好workInProgress Fiber树之后切换成current Fiber,内存中直接一次性切换,提高了性能
  • Fiber可以使异步通过shouldYield可中断更新,时间分片的概念把超大的更新任务分成一个个小的执行单元,每一次执行时间分片都有返回,能够让浏览器去执行更高优先级的任务,React并不会独占js线程,高级任务能够得到相应,页面自然不会卡顿,提高应用效率,而下次时间分片会继续执行之前暂停之后返回的Fiber
  • Fiber可以在reconcile的时候进行相应的diff更新,让最后的更新应用在真实节点上。
  • 所以fiber解决了超长任务更新,独占js线程造成页面卡顿,并且不可中断应用的问题。

三、你们在项目中使用的是React Hook是吧,那你了解几种hook呢?为什么不能在hook中写判断条件呢?

常见hook介绍详见React源码解析系列(八) -- 深入hooks的原理 为什么不能在hook中写判断是因为:hook会按顺序存储在链表中,每次执行都会根据全局的索引从链表头部依次向下执行并返回链表中保存的值,如果在条件判断下执行hook,那么全局的索引从0开始执行的时候,有可能会执行到不是对应hook保存的值,这样会导致链表保存的顺序不正确的问题,也会影响其他的hook执行的。

四、React的scheduler是如何实现空闲帧调度以及任务中断的呢?

react通过模拟浏览器requestIdleCallback函数来实现空闲帧调度的,具体的可以看React源码解析系列(四) -- render阶段的协调与调度,任务中断只发生在处理异步任务当中,通过shouldYield函数来判断,当前任务是否能够中断。

五、setState是同步的还是异步的?

通过前面的学习,我们知道setState并不具备有同步异步的概念,他只是Component原型上的一个方法而已。但是它可以帮助我们把需要执行的任务推送更新队列当中,借助于React的执行机制来实现数据更新,所以很多人认为这setState具备有同步异步的功能。详见React源码解析系列(一) -- babel解析jsx的那些事儿

setState有时候更新任务是同步的,有时候更新任务是异步的这又是怎么回事呢? 这是因为在函数执行生成的同一个执行上下文中,多次调用setStateReact会触发一个批处理的机制,批处理的机制表明多次setState会被合并成一个setState更新。在源码里面表现为batchedUpdates函数。在批处理中,React当然会去异步处理这些更新任务。

// packages/react-reconciler/src/ReactFiberWorkLoop.new.js
// batchedUpdates
export function batchedUpdates<A, R>(fn: A => R, a: A): R {
  const prevExecutionContext = executionContext;
  // executionContext与BatchedContext做位运算
  executionContext |= BatchedContext; // 批处理标识
  try {
    return fn(a);
  } finally {
    executionContext = prevExecutionContext;
    // 如果executionContext与NoContext相等,执行Scheduler_cancelCallback函数
    if (executionContext === NoContext) {
      // 重置渲染时候的时间 requestTimes
      resetRenderTimer();
      // flushSyncCallbackQueue => Scheduler_cancelCallback
      flushSyncCallbackQueue();
    }
  }
}

上面讲述的是批处理合并,如果setState在脱离原有的上下文中(比如在setTimeout中执行),他又是如何执行的呢? React认为,如果多次更新的执行环境脱离了原来的上下文,那么这些多次更新就不会进行合并,而且还会同步执行。这当然只是在React18之前会这么处理的,但是在React18中,不论是何种写法,Automatic Batching都会去异步执行这些更新任务。

所以在React18之前,也就是传统模式中:

  • 如果在一个执行上下文中多次更新,就会触发批处理,这是异步执行的。
  • 如果脱离的执行上下文,比如在setTimeout等,他就是同步执行的。

React18中,会自动批处理,所以这时候都是异步执行的。

追问setStateReact源码历史版本中,都有什么改变呢?

  • React 15:

在 React 15 中,setState() 的源码实现比较简单。当调用 setState() 时,React 会将更新放入一个更新队列中,然后执行一次批量更新。这意味着,多次连续调用 setState() 方法可能只会导致一次更新操作。

  • React 16:

在 React 16 中,setState() 方法的实现发生了重大变化,这主要是由于 React Fiber 架构的引入。在 React 16 中,setState() 仍然是异步更新状态的,但与 React 15 不同的是,React 16 实现了一种基于优先级的异步更新机制。这个机制可以更好地处理多个更新之间的竞争关系,从而提高 React 应用程序的性能。追问:具体讲讲这个机制

  1. 当我们调用 setState() 方法时,React 会将更新放入一个更新队列中。在 React 16 中,React Fiber 架构的引入使得这个更新队列可以被分成多个优先级较高或较低的子队列。React 会先处理高优先级的队列中的更新,然后再去处理低优先级队列中的更新。

  2. 在具体实现上,React 会将每个更新标记为不同的优先级,然后将这些更新放入对应的队列中。在渲染过程中,React 会先处理高优先级的更新,直到它们全部处理完毕或者当前帧没有剩余时间为止,然后再去处理低优先级队列中的更新。这个过程会持续进行,直到所有的更新都被处理完成。

  3. 这个优先级算法可以有效地减少不必要的更新操作,从而提高 React 应用程序的性能。它可以确保高优先级的更新能够及时地被处理,而不会因为低优先级队列中的更新而被阻塞。同时,它还可以让 React 更加智能地选择更新的时机,从而避免不必要的重绘操作,提高渲染性能。

  • React 17:

在 React 17 中,setState() 的源码实现与 React 16 类似,但是 React 17 引入了一些优化,使得 setState() 更加高效。具体来说,React 17 可以更好地利用 JavaScript 引擎的优化,例如内联缓存内联函数,从而提高 setState() 方法的性能。 追问:具体什么优化

  1. 事件委托优化

在 React 17 中,事件处理程序默认采用事件委托的方式进行处理,即将事件处理程序绑定到顶层 DOM 节点上,然后通过事件冒泡机制来处理子节点的事件。这样做可以避免在每个子节点上都添加事件处理程序,减少内存占用和事件绑定的开销,提高性能。追问:为什么16绑定在document上,17却绑定在root上,有什么好处?

在 React 16 中,事件处理程序默认是绑定在 document 上的。这会导致一些问题,例如事件处理程序会被全局捕获,同时也会将事件处理程序注册到很多不必要的元素上,从而增加了页面的开销和复杂度。

在 React 17 中,事件处理程序默认采用事件委托的方式进行处理,即将事件处理程序绑定到顶层 root 节点上。这样做有以下几个优点:

  1. 减少不必要的事件处理程序:React 17 只会在必要的情况下将事件处理程序注册到具体的元素上,这样可以减少页面的开销和复杂度。
  2. 更好的事件管理:React 17 可以更好地管理事件处理程序的生命周期,从而避免一些潜在的内存泄漏和其他问题。
  3. 更好的性能表现:由于事件处理程序被绑定在顶层节点上,React 17 可以更好地优化事件的捕获和冒泡过程,从而提高应用程序的性能。

需要注意的是,虽然在 React 17 中事件处理程序默认绑定在顶层 root 节点上,但是开发者仍然可以通过 addEventListener 等方法手动将事件处理程序绑定到具体的元素上。

  1. 批处理优化

在 React 17 中,setState() 调用也采用了类似于 React 16 的基于优先级的异步更新机制,但与 React 16 不同的是,这个机制在更新时采用了一种更加智能的算法,可以更好地避免更新过程中的阻塞和长时间卡顿问题。

  1. 具体来说,React 17 中的批处理机制采用了类似于时间分片的技术,在不同的更新任务之间加入一些时间片,以确保渲染过程不会被阻塞。如果某个更新任务无法在规定的时间内完成,React 会暂停该任务,并将其转移到下一个时间片中执行,以避免对渲染过程造成影响。

总的来说,React 17 的优化主要围绕事件委托和批处理两个方面展开,这些优化都可以帮助提高 React 应用程序的性能和稳定性,使其更加适应各种复杂的应用场景。

  • React 18:

在 React 18 中,setState() 的源码实现并没有大的变化。但是,React 18 引入了一些新的特性,例如batch updatesconcurrent rendering,这些特性可以更好地管理 setState() 的更新操作。其中,batch updates 可以将多个连续的 setState() 调用合并为一次更新,从而提高性能。而concurrent rendering 则可以更好地利用浏览器的空闲时间,使得更新操作更加平滑和响应式。

六、react元素$$typeof属性什么?怎么区分组件类型?函数组件与类组件有什么区别?

$$typeof表示元素类型的唯一值,React中通过类组件上的isReactComponent属性来区分组件类型。

七、请说一说React的渲染过程。

可以详细看看React源码解析系列(零) -- 全局概况中的第一章到第八章。

八、你在写React的时候做了哪些性能优化?

  • 传递参数是尽量减少传递参数的数量。比如多个props使用对象形式传参。

  • 使用React.PureComponent,定制shouldComponentUpdate函数。

  • 使用React.memo来缓存组件

  • 延迟加载不是立即需要的组件

  • React.Lazy / React.Suspense:懒加载或者 Suspensefallback属性定制spinner

  • map数据结构使用唯一key

  • 合理的使用useCallbackuseMemo。处理函数的不必要重新创建与大量不必要的重新计算。

  • 通常在组件内部写jsx或者有多个子组件,他们需要一个唯一父级,这时候可以用React.Fragment / <> </>

九、你知道React的生命周期吗,类组件有哪些生命周期,函数组件呢,如果是父子组件他们生命周期是怎么执行的呢?

关于组件的生命周期可以参考这篇文章:React源码解析系列(十一) -- react生命周期与事件系统的解读。因为fiber树的遍历是深度优先,所以关于父子组件嵌套的组件生命周期执行顺序可以用几个字来概括:*先有父亲,才有儿子,儿子挂载,父亲挂载,父亲更新,儿子更新,儿子更新完,父亲才更新完,儿子销毁,父亲才销毁

ClassComponent组件生命周期执行顺序:

Mount:

parent-constructor -> parent-getDerivedStateFromProps -> parent-render -> child-constructor -> child-getDerivedStateFromProps -> child-render -> child-componentDidMount -> parent-componentDidMount

Update:

parent-getDerivedStateFromProps -> parent-shouldComponentUpdate -> parent-render -> child-getDerivedStateFromProps -> child-shouldComponentUpdate -> child-render -> child-componentDidUpdate -> parent-componentDidUpdate

UnMount:

child-componentWillUnmount -> parent-componentWillUnmount

FunctionComponent组件生命周期执行顺序:

parent-render -> child-render -> child-useLayoutEffect -> parent-useLayoutEffect -> child-useEffect -> parent-useEffect

十、你知道React在写循环的时候的key是什么作用吗?

在写jsx遍历数组结构的时候,我们通常会给标签绑定上一个key,比如:

[1,2,3].map(item=>{
  <p key={item}>{item}</p>
})

那么这个key的作用就是用来区分current Fiber节点与workInProgress Fiber节点,是不是同一个节点,以便于useFiber复用,进而提高diff的性能。具体的可以看React源码解析系列(五) -- reconcileChildren的解读

十一、你理解虚拟dom的概念吗,谈一谈我们为什么要有虚拟dom?

所谓虚拟dom,也可以叫做虚拟dom树,他其实就是一个js对象,以便于描述与记录将要生成的真实dom树的一个diff后的产物。为什么我们需要有虚拟dom呢,我应该有四点:

  • 能够解决大量的不必要的dom更新:在细小颗粒度层面,因为更新往往包含多个dom操作,如果每一次修改数据,就去操作一个dom,那将会带来很大的性能问题。使用虚拟dom的好处就在于不管你有多少个操作,我只会一次性的去操作真实dom,提高性能。
  • 在内存方面,虚拟dom的计算是独立在js线程之外的,很大的空余去做其他的事情,不必时时刻刻关注dom渲染,只需要最后虚拟dom计算完毕之后,绘制一次就可以了,大大提高了能存使用效率
  • 虚拟dom能够有更好的兼容性、安全性。主要表现在内部对终端版本的兼容处理,对前端安全的防范。
  • 虚拟dom还有另外一个优点就是能够跨终端跨平台

既然虚拟dom这么强,那是不是没有缺点呢,换句话说使用虚拟dom一定比直接操作原生dom快吗?答案肯定是不一定的,因为在简单的场景下,虚拟dom多了一层计算,最后才会有映射到真实dom上的操作。

十二、能不能给我介绍一下React的合成事件呢?

可以详细看看React源码解析系列(十一) -- react生命周期与事件系统的解读。那么React的合成事件与原生事件对比有什么好处呢?

  • 原生事件就是js的原生事件,通过document.addEventListener来做事件监听。React重新封装了绝大部分的原生事件。合成事件采用了事件池,这样做可以大大节省内存,而不会频繁的创建和销毁事件对象。
  • React合成事件,并不是像原生事件一样绑定在dom节点上,而是绑定在root上(React16绑定在document上),通过元素触发并冒泡到root上,去执行对应的事件处理函数。
  • React通过对象池的形式管理合成事件对象的创建和销毁,减少了垃圾的生成和新对象内存的分配,提高了性能

image.png

展望

本系列更新完毕,我将会在wlb的同时,抽出时间去深究一下其他的东西,比如Vue3的源码api以及周边生态相关的框架或者nodejs工程化相关的东西,同时也希望跟各位同学一起交流学习心得。