为什么react每次改变一个节点的值都要重新生成一个完整的虚拟dom树?

为什么react每次改变一个节点的值都要重新生成一个完整的虚拟dom树?

如上图,我改变c的值,为什么要重新生成一颗完整的虚拟dom树呢?不能只针对改变的地方生成呢?
vue好像是只针对改变的地方生成,求解惑


回答:

我来正式答一下吧

React

更新

之前的内容有点问题,React源码还是很早之前看的,更新fiber后只是简单的看了下源码没深入,又重新看了一遍React源码来修复之前的问题

从触发setState组件开始,往下遍历调用子元素render,中间可以跳过shouldComponentUpdate等方法跳过

正确的说法是:从触发setState节点开始,先往上找到root最顶层根元素,然后往下根据已存在属性拷贝一份新的fiber,直到触发setState节点,再往下遍历调用子元素render,中间可以根据shouldComponentUpdate等方法跳过

多了一个往上找直到root开始遍历(因为是fiber链表结构没有层级概念,依然用树的那一套就会导致渲染重复),并且父、祖父元素都拷贝一份新fiber的步骤

React自从16.8开始使用了 fiber 架构

fiber使用了链表结构串联来虚拟dom树,主要的三个参数:child(子)、sibling(下一个兄弟)、return(父)
为什么react每次改变一个节点的值都要重新生成一个完整的虚拟dom树?
fiber遍历过程就是找第一个元素一直找到底,然后找兄弟,没兄弟了往上

这个阶段分为两块,往下的过程是一些调用render或者克隆一个fiber节点的操作,往上的过程是生成effectupdateQueue更新内容的操作

假如在Text2内触发setState

为什么react每次改变一个节点的值都要重新生成一个完整的虚拟dom树?

Text2标记更新字段lanes: 1,并一直往上找,找到root,并给沿途所以父节点设置childLanes: 0,然后从root根节点开始遍历

  • 当遍历到 lanes: 0 的时候:

    • childLanes: 0 表示子孙元素没有变动直接跳过,也等于跳过了diff
    • childLanes: 1 就只是从之前的fiber克隆一个新的fiber节点
  • 当遍历到 lanes: 1的时候

    • 如果shouldComponentUpdate或类似的操作不更新,则走到上面lanes: 0的流程
    • 调用render生成新的fiber

当到达最底部没有子元素的时候,开始compile生成updateQueue节点然后重复上面步骤(叶子节点为没有子元素的节点)叶子节点->兄弟->父->子->叶子,最终回到root结束

为什么react每次改变一个节点的值都要重新生成一个完整的虚拟dom树?

然后commit阶段,这时候有个Effect链表(Effect链表只有变动的节点),遍历Effect拿到节点的updateQueue更新了哪些内容,将updateQueue渲染到dom上

Vue

Vue众所周知是依靠响应式那么怎么依靠呢

<div>

<div>{{name}}</div>

<div>{{age}}</div>

</div>

当我们在修改name或者age的时候就会触发render

Vue并不会管哪个触发的,反正只要有一个或多个触发一合并最终调用render方法就行了,这里有个误区,vue可以精细到具体dom上,响应式可以知道在哪个函数里调用了依赖,但是不知道你把变量赋值给谁了啊

function fn(){

const a = vue.name

}

可以知道在函数fn里依赖了响应式的name属性,因为fn被包装处理了,但是没法知道你赋值给a了,相同道理也没办法知道赋值给<div>{{name}}</div>,就算利用编译时处理也没法处理各种动态情况<div>{{obj[name][c]}}</div>


回到正题,响应式的子组件是否渲染是怎么做的?
分为两种情况,props的传递,一种是传递值,一种是传递引用

<template>

<div @click="handleClick">A组件</div>

<Text :value="obj.title"></Text>

</template>

<script setup>

const obj = reactive({

title: '你好'

})

function handleClick() {

obj.title = 'hello'

}

</script>

我们把title传给了Text组件,但是 obj.title 这个语法就触发依赖追踪了,这个触发是在A组件里的,传给Text的是个文本【A组件render用了objtitle字段

所以obj.title = 'hello' 触发的是A组件的renderText是否要更新呢?,Vue浅层对比props发现title字段由你好变成hello,就触发了Text的更新,Textrender被调用了

如果更新的是其他字段,Text自动浅层数据props对比发现没变化就会自动跳过

引用

现在我们改一下,改成引用传递

<Text :value="obj"></Text>

// Text组件内部:

<div>{{value.title}}</div>

这时候就不一样了,因为把obj传给了value,所以value.title就相当于obj.title,这就触发了响应式追踪【在Text组件render内使用了objtitle属性】

那么当obj.title = 'hello' 就会触发Textrender,不会触发父级的A组件render了,也不会经过Text组件的浅层数据比对了,精准触发了Textrender

总结

当传递值的时候更新的是父元素,因为传递的是值只需要浅层对比
当传递引用的时候,更新的是使用的那个组件的render

单纯是否决定渲染来说:Vue相比于React,不用手动处理数据的比对,哪个组件的数据发生变化就调用哪个的render

二者的核心流程都差不多,render方法调用,是否更新组件,diff(不更新组件就直接跳过这个组件的diff)
细节上差别也挺大,ReactfiberConcurrent renderingVue有响应式和模板标记优化


回答:

最近也在看 react fiber 相关的代码,我从一开始也有这个疑问。哈哈,专门注册了一个账号来回答。
我说说我的看法,其实如 @liuye1296 在回答中说到,react 是单向数据流,理论上我们可以只处理以 setState 节点及其指数。但是实际上 react 的做法如 @李十三 所说,大概是:

  1. 从 setState 往上回溯直到 root,目的是把当前的优先级标准到父节点上
  2. 从 root 往下遍历,判断:

    1. properties 发生改变,需要更新,继续遍历
    2. properties 未发生改变,优先级满足,说明 state 发生改变,需要更新(还要考虑 shouldUpdate 方法,如果返回 false 也不需要遍历),继续遍历
    3. properties 未发生改变,当前节点优先级不满足,但是子节点优先级满足,需要更新子节点,继续遍历
    4. 不符合以上几种情况,不需要往下遍历

