React 初次接触

背景

还是为了完善高大上的在线文档系统,虽然比着葫芦画瓢的修改了一些所谓的代码,慢慢的才发现,原来这就是传说中的React,所以有比较又要囫囵吞枣一下React。

基本原理

参照《React技术揭秘》 网上有电子版 ,应该是个高手,点赞

概述

前端框架主要的作用是将数据的变化映射为UI的变化: UI=fn(state)

fn就是计算数据的变动导致UI是如何变化的,不同的框架中,fn的描述方式不同

主流的描述方式分为:

  1. jsx:使UI和逻辑更紧密,它是ES语法糖.(从逻辑出发,扩展逻辑,描述UI)
  2. 模板语法:使用HTML描述UI,它是HTML语法扩展。(从UI出发,扩展UI,描述逻辑)

jsx是动态的,即使代码没有变,每次更新,都会重新编译,

模板语法是静态的,可以用来分析,哪些节点是动态的,哪些节点是静态的。有很大的优化空间。

不管是jsx还是模板语法,它们都是组织逻辑和UI的关系

根据UI变化方式(更新细粒度)不同,将框架可以分为三类:

  1. 应用级:数据变化时,重新渲染整个应用,React
  2. 组件级:数据变化时,重新渲染数据有变化的组件Vue
  3. 元素级:数据变化时,只渲染数据变化的DOM节点,Svelte

按下性能问题暂且不表,先想想,为啥会有这种差别呢?这是因为不同的框架,架构不同导致的。

我们的代码并不是立即执行的,而是先进行编译(语法转换、将ts转为js、压缩、polyfill等),将我们的代码转为宿主环境可以识别的代码。

React

React经过编译之后返回的是createElement函数,所以每次数据变化,React都会从应用根节点重新加载整个应用。因此React无需知道是哪个变量发生变化导致的更新。

export const App = () => {
  const [count, setCount] = useState(0);
  return /*#__PURE__*/React.createElement("div", {
    onClick: () => setCount(conut++)
  }, count);
};

所以这种框被架称为应用级框架

Vue3

Vue3经过编译之后返回的是组件的render函数,Vue3会为每个组件建立watchEffect事件,这个大致如下:

在页面首次进入或者watchEffect的依赖项发生变化时,都会调用组件的render函数。

render函数的返回值是本次更新的VNode,Vue会根据本次更新的VNode与上次更新做比较(patch),找到最优的更新路径,并且进行更新。 所以这种框架被称为组件级框架

function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("h1", {
    onClick: _cache[0] || (_cache[0] = $event => (_ctx.count++))
  }, _toDisplayString(_ctx.count), 1 /* TEXT */))
}
// patch是对比前后VNode变化的方法
watchEffect(() => patch(render(props), preVDOM), [conut])

React性能

你肯定会问,我就改了个count的值,像React这样大动干戈,重新渲染整个应用,是不是很低效啊。

其实,React在运行时阶段,做了一部分关键的优化。不管是Vue还是React,在编译之后返回的都是VNode。

双缓存机制

一方面,React在拿到编译之后的VNode,首先会在内存中和上次更新的VNode进行对比,找到具体更新的VNode并且在内存中更新,上次没有更新时(mount),在内存中全部更新。

更多内容,查找对应的内容,我是只知道那么个意思就行。

React实现理解

react 和 vue 都是基于 vdom 的前端框架:

Vdom

html中有很多属性根本用不到,但在更新时却要跟着重新设置一遍。能不能只对比我们关心的属性呢?

把这些单独摘出来用 JS 对象表示不就行了?这就是为什么要有 vdom,是它的第一个好处。

而且有了 vdom 之后,就没有和 dom 强绑定了,可以渲染到别的平台,比如 native、canvas 等等。

这是 vdom 的第二个好处。为了更简洁,引入

dsl 的编译

dsl 是 domain specific language,领域特定语言的意思,html、css 都是 web 领域的 dsl。