为什么不直接从 setState 节点开始更新,是因为 react 存放任务优先级的机制。每次 set 之后会生成一个新任务。react 会根据当前任务情况(未完成的任务,上次被打断的任务等)计算一个更新优先级,如果当然任务的优先级等于这个更新优先级,就不会新启动一个任务,而是复用之前的任务。也就是说一个任务中,存的更新可能不只是当前组件的 setState 引起的,还可能包括其他组件。因此不能直接从某个 setState 的组件开始更新,而是要从 root 开始遍历。


回答:

个人认为:
一个节点发生变化可能会影响附件的节点,那如果去比较发生变化的节点的子节点需要一层一层的比较,时间复杂度比较高。
react是自顶向下,一次可以遍历整个dom结构生成新的虚拟树,而且允许用shouldComponentUpdate来决定是否需要进行diff。
vue是在发生变化时,把变化的信息存入更新队列,在合适的时机调用render,然后对新旧2个虚拟树进行diff和patch。

ps: 一般都是研究diff算法的,对于变化时如何重新生成虚拟树的具体过程的文章比较少,我也不是非常清楚。


回答:

@李十三 其实说的很对的,你的问题和你如何得到这个结论有很大关系。
其实这是一个很简单的问题,是更新容易还是删除后重建容易?明显是后者,例如“您的爱好”这种复选框,每次保存的时候后端开发者通常都是删掉老数据然后保存新数据的,否则就要先对比哪些没变,哪些需要删掉,然后还要写入什么。
所以,当组件的props变化时,更新虚拟dom树的最佳办法就是产生该组件的虚拟dom树,然后对页面的虚拟dom树上的节点进行替换。如果同一个props用于多个组件,那么这个组件的公共父将被替换。state也是如此,但是被局限在当前组件内而已。
...反复修改,折腾1个多小时了,感觉确实很难解释清楚,丢一小段代码给你,也许有帮助。

import React, {useState, useEffect} from 'react';

import ReactDOM from 'react-dom';

const Element = (props) => {

const [num, setNum] = useState(0);

useEffect(() => {

setNum(props.num * 2);

}, [props]);

return <>

<h1>Great! ({props.num})</h1>

<h1>Hello! ({num})</h1>

<h1>Merge! ({props.num}) ({num})</h1>

<div>

<h1>Great! ({props.num})</h1>

<h1>Hello! ({num})</h1>

</div>

</>

}

let i = 0;

setInterval(() => {

i++;

ReactDOM.render(<Element num={i} />, document.getElementById('container'));

}, 5000);


回答:

setState告诉react需要更新,但它不知道哪里改变了,于是生成一棵新的树,和原来的对比,才能知道如何修改dom。

vue能知道那里变更了是因为它有依赖收集。触发getter的时候就能将vnode和data绑定上,发生变更时直接对有依赖的vnode比较就可以了。


回答:

如果你的【完整的虚拟dom树】指的是render/functon component return的那个东西的话,那确实如此
不过,在vue2里也是这么做的,vue3据说才有一定的优化
精准dom更新只有solidjs、svelte这种框架能做到


回答:

React 并不是生成一颗完整的DOM 树 而是以调用setState的组件为 ROOT节点 开始重新生成一棵DOM树
为什么出现这种情况呢?
Vue 是对一个对象 监听 这个对象会收集所有依赖过他的组件,每次这个调用对象的set方法的时候 就会去通知它收集的依赖 去更新。
React 则是用户触发setState 通知React 更新。而基于React 数据单向流设计 组件更新是不会影响到父组件的 只会影响他的子组件。所以React 会从自己开始往下生成一颗完整的DOM树 再对比 (React 需要开发者手动调用API 去对比Props 子组件是否更新。而Vue 本身已经做了这一步了)
结论就是: Vue 基于他的依赖收集 可以做到颗粒度更细的更新 而React 是没有依赖收集的 则是自顶向下重新渲染


回答:

最近在看 react 18.1 的源码 也有类似的问题,我认为可能是这样的
虽然触发的是fiber tree 的内部的某个 component 的 state hook,但是 react 会回溯到顶层root,但是会向上冒泡 childLines ,同时设置当前触发了 state hook 的fiber lane = 1,
在下一次更新任务的时候,就是从root 开始的。这里有一个比较重要的比较点,react 会比较 props 和memoProps 是否相等如果是相同的 会执行 bailout 操作,只会单纯的clone current fiber 的数据,这里的操作其实并不多,对于这种bailout的fiber 他的 所有child 都会做bailout操作,如果不是在 触发了 state hook 的那个分支的 tree上面。这应该是基于 react 双缓存的先决条件来做的,保证work的fiber 和 current 的fiber 数据的独立性。

但是,还是有一个问题,如果到达了触发 state 的 fiber那里,这个fiber 的子集child 就不能到bailout这个逻辑了,因为这个时候 clone children的时候会从 element.props 设置fiber 的props,导致 在和 memoProps 比较的时候 永远不会相等,这里感觉react 执行了很多的无用代码,导致整个子 tree 都需要走一遍 component (组件)的逻辑。而上面的遍历因为有bailout的操作其实用不了多久时间,但是走 component的逻辑就很花时间。

以上是 为什么react每次改变一个节点的值都要重新生成一个完整的虚拟dom树? 的全部内容, 来源链接: utcz.com/p/937213.html

回到顶部