直接写 vdom 太麻烦了,所以前端框架都会设计一套 dsl,然后编译成 render function,执行后产生 vdom。

vue 和 react 都是这样:

这套 dsl 怎么设计呢?前端领域大家熟悉的描述 dom 的方式是 html,最好的方式自然是也设计成那样。

所以 vue 的 template,react 的 jsx 就都是这么设计的。

vue 的 template compiler 是自己实现的,而 react 的 jsx 的编译器是 babel 实现的,是两个团队合作的结果。

编译成 render function 后再执行就是我们需要的 vdom。接下来渲染器把它渲染出来就行了。那渲染器怎么渲染 vdom 的呢?

渲染 vdom

渲染 vdom 也就是通过 dom api 增删改 dom。

比如一个 div,那就要 document.createElement 创建元素,然后 setAttribute 设置属性,addEventListener 设置事件监听器。如果是文本,那就要 document.createTextNode 来创建。所以说根据 vdom 类型的不同,写个 if else,分别做不同的处理就行了。没错,不管 vue 还是 react,渲染器里这段 if else 是少不了的:

switch (vdom.tag) {
  case HostComponent:
    // 创建或更新 dom
  case HostText:
    // 创建或更新 dom
  case FunctionComponent: 
    // 创建或更新 dom
  case ClassComponent: 
    // 创建或更新 dom
}

react 里是通过 tag 来区分 vdom 类型的,比如 HostComponent 就是元素,HostText 就是文本,FunctionComponent、ClassComponent 就分别是函数组件和类组件。那么问题来了,组件怎么渲染呢?

这就涉及到组件的原理了:

组件

我们的目标是通过 vdom 描述界面,在 react 里会使用 jsx。这样的 jsx 有的时候是基于 state 来动态生成的。如何把 state 和 jsx 关联起来呢?封装成 function、class 或者 option 对象的形式。然后在渲染的时候执行它们拿到 vdom 就行了。这就是组件的实现原理:

switch (vdom.tag) {
  case FunctionComponent: 
       const childVdom = vdom.type(props);
       
       render(childVdom);
       //...
  case ClassComponent: 
     const instance = new vdom.type(props);
     const childVdom = instance.render();
     
     render(childVdom);
     //...
}

如果是函数组件,那就传入 props 执行它,拿到 vdom 之后再递归渲染。如果是 class 组件,那就创建它的实例对象,调用 render 方法拿到 vdom,然后递归渲染。所以,大家猜到 vue 的 option 对象的组件描述方式怎么渲染了么?

{
    data: {},
    props: {}
    render(h) {
        return h('div', {}, '');
    }
}

没错,就是执行下 render 方法就行:

const childVdom = option.render();
 
 render(childVdom);

大家可能平时会写单文件组件 sfc 的形式,那个会有专门的编译器,把 template 编译成 render function,然后挂到 option 对象的 render 方法上:

所以组件本质上只是对产生 vdom 的逻辑的封装,函数的形式、option 对象的形式、class 的形式都可以。

就像 vue3 也有了函数组件一样,组件的形式并不重要。

基于 vdom 的前端框架渲染流程都差不多,vue 和 react 很多方面是一样的。但是管理状态的方式不一样,vue 有响应式,而 react 则是 setState 的 api 的方式。

真说起来,vue 和 react 最大的区别就是状态管理方式的区别,因为这个区别导致了后面架构演变方向的不同。

状态管理

react 是通过 setState 的 api 触发状态更新的,更新以后就重新渲染整个 vdom。

而 vue 是通过对状态做代理,get 的时候收集以来,然后修改状态的时候就可以触发对应组件的 render 了。

有的同学可能会问,为什么 react 不直接渲染对应组件呢?

想象一下这个场景:

父组件把它的 setState 函数传递给子组件,子组件调用了它。

这时候更新是子组件触发的,但是要渲染的就只有那个组件么?

明显不是,还有它的父组件。

同理,某个组件更新实际上可能触发任意位置的其他组件更新的。

所以必须重新渲染整个 vdom 才行。

那 vue 为啥可以做到精准的更新变化的组件呢?

因为响应式的代理呀,不管是子组件、父组件、还是其他位置的组件,只要用到了对应的状态,那就会被作为依赖收集起来,状态变化的时候就可以触发它们的 render,不管是组件是在哪里的。

这就是为什么 react 需要重新渲染整个 vdom,而 vue 不用。

这个问题也导致了后来两者架构上逐渐有了差异。

react 架构的演变

react15 的时候,和 vue 的渲染流程还是很像的,都是递归渲染 vdom,增删改 dom 就行。

但是因为状态管理方式的差异逐渐导致了架构的差异。

react 的 setState 会渲染整个 vdom,而一个应用的所有 vdom 可能是很庞大的,计算量就可能很大。

浏览器里 js 计算时间太长是会阻塞渲染的,会占用每一帧的动画、重绘重排的时间,这样动画就会卡顿。

作为一个有追求的前端框架,动画卡顿肯定是不行的。但是因为 setState 的方式只能渲染整个 vdom,所以计算量大是不可避免的。

那能不能把计算量拆分一下,每一帧计算一部分,不要阻塞动画的渲染呢?

顺着这个思路,react 就改造为了 fiber 架构。 这个架构一坨,不看了

React渲染

铺垫


 

tupian


 

首先回想一下,如果不用任何框架,用原生js创建这样一颗dom树应该怎么处理。 为了减少dom操作,我们会先创建最底层的元素存放到变量中,然后依次创建其父元素,直至创建到最顶层的div,最后将顶层的div插入到dom中即可,这样避免了多次的dom插入。 React其实也是这样, 先来看一下我们常用的入口写法

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

在React 17及之前版本,我们都是通过以上方式将React组件注册到视图中。作为高级动物的我们是能一眼看出来哪个是最底层的元素(叶子节点),然后一层层向上创建父元素直至顶层元素(根节点)。但是对于一个程序来说,最开始是没办法知道哪个是叶子节点,所以只能通过入口提供的根节点向下遍历, 直到找到叶子节点然后创建对应的元素。 所以对于React框架来说, 是有两个方向的流程的, 自上而下查找子元素、自下而上创建dom元素,分别对应两个遍历流程beginWork、completeWork 后面会详分析两个流程,这里先大概了解。在进入这两个流程之前先看下render函数中做了哪些处理。

准备工作

我认为在进入beginWork、completeWork流程之前,先做些准备工作为这俩流程做铺垫。

(为了方便描述,暂且称React的顶层组件为根组件,需要挂在到的dom元素称为根元素即上文代码的div#root)

如果要遍历App组件,必须得标记一个起点,方便在后续创建到根节点时执行插入dom的操作。那么起点怎么标记呢?有的同学就说,App就是呀! 试想一下,如果根组件命名不是App而是Root或者别的名字,只认识App那就不行了。所以React内部加了一个"组件"HostRoot用来标示组件的开始(HostRoot并不是一个组件,只是特殊的值, 在创建根组件Fiber时, 作为tag使用)

(Fiber是个Class类型, 每个组件节点后续都会创建为fiber对象, 大概结构如下)


 

tupian

记录了根组件HostRoot还要记录根元素div#root,因为组件dom创建完后要插入到div#root下,并不是body下。

同时在这里还注册了事件,为啥要注册事件呢?对于dom事件,在jquery时代我们就知道,不要在每个元素上绑定事件,尽量通过代理绑定的形式绑定到父元素上。 没错React也一样,在17版本之前,所有的事件都是绑定到document上, 17之后都是绑定到挂载的根元素上即这里的div#root。我们在写代码时,虽然在React组件上绑定了像onClick、onFocus、onScroll等事件,最终都是通过代理的形式触发事件的执行的。React的事件系统也很精彩后面单独抽出一篇文章来分析。 这里只需知道也是在开始遍历之前,先把事件在根组件上注册了。

整个过程的主要函数调用流程为


 

tupian


 

在实例化ReactDomBlockingRoot时又创建了根组件对应的fiber对象即上面所说tag为HostRoot的Fiber我们称为RootFiber, 同时为了维护RootFiber和div#root的关系创建了一个对象叫FiberRoot。 此外对于div#root、FiberRoot、RootFiber三个对象上都有字段指向彼此,这样在不同的场景下,都能很容易根据一方找到另外两个。实例化的主要函数流程调用如下图,可以看到通过调用listenToAllSupportedEvents在div#root上注册了事件


 

tupian

三者之间的关系如下


 

mage.png

准备工作做完开从根组件向下遍历查找子组件了

自上而下、自下而上遍历执行

在没遍历执行beginWork之前,react也不知道后续的组件结构会是啥样,所以在beginWork时每遇到一个组件时都要记录下来,同时要记录父组件和子组件、组件与组件间的关系,这样才能保证后续创建出来的dom树不会错乱掉。react内部对于每个组件都会创建成Fiber对象,通过Fiber记录组件间的关系,最后构成一个Fiber链表结构。 父组件parentFiber.child指向第一个子组件对应的fiber,子组件的fiber.return指向父组件,同时子组件的fiber.sibling指向其右边的相邻兄弟节点的fiber, 构成一个fiber树。如下图


 

image.png

还需要说明的是, beginWork的遍历并不是先查找完某一层所有的子元素再进行下一层的查找, 而是只查父元素的第一个子元素, 然后继续查找下一层的子元素, 如果没有子元素才会查找兄弟元素,兄弟元素查找完再查找父元素的兄弟元素, 类似于二叉树的前序遍历。所以对于上图的结构, 遍历顺序如下: App->Comp1->Comp3->div1->div2->div3->div4->Comp2->div5

beginWork

beginWork主要的功能就是遍历查找子组件,建立关系树。 那么怎么查找子组件呢,我们只分析class组件和函数式组件。 对于函数式组件,会执行组件对应的函数,注册hooks,同时拿到函数return的结果,即为该组件的child;对于class组件,会先实例化class,在这个阶段也会调用class的静态方法getDerivedStateFromProps以及实例的componentWillMount方法最后执行render方法拿到对应的child。 在mount阶段和update阶段, beginWork的执行逻辑也有区别的。 我们都知道为了减少重排和重绘,react帮助我们找出那些有变化的节点,只做这些节点的更新。 在mount阶段,因为在这之前没有创建节点,所以每个节点的fiber都是新建的;在update阶段, 会通过diff算法判断当前节点是否需要变更,如果需要变更会重新创建新的fiber对象并复用部分老的fiber对象属性,如果不需要变更则直接clone老的fiber对象;如果diff对比后老的fiber存在,新的fiber不存在,则会给fiber打上Deletion标签标示该元素需要删除; 如果老的fiber不存在,新的fiber存在说明是新创建的元素,则给fiber打上Placement标签。 beginWork大概流程如下


 

tupian

completeWork

completeWork阶段主要执行dom节点的创建或者标记变更。在mount阶段时,对于自定义组件比如class组件、函数式组件,其实不做什么特殊处理; 对于div、p、span(这种组件在react内部定义为HostComponent),就会调用document.createElement方法创建dom元素存放到该节点fiber对象的stateNode字段上;对于父元素是HostComponent的情况,先创建父元素的dom节点parentInstance, 然后调用parentInstance.appendChild(child)方法将子元素挂在该节点上。 在update阶段,如果老的fiber存在则不会重新创建dom元素,而是给该元素打上Update标签;如果是新的元素和mount阶段一样创建新的dom元素。 大概流程如下


 

image.png

此外在react内部, beginWork和completeWork是交替进行,这是为什么呢? 试想一下, 如果不交替运行,beginWork执行完之后只记录了关系, 然后再想通过completeWork创建dom元素,是不是又得从根组件开始遍历一遍,这样就至少需要遍历两遍。react通过合适的时机切换执行beginWork、completeWork只需遍历一遍就可以完成所有操作了。那么在什么时机切换呢?还记得我们一开始说,用原生js创建dom时先创建最底层的元素, react也是,在遍历执行beginWork到最底层元素时即下图的div1,该元素已经没有子元素了, 开始执行completeWork创建dom节点, 执行完div1的completeWork又切换成执行div2的beginWork,div2也没有子节点,所以进而执行div2的completeWork; div3也同样先执行beginWork再执行completeWork, 和div1、div2不同的是, div3已经没有右边的兄弟元素了, 转向执行父元素Comp3的completeWork, 然后再执行div4的beginWork。所以beginWork和completeWork的执行顺序是动态切换的
 

图片

在beginWork和completeWork时, 分别维护了一个指针workInProgress、completeWork指向当前正在执行的work的节点, 执行完当前节点指针执行下一个节点, 通过判断workInProgress是否为null进行beginWork => completeWork的切换, 通过判断fiber.sibling是否为null进行completeWork => beginWork的切换。

整个遍历流程的主要函数调用如下


 

图片


 

经过beginWork、completeWork, 每个组件节点的dom元素都创建完成或是被打上了对应的标签。在mount阶段,根组件下已经挂载了所有子元素节点的dom, 那么只需要将根组件dom节点插入到div#app下即可;update阶段组件fiber都被打上了标记,哪个元素需要删除,哪个需要更新都在下个阶段这些;这些操作在commit流程中进行。

Commit阶段

上面说了对于dom元素挂在到根标签div#root上以及一些元素的删除、更新等都是在commit阶段进行。 此外我们声明的一些useLayoutEffect、useEffect等hooks,以及组件的生命周期也会在该阶段运行。 commit又分为3个阶段分别为commitBeforeMutationEffects、commitMutationEffects、commitLayoutEffects

1. commitBeforeMutationEffects

个人认为该阶段主要是为后面两个阶段做一些准备工作

对于不同组件,处理逻辑不同。 对于HostRoot根组件,在mount时会清除根节点div#root已有的子元素,为了插入App的dom做准备; 对于函数式组件,在这个阶段会通过react-scheduler以普通优先级调用useEffect但是不会立刻执行,可简单认为在这里加了一个延时器执行useEffect; 对于class组件会调用静态方法getSnapshotBeforeUpdate, 即组件被提交到dom之前的方法

2. commitMutationEffects

在这个阶段,主要是根据组件上打的对应标签,执行不同的逻辑; 比如mount阶段,App组件对应的dom节点就会挂在到div#root上了,此时页面就可以看到对应的元素了;在update时,会根据被打的标签执行对应的Update、Deletion、Placement等; 同时在该阶段,如果存在useLayoutEffect的回调即组件被销毁的函数也会在该阶段执行

3. commitLayoutEffects

因为上个阶段已经把组件的dom元素挂在到页面中去了, 这个阶段主要是执行组件的mount生命周期函数,比如函数组件的useLayoutEffect、componentDidMount;

以上三个阶段执行完,如果没有更高优先级的任务(比如在didMount生命周期里有调用setState), 则第一阶段延迟执行的函数会调用useEffect; 如果有则会进入update阶段,重新执行beginWork、completeWork、commit。 其实可以发现useEffect和componentDidMount的执行时机还是有区别的。

整个commit的主要函数调用流程如下


 

tupian


 

这样整个react的渲染和更新流程基本结束

总结

  1. 个人感官,用起来是相当的爽,特别是return出一个东西来,这就是组件的力量。
  2. 原理知道大体的意思就行,出了问题知道朝着哪个方向去找,react 官方的入门教程,有时间多读几遍价值很大的, 快速入门 – React 中文文档
  3. 可能还有比较长的路要走,如果有可能,尽量还是用React吧,Vue立刻觉得不香了,哈哈,虽然,vue也不熟,就怕货比货,饱暖思淫欲,得客服。