4

[react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并...

 2 years ago
source link: https://www.cnblogs.com/echolun/p/16359890.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
1213309-20220609163742187-416297054.png

虚拟DOM(Virtual DOM)在前端领域也算是老生常谈的话题了,若你了解过vue或者react一定避不开这个话题,因此虚拟DOM也算是面试中常问的一个点,那么通过本文,你将了解到如下几点:

  • 虚拟DOM究竟是什么?
  • 虚拟DOM的优势是什么?解决了什么问题?
  • 虚拟DOM的性能比操作原生DOM要快吗?
  • react中的虚拟DOM是如何生成的?
  • react是如何将虚拟DOM转变成真实dom的?

阅读前建议与提醒:

  • 本篇文章可能比较长,建议挑一个空闲的时间段阅读,还请保持耐心,我将以通俗易懂的口吻带你了解这些问题。
  • 本文源码分析部分react版本为17.0.2,无须担心低版本源码分析对你之后面试帮助不大的问题。
  • 如果可以,泡上一杯性温的茶或者咖啡,保持一个舒服的姿势会让你阅读更加愉快。

那么本文开始。

贰 ❀ 在虚拟dom之前

在聊虚拟DOM之前,我还是想先聊聊在没有虚拟DOM概念的时候,我们是如何更新页面的,所以在这里我将先引出前端框架(库)的发展史,通过这个变迁过程也便于大家理解虚拟dom的出现到底解决了什么问题。

贰 ❀ 壹 石器时代jqery

其实在15年以及更早之前,前端面试涉及到性能优化问题,往往都会提到尽可能少的操作DOM这一点。为什么呢?因为在原生JS的年代,前端项目文件都明确分为html、jscss三种,我们在js中获取DOM,并为其绑定事件,通过事件监听感知用户在UI层的操作,并随之更新DOM,从而达到页面交互的目的:

1213309-20220609163746500-534884916.png

而在后面,jqery的出现极大简化了开发者操作DOM的成本,抹平了当时不同浏览器操作DOMAPI差异,为当时苦于ie以及不同浏览器自研API的开发者解决了不少兼容性问题,当然JQ也并未改变开发者在JS层直接操作DOM这一现状。

那么我们为什么说要尽可能少的操作DOM呢,这里就涉及到重绘与回流两个概念,比如单纯修改颜色就会引发重绘,删除或新增一个DOM节点就会引发回流和重绘,用户虽然无法感知这个过程,但对于浏览器而言也存在消耗性能。所以针对于回流,在此之后又提出了DocumentFragment文档对象以优化多次操作DOM的方案。简单理解就是,假如我要依次替换五个li节点,那么我们可以创建一个DocumentFragment对象保存这五个节点,然后一次性替换。

关于节流与重绘,若有兴趣可读读博主页面优化,谈谈重绘(repaint)和回流(reflow)一文。

关于DocumentFragment可读读博主页面优化,DocumentFragment对象详解一文。

这些都是时代的眼泪,现在应该很少会有人提及,这里就不再赘述了。

贰 ❀ 贰 青铜时代angularjs

JQ之后,angularjs(这里指angularjs1而非angular)横空出世,一招双向绑定在当时更是惊为天人,除此之外,angularjs的模板语法也格外惊艳,我们将所有与数据挂钩的节点通过{{}}包裹(vue在早期设计上大量借鉴了angularjs),比如:

<span>{{vm.name}}</span>

之后 view 视图层就自动与 Model 数据层进行挂钩(MVC那一套),只要 Model 层数据发生变化,view 层便自动更新。angularjs 的这种做法,彻底将开发者从操作 DOM 上解放了出来(为jq没落埋下伏笔),自此之后开发者只用专注 Model 层的数据加工以及业务处理,至于页面如何渲染全权交给 angularjs 底层处理即好了。

但需要注意的是,angularjs 在当时并没有虚拟dom的概念,那它是怎么做感知数据层变化以及更新视图层的呢?angularjs有一套脏检测机制$digesthtml中凡是使用了模板语法{{}}或者ng-bind指令的部分,都会被加入到脏检测的warchers列表中,它是一个数组,之后只要用户通过ng-click(与传统click不同,内置绑定了触发脏检测的机制)等方法改变了Model的数据,angularjs就会从顶层rootScope向下递归,依次访问每个子scope中的warchers列表,并对其中监听的部分做新旧对比,如果不同则进行数据替换,以及DOM层的更新。

但是你要想想,一个应用那么大的结构,只要某一个数据变化了就得从顶层向下对比N个子 scopewarchers 下的所有监听对象,全量对比的性能有多差可想而知,angularjs 自身也意识到了这点,所以之后直接放弃了 angularjs 的维护转而新开了 angular 项目。

对于 angularjs 脏检测感兴趣可以读读博主深入了解angularjs中的𝑑𝑖𝑔𝑒𝑠𝑡与apply方法,从区别聊到使用优化一文,同样是时代的眼泪了。

贰 ❀ 叁 铁器时代react与vue

如果从 angularjs 转到 vue ,你会发现早期vue的模板语法、指令,双向绑定等很多灵感其实都借鉴了angularjs,但在更新机制上,vue 并不是一个改动牵动全身,而是组件均独立更新。reactvue 一样相对 angularjs 也是局部更新,只是 react 中的局部是以当前组件为根以及之下的所有子组件。

打个比方,如果组件 A 状态发生变化,那么 A 的所有子组件默认都会触发更新,即使子组件的props未发生改变,所以对于react我们需要使用 PureComponentshouldComponentUpdate 以及 memo 来避免这种场景下的多余渲染。而在更新体系中,reactvue 都引入了虚拟 DOM 的概念,当然这也是本文需要探讨的重点。

我们先总结下上述的观点:

jsjq:研发在专注业务的同时,还要亲自操作 dom

angularjs版本1:将研发从操作 dom 中解脱了出来,更新 dom 交由 angularjs 底层实现,这一套机制由脏检测机制所支撑。

react/vue:同样由底层更新 dom,只是在此之前多了虚拟dom的对比,先对比再更新,以此达到最小更新目的。

所以相对传统更新 dom 的策略,虚拟dom的更新如下:

1213309-20220609163749516-249351160.png

到这里,我们站在宏观的角度解释了前端框架的变迁,以及有虚拟dom前后我们如何更新dom,也许到这里你的脑中隐约对于虚拟dom有了一丝感悟,但又不是很清晰,虚拟dom到底解决了什么问题,别着急,接下来才是虚拟dom的正餐,我们接着聊。

叁 ❀ 什么是虚拟DOM?

本文将默认你有 react 或者 vue 的开发经历,当然本文出发点还是以react为主。

熟悉 react 的同学对于 React.createElement 方法一定不会陌生,它用于创建reactNode,语法如下:

/*
* component 组件名,一个标签也可以理解成一个最基础的组件
* props 当前组件的属性,比如class,或者其它属性
* children 组件的子组件,就像标签套标签
*/
React.createElement(component, props, ...children)

比如我们定一个最简单的html片段:

<span className='span'>hello echo</span>

React.createElement表示如下:

React.createElement('div', {className:'span'}, 'hello echo');

这样看好像也没什么大问题,但是假定我们dom存在嵌套关系:

<span className='span'>
  <span>
    hello echo
  </span>
</span>

React.createElement表示就相对比较麻烦了,你需要在createElement中不断嵌套:

React.createElement('span', {className:'span'}, React.createElement("span", null, "hello echo"));

这还仅仅是两层嵌套,实际开发中dom结构往往要复杂的多,因此react中我们常常推荐直接使用jsx文件定义业务逻辑以及html片段。

我们可以将jsx中定义的html模板理解成React.createElement的语法糖,它方便了开发者以html的习惯去定义reactNode片段,而在编译之后,这些reactNode本质上还是会被转变成React.createElement所创建的对象,这个过程可以理解为:

1213309-20220609163751367-120912735.png

为方便理解,我们可以将React.createElement创建对象结构抽象为:

const VitrualDom = {
  type: 'span',
  props: {
    className: 'span'
  },
  children: [{
    type: 'span',
    props: {},
    children: 'hello echo'
  }]
}

说到底,这个就是传递给React.createElement的结构,而React.createElement接收后生成的数据,其实才是真正意义上的虚拟dom。我们可以简单定一个react组件,来查看虚拟dom真正的结构:

class C extends React.PureComponent {
  render() {
    console.log(this.props.children);
    return <div>{this.props.children}</div>;
  }
}

class P extends Component {
  render() {
    return (
      <C>
        <span className="span">
          <span>hello echo</span>
        </span>
      </C>
    );
  }
}
1213309-20220609163753123-1673233804.png

那么到这里,我们搞清楚了虚拟DOM究竟是什么,所谓虚拟DOM其实只是一个包含了标签类型type,属性props以及它包含子元素children的对象。

肆 ❀ 虚拟DOM的优势是什么?

肆 ❀ 壹 销毁重建与局部更新

在提及虚拟DOM的优势之前,我们可以先抛开什么虚拟DOM以及什么MVC思想,回想下在纯 js 或者 jq 开发角度,我们是如何连接UI和数据层的。其实在16年之前,博主所经历的项目开发中,UI和数据处理都是强耦合,比如我们页面渲染完成,使用onload进行监听,然后发起ajax请求,并在回调中加工数据,以及在此生成DOM片段,并将其替换到需要更新的地方。

打个比方,后端返回了一个用户列表userList

const userList = [
  'echo',
  '听风是风',
  '时间跳跃'
]

前端在请求完成,于是在ajax回调中进行dom片段生成以及替换工作,比如:

<ul id='userList'></ul>
const ulDom = document.querySelector('#userList');
// 生成代码片段
const fragment = document.createDocumentFragment();

for (let i = 0; i < userList.length; i++) {
  const liDom = document.createElement("li");
  liDom.innerHTML = userList[i];
  // 依次生成li,并加入到代码片段
  fragment.appendChild(liDom);
}

// 最终将代码片段塞入到ul
ulDom.appendChild(fragment);

所以不管是页面初始化,还是之后用户通过事件发起请求更新了用户数据,到头来还是都是调用上面生成li的这段逻辑。在当时能想着把这段逻辑复用成一个方法,再考虑用上createDocumentFragment减少操作dom的次数,能做到这些,这在当时都是能小吹一波的了....

1213309-20220609163755826-280069838.png

所以你会发现,在原生js的角度,根本没有所谓的dom对比,都是重新创建,因为在写代码之前,我们已经明确知道了哪部分是静态页面,哪部分需要结合数据进行动态展示。那么只需要将需要动态生成的dom的逻辑提前封装成方法,然后在不同时期去调用,这在当年已经是非常不错的复用了(组件的前生)。

那么问题来了,假定现在我们有一个类似form表单的展示功能,点击不同用户,表单就会展示用户名,年龄等一系列信息:

1213309-20220609163757491-1310501744.png

js写怎么做?还是一样的,点击不同用户,肯定会得到一个用户信息对象,我们根据这个对象动态生成多个信息展示的input等相关dom,然后塞入到form表单中,所以每次点击,这个form其实都等同于完全重建了。

假定现在我们不希望完整重建这个结构,而是希望做前后dom节点对比,比如inputvalue前后不一样,某个style颜色不同,我们单点更新这个属性,比较笨拙的想法肯定还是得生成一份新dom片段,然后递归对比两个结构,且属性一一对比,只有不同的部分我们才需要更新。但仅仅通过下面这段代码,你就能预想到这个做法的性能有多糟糕了:

// 一个li节点自带的属性就有307个
const liDom = document.createElement("li");
let num = 0;
for (let key in liDom) {
  num += 1;
}
console.log(num); // 307

我们生成了一个最基本的li节点,并通过遍历依次访问节点的属性,经过统计发现li单属性就307个,而这仅仅是一个节点。

在前面我们也提到过,不管是jq封装,还是react vue的模板语法,它的前提一定是研发自己提前知道了哪部分内容未来是可变的,所以我们才要动态封装,才需要使用{}进行包裹,那既然如此,我们就对比未来可能会变的部分不是更好吗?

而回到上文我们对于虚拟结构的抽象,对于react而言,props是可变的,child是可变的,state也是可变的,而这些属性恰好都在虚拟dom中均有呈现。

所以到这里,我们解释了虚拟dom的第一个优势,站在对比更新的角度,虚拟dom能聚焦于需要对比什么,相对原生dom它提供更高效的对比可行性。

肆 ❀ 贰 更佳的兼容性

我们在上文提到,react与babeljsx转成了js对象(虚拟dom),之后又通过render生成dom,那为啥还要转成js而不是直接生成dom呢,因为在这个中间react还需要做diff对比,兼容处理,以及跨平台的考虑,我们先说兼容处理。

准确来说,虚拟dom只是react中的一部分,要真正体现虚拟dom的价值,肯定得结合react中的其它设计来一起讲,其中一点就是结合合成事件所体现的强大的兼容性。

我们在介绍jq时强调了它在操作dom的便捷,以及各类api兼容性上的贡献,而react中使用了虚拟dom也做了大量的兼容。

打个比方,原生的inputchange事件,普通的div总没有onchange事件吧?不管你有没有留意,其实dom和事件在底层已经做了强关联,不同的dom能触发的事件,浏览器在一开始就已经定义好了,而且你根本改不了。

但是虚拟dom就不同了,虚拟dom一方面模仿了原生dom的行为,其次在事件方面也做了合成事件与原生事件的映射关系,比如:

{
  onClick: ['click'],
  onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange']
}

react暴露给我们的合成事件,其实在底层会关联到多个原生事件,通过这种做法抹平了不同浏览器之间的api差异,也带来了更强大的事件系统。

若对于合成事件若感兴趣,可以阅读博主 八千字长文深入了解react合成事件底层原理,原生事件中阻止冒泡是否会阻塞合成事件?一文。

肆 ❀ 叁 渲染优化

我们知道react遵循UI = Render(state),只要state发生了改变,那么render就会重新触发,以达到更新ui层的效果。而更改state依赖了setState,大家都知道setState对于state更新的行为其实是异步的,假设我们在一次事件中更改了多次state,你会发现页面也仅会渲染一次。

而假定我们是直接操作dom,那还有哪门子的异步和渲染等待,当你append完一个子节点,页面早渲染完了。所以虚拟dom的对比提前,以及setState的异步处理,本质上也是在像尽可能少的操作dom靠近。

若对于setState想有更深入的了解,可以阅读博主这两篇文章:

react中的setState是同步还是异步?react为什么要将其设计成异步?

react 聊聊setState异步背后的原理,react如何感知setState下的同步与异步?

肆 ❀ 肆 跨平台能力

同理,之所以加入虚拟dom这个中间层,除了解决部分性能问题,加强兼容性之外,还有个目的是将dom的更新抽离成一个公共层,别忘了react除了做页面引用外,react还支持使用React Native做原生app。所以针对同一套虚拟dom体系,react只是在最终将体现在了不同的平台上而已。

伍 ❀ 虚拟DOM比原生快吗?

那么问题来了,聊了这么久的虚拟dom,虚拟dom性能真的比操作原生dom要更快吗?很遗憾的说,并不是,或者说不应该这样粗暴的去对比。

我们在前面虽然对比了虚拟dom属性以及原生dom的属性量级,但事实上我们并不会对原生dom属性进行递归对比,而是直接操作dom。而且站在react角度,即便经历了diff算法以及一系列的优化,react到头来还是要操作原生dom,只是对于研发来讲不用关注这一步罢了。

所以我们可以想象一下,现在要替换p标签的内容,用原生就是直接修改innerHTML属性,对于react而言它需要先生成虚拟dom,然后新旧diff找出变化的部分,最后才修改原生dom,单论这个例子,一定是原生快。

但我们既然说虚拟dom,就一定得结合react的使命来解释,虚拟dom的核心目的是模拟了原生dom大部分特性,让研发高效无痛写html的同时,还达到了单点刷新而不是整个替换(前面表单替换的例子),最重要的,它也将研发从繁琐的dom操作中解放了出来

总结来说,单论修改一个dom节点的性能,不管react还是vue亦或是angular,一定是原生最快,但虚拟dom有原生dom比不了的价值,起码react这些框架能让研发更专注业务以及数据处理,而不是陷入繁琐的dom增删改查中。

陆 ❀ 虚拟DOM的实现原理

文章开头的五个问题到这里已经解释了三个,还剩两个问题均与源码有一定关系,虽然略显枯燥但我会精简给大家阐述这个过程,另外,为了让知识量不会显得格外庞大,本文将不会阐述diff算法与fiber部分,这两个知识点我会另起文章单独介绍,敬请期待。

除此之外,接下来两个问题的源码,我将均以react17.0.2源码为准,所以大家也不用担心版本差异,会不会有理解了用不上的问题,而且目前用react 18的公司也不会很多。

我们先解释虚拟dom的创建过程,要聊这个那必然逃不开React.createElement方法,github源码,具体代码如下(我删除了dev环境特有的逻辑):

/**
 * 创建并返回给定类型的新ReactElement。
 * See https://reactjs.org/docs/react-api.html#createelement
 */
function createElement(type, config, children) {
  let propName;

  // 创建一个全新的props对象
  const props = {};

  let key = null;
  let ref = null;
  let self = null;
  let source = null;

  // 有传递自定义属性进来吗?有的话就尝试获取ref与key
  if (config != null) {
    if (hasValidRef(config)) {
      ref = config.ref;
    }
    if (hasValidKey(config)) {
      key = '' + config.key;
    }

    // 保存self和source
    self = config.__self === undefined ? null : config.__self;
    source = config.__source === undefined ? null : config.__source;

    // 剩下的属性都添加到一个新的props属性中。注意是config自身的属性
    for (propName in config) {
      if (
        hasOwnProperty.call(config, propName) &&
        !RESERVED_PROPS.hasOwnProperty(propName)
      ) {
        props[propName] = config[propName];
      }
    }
  }

  // 处理子元素,默认参数第二个之后都是子元素
  const childrenLength = arguments.length - 2;
  // 如果子元素只有一个,直接赋值
  if (childrenLength === 1) {
    props.children = children;
  } else if (childrenLength > 1) {
    // 如果是多个,转成数组再赋予给props
    const childArray = Array(childrenLength);
    for (let i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }
    props.children = childArray;
  }

  // 处理默认props,不一定有,有才会遍历赋值
  if (type && type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      // 默认值只处理值不是undefined的属性
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }

  // 调用真正的React元素创建方法
  return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props);
}

代码看着好像有点多,但其实一共就只做了两件事:

  • 根据createElement所接收参数config做数据加工与赋值。
  • 加工完数据后调用真正的虚拟dom创建API ReactElement

而数据加工部分可分为三步,大家可以对应上面代码理解,其实注释写的也很清晰了:

  • 第一步,判断config有没有传,不为null就做处理,步骤分为
    • 判断refkey__self__source这些是否存在或者有效,满足条件就分别赋值给前面新建的变量。
    • 遍历config,并将config自身的属性依次赋值给前面新建props
  • 第二步,处理子元素。默认从第三个参数开始都是子元素。
    • 如果子元素只有一个,直接赋值给props.children
    • 如果子元素有多个,转成数组后再赋值给props.children
  • 第三步,处理默认属性defaultProps,一个纯粹的标签也可以理解成一个最最最基础的组件,而组件支持 defaultProps,所以这一步判断有没有defaultProps,如果有同样遍历,并将值不为undefined的部分都拷贝到props对象上。

至此,第一大步全部做完,紧接着调用ReactElement,我们接着看这一块的源码,同样我删掉dev部分的逻辑,然后你会发现就这么一点代码,github源码

const ReactElement = function (type, key, ref, self, source, owner, props) {
  const element = {
    // 这个标签允许我们将其标识为唯一的React Element
    $$typeof: REACT_ELEMENT_TYPE,
    // 元素的内置属性
    type: type,
    key: key,
    ref: ref,
    props: props,
    // 记录负责创建此元素的组件。
    _owner: owner,
  };
  return element;
};

这个方法啥也没干,单纯接受我们在上个方法加工后的数据,并将其组装成了一个element对象,也就是我们前文所说的虚拟dom

不过针对这个虚拟dom,我们可以把$$typeof: REACT_ELEMENT_TYPE拧出来单独讲讲。我们可以看看它的具体实现:

// The Symbol used to tag the ReactElement-like types.
export const REACT_ELEMENT_TYPE = Symbol.for('react.element');

大家在查看虚拟dom时应该都有发现它的$$typeof定义为Symbol(react.element),而Symbol一大特性就是标识唯一性,即便两个看着一模一样的Symbol,它们也不会相等。而react之所以这样做,本质也是为了防止xss攻击,防止外部伪造虚拟dom结构。

其次,如果大家有在开发中留意,虚拟dom的不允许修改,哪怕你为这个对象新增属性也不可以,这是因为在ReactElement方法省略的dev代码中,react使用Object.freeze冻结了虚拟dom使其无法修改。但实际上我们确实有为虚拟dom添加属性的场景,解决这个问题时我们可以借用顶层React.cloneElement()方法,它会以你传递的虚拟dom为模板克隆并返回一个新的虚拟dom对象,同时这个过程中你可以为其添加新的config,具体用法可见 React.cloneElement

其次,如果当前环境不支持Symbol时,REACT_ELEMENT_TYPE的值为0xeac7

var REACT_ELEMENT_TYPE = 0xeac7;

为什么是0xeac7呢?官方答复是,因为它看起来像React....好了,那么到这里,关于如何生成虚拟dom的源码分析结束。

柒 ❀ react中虚拟dom是如何转变成真实dom的

终于,我们来到了本文的最后一个问题,要想搞清这个问题,我们的关注点自然是ReactDOM.render方法了,这个部分比较麻烦,大家跟着我的思路走就行。(有兴趣可以直接把react脚手架项目跑起来,写一个最基本的组件,然后去react-dom.development.js文件断点也可以)。

// 我为了方便断点,定义了一个class组件P
class P extends Component {
  state = {
    name: 1,
  };
  handleClick = () => {};
  render() {
    return <span onClick={this.handleClick}>111</span>;
  }
}
ReactDOM.render(<P />, document.getElementById("root"));

首先我们来到render方法,代码如下:

function render(element, container, callback) {
	// 我删除了对于container是否合法的效验逻辑
  return legacyRenderSubtreeIntoContainer(null, element, container, false, callback);
}

render做的事情其实很简单,验证container是否合法,如果不是一个有效的dom就会抛错,核心逻辑看样子都在legacyRenderSubtreeIntoContainer中,根据命名可以推测是将组件子树都渲染到容器元素中。

// 同样,我删除了部分对主逻辑理解没啥影响的代码
function legacyRenderSubtreeIntoContainer(parentComponent, children, container, forceHydrate, callback) {
  var root = container._reactRootContainer;
  var fiberRoot;
	// 有fiber的root节点吗?没有就新建
  if (!root) {
    root = container._reactRootContainer = legacyCreateRootFromDOMContainer(container, forceHydrate);
    fiberRoot = root._internalRoot;
    unbatchedUpdates(function () {
      // 核心关注这里
      updateContainer(children, fiberRoot, parentComponent, callback);
    });
  } else {
    fiberRoot = root._internalRoot;

    updateContainer(children, fiberRoot, parentComponent, callback);
  }
  return getPublicRootInstance(fiberRoot);
}

因为react 16引入了fiber的概念,所以后续其实很多代码就是在创建fiber节点,legacyRenderSubtreeIntoContainer一样,它一开始判断有没有root节点(一个fiber对象),很显然我们初次渲染走了新建逻辑,但不管是不是新建,最终都会调用updateContainer方法。但此方法没有太多我们需要关注的逻辑,一直往下走,我们会遇到一个很重要的beginWork(开始干正事)方法,代码如下:

function beginWork(current, workInProgress, renderLanes) {
	// 删除部分无影响的代码
  workInProgress.lanes = NoLanes;

  switch (workInProgress.tag) {
    // 模糊定义的组件
    case IndeterminateComponent:
      {
        return mountIndeterminateComponent(current, workInProgress, workInProgress.type, renderLanes);
      }
		// 函数组件
    case FunctionComponent:
      {
        var _Component = workInProgress.type;
        var unresolvedProps = workInProgress.pendingProps;
        var resolvedProps = workInProgress.elementType === _Component ? unresolvedProps : resolveDefaultProps(_Component, unresolvedProps);
        return updateFunctionComponent(current, workInProgress, _Component, resolvedProps, renderLanes);
      }
		// class组件
    case ClassComponent:
      {
        var _Component2 = workInProgress.type;
        var _unresolvedProps = workInProgress.pendingProps;

        var _resolvedProps = workInProgress.elementType === _Component2 ? _unresolvedProps : resolveDefaultProps(_Component2, _unresolvedProps);

        return updateClassComponent(current, workInProgress, _Component2, _resolvedProps, renderLanes);
      }
    case HostRoot:
      return updateHostRoot(current, workInProgress, renderLanes);
  }
}

beginWork方法做了很重要的一件事,那就是根据你render接收的组件类型,来执行不同的组件更新的方法,毕竟我们可能给render传递一个普通标签,也可能是函数组件或者Class组件,亦或是hooksmemo组件等等。

比如我此时定义的Pclass组件,于是走了ClassComponent路线,紧接着调用updateClassComponent更新组件。

function updateClassComponent(current, workInProgress, Component, nextProps, renderLanes) {
  // 删除了添加context部分的逻辑
	// 获取组件实例
  var instance = workInProgress.stateNode;
  var shouldUpdate;
	// 如果没有实例,那就得创建实例
  if (instance === null) {
    if (current !== null) {
      current.alternate = null;
      workInProgress.alternate = null;

      workInProgress.flags |= Placement;
    }
    // 全体目光向我看齐,看我看我,这里new Class创建组件实例
    constructClassInstance(workInProgress, Component, nextProps);
    // 挂载组件实例
    mountClassInstance(workInProgress, Component, nextProps, renderLanes);
    shouldUpdate = true;
  } else if (current === null) {
    shouldUpdate = resumeMountClassInstance(workInProgress, Component, nextProps, renderLanes);
  } else {
    shouldUpdate = updateClassInstance(current, workInProgress, Component, nextProps, renderLanes);
  }
  // Class组件的收尾工作
  var nextUnitOfWork = finishClassComponent(current, workInProgress, Component, shouldUpdate, hasContext, renderLanes);
}

在看这段代码前,我们自己也可以提前想象下这个过程,比如Class组件你一定是得new才能得到一个实例,只有拿到实例后才能调用其render方法,拿到其虚拟dom结构,之后再根据结构创建真实dom,添加属性,最后加入到页面。

所以在updateClassComponent中,首先会对组件做context相关的处理,这部分代码我删掉了,其余,判断当前组件是否有实例,如果有就去更新实例,如果没有那就创建实例,所以我们聚焦到constructClassInstancemountClassInstance、finishClassComponent三个方法,看命名就能猜到,前者一定是创造实例,后者是应该是挂载实例前的一些处理,先看第一个方法:

function constructClassInstance(workInProgress, ctor, props) {
	// 删除了对组件context进一步加工的逻辑
	// ....
  
  // 看我看我,我宣布个事,这里创建了组件实例
  // 验证了前面的推测,这里new了我们的组件,并且传递了当前组件的props以及前面代码加工的context
  var instance = new ctor(props, context);
  var state = workInProgress.memoizedState = instance.state !== null && instance.state !== undefined ? instance.state : null;
  adoptClassInstance(workInProgress, instance);

  // 删除了对于组件生命周期钩子函数的处理,比如很多即将被废弃的钩子,在这里都会被添加 UNSAFE_ 前缀
  //.....

  return instance;
}
1213309-20220609163759405-1233473738.png

constructClassInstance正如我们推测的一样,这里通过new ctor(props, context)创建了组件实例,除此之外,react后续版本已将部分声明周期钩子标记为不安全,对于钩子命名的加工也在此方法中。

紧接着,我们得到了一个组件实例,接着看mountClassInstance方法:

function mountClassInstance(workInProgress, ctor, newProps, renderLanes) {
	// 此方法主要是对constructClassInstance创建的实例进行数据组装,为其赋予props,state等一系列属性
  var instance = workInProgress.stateNode;
  instance.props = newProps;
  instance.state = workInProgress.memoizedState;
  instance.refs = emptyRefsObject;
  initializeUpdateQueue(workInProgress);
  
  // 删除了部分特殊情况下,对于instance的特殊处理逻辑
}

虽然命名是挂载,但其实离真正的挂载还远得很,本方法其实是为constructClassInstance创建的组件实例做数据加工,为其赋予props state等一系列属性。

在上文代码中,其实还有个finishClassComponent方法,此方法在组件自身都准备完善后调用,我们期待已久的render方法处理就在里面:

function finishClassComponent(current, workInProgress, Component, shouldUpdate, hasContext, renderLanes) {
  var instance = workInProgress.stateNode;
  ReactCurrentOwner$1.current = workInProgress;
  var nextChildren;
  if (didCaptureError && typeof Component.getDerivedStateFromError !== 'function') {
			// ...
  } else {
    {
      setIsRendering(true);
      // 关注点在这,通过调用组件实例的render方法,得到内部的元素
      nextChildren = instance.render();
      setIsRendering(false);
    }
  } 
  workInProgress.memoizedState = instance.state;
  return workInProgress.child;
}

在此方法内部,我们通过获取之前创建的组件实例,然后调用了它的render方法,于是成功执行了我们组件Prender方法:

render() {
  return <span onClick={this.handleClick}>111</span>;
}

需要注意的是,render返回的其实是一个jsx的模板语法,在真正return之前,react还会再次调用生成虚拟dom的逻辑也就是ReactElement方法,将span这一段转变成虚拟dom

而对于react而言,很明显虚拟domspan也可能理解成一个最最最基础的组件,所以它会重走beginWork这条路线,只是到了组件分类时,这一次会走HostComponent路线,然后触发updateHostComponent方法,我们直接跳过相同的流程,之后就会走到completeWork方法。

到这里,我们可以理解例子P组件虚拟dom都准备完毕,现在要做的是对于虚拟dom这种最基础的组件做转成真实dom的操作,见如下代码:

function completeWork(current, workInProgress, renderLanes) {
  var newProps = workInProgress.pendingProps;
	// 根据tag类型做不同的处理
  switch (workInProgress.tag) {
    // 标签类的基础组件走这条路
    case HostComponent:
      {
        popHostContext(workInProgress);
        var rootContainerInstance = getRootHostContainer();
        var type = workInProgress.type;

        if (current !== null && workInProgress.stateNode != null) {
          // ...
        } else {
          // ...
          } else {
            // 关注点1:创建虚拟dom的实例
            var instance = createInstance(type, newProps, rootContainerInstance, currentHostContext, workInProgress);
            appendAllChildren(instance, workInProgress, false, false);
            workInProgress.stateNode = instance; // Certain renderers require commit-time effects for initial mount.
            // 关注点2:初始化实例的子元素
            if (finalizeInitialChildren(instance, type, newProps, rootContainerInstance)) {
              markUpdate(workInProgress);
            }
          }
        }
      }
  }
}

可以猜到,虽然同样还是调用createInstance生成实例,但目前咱们的组件是个虚拟dom对象啊,一个普通的span标签,所以接下来一定会创建最基本的span节点,代码如下:

function createInstance(type, props, rootContainerInstance, hostContext, internalInstanceHandle) {
	// 根据span创建节点,调用createElement方法
  var domElement = createElement(type, props, rootContainerInstance, parentNamespace);
  precacheFiberNode(internalInstanceHandle, domElement);
  // 将虚拟dom span的属性添加到span节点上
  updateFiberProps(domElement, props);
  return domElement;
}

// createElement具体实现
function createElement(type, props, rootContainerElement, parentNamespace) {
  var isCustomComponentTag; 
  var ownerDocument = getOwnerDocumentFromRootContainer(rootContainerElement);
  var domElement;
  var namespaceURI = parentNamespace;

  if (namespaceURI === HTML_NAMESPACE$1) {
    if (type === 'script') {
      var div = ownerDocument.createElement('div');
      div.innerHTML = '<script><' + '/script>';
      var firstChild = div.firstChild;
      domElement = div.removeChild(firstChild);
    } else if (typeof props.is === 'string') {
      domElement = ownerDocument.createElement(type, {
        is: props.is
      });
    } else {
      // 在这里,真实dom span节点创建完毕
      domElement = ownerDocument.createElement(type); 
      if (type === 'select') {
        var node = domElement;

        if (props.multiple) {
          node.multiple = true;
        } else if (props.size) {
          node.size = props.size;
        }
      }
    }
  } else {
    domElement = ownerDocument.createElementNS(namespaceURI, type);
  }
  return domElement;
}

createElement方法中,react会根据你的标签类型来决定怎么创建dom,比如如果你是script,那就创建一个div用于包裹一个script标签。而我们的span很显然就是通过ownerDocument.createElement(type)创建,如下图:

1213309-20220609163800478-710099574.png

创建完成后,此时的span节点还是一个啥都没有的空span,所以通过updateFiberProps将还未加工的span的子节点以及其它属性强行赋予给span,在之后会进一步加工,之后返回我们的span

1213309-20220609163801822-1266371902.png

然后来到finalizeInitialChildren方法,这里开始对创建的span节点的子元素进一步加工,其实就是文本111

function finalizeInitialChildren(domElement, type, props, rootContainerInstance, hostContext) {
  // 实际触发的其实是这个
  setInitialProperties(domElement, type, props, rootContainerInstance);
  return shouldAutoFocusHostComponent(type, props);
}

// 跳过对于部分,接着看 setInitialDOMProperties
function setInitialProperties(domElement, tag, rawProps, rootContainerElement) {
  var props;

  switch (tag) {
		// ...
    default:
      props = rawProps;
  }
	// 验证props合法性
  assertValidProps(tag, props);
  // 正式设置props
  setInitialDOMProperties(tag, domElement, rootContainerElement, props, isCustomComponentTag);
  }
}

又是一系列的跳转,为dom设置属性的逻辑现在又聚焦在了setInitialDOMProperties中,我们直接看代码:

function setInitialDOMProperties(tag, domElement, rootContainerElement, nextProps, isCustomComponentTag) {
  for (var propKey in nextProps) {
    // 遍历所有属性,只要这个属性不是原型属性,那就开始正式处理
    if (!nextProps.hasOwnProperty(propKey)) {
      continue;
    }

    var nextProp = nextProps[propKey];
		// 如果属性是样式,那就通过setValueForStyles为dom设置样式
    if (propKey === STYLE) {
      {
        if (nextProp) {
          Object.freeze(nextProp);
        }
      }
      setValueForStyles(domElement, nextProp);
    } else if (propKey === DANGEROUSLY_SET_INNER_HTML) {

    } else if (propKey === CHILDREN) {
      if (typeof nextProp === 'string') {
        var canSetTextContent = tag !== 'textarea' || nextProp !== '';
        if (canSetTextContent) {
          // 设置文本属性
          setTextContent(domElement, nextProp);
        }
      } else if (typeof nextProp === 'number') {
        setTextContent(domElement, '' + nextProp);
      }
    } else if (propKey === SUPPRESS_CONTENT_EDITABLE_WARNING || propKey === SUPPRESS_HYDRATION_WARNING) ; else if (propKey === AUTOFOCUS) ; else if (registrationNameDependencies.hasOwnProperty(propKey)) {
      if (nextProp != null) {
        if ( typeof nextProp !== 'function') {
          warnForInvalidEventListener(propKey, nextProp);
        }

        if (propKey === 'onScroll') {
          listenToNonDelegatedEvent('scroll', domElement);
        }
      }
    } else if (nextProp != null) {
      setValueForProperty(domElement, propKey, nextProp, isCustomComponentTag);
    }
  }
}

这段代码看着有点长,其实做的事情非常的清晰,遍历span目前的props,如果propskeystyle,那就通过setValueForStyles为当前真实dom一一设置样式,如果keychildren,很明显我们虚拟dom111是放在children属性中的,外加上如果这个children类型还是string,那就通过setTextContentdom添加文本信息。

这里给大家展示为真实dom设置style以及设置innerHTML的源码:

// 为真实dom添加样式的逻辑
function setValueForStyles(node, styles) {
  // 获取真是dom的style对象,后面就遍历styles对象,依次覆盖
  var style = node.style;
  for (var styleName in styles) {
    if (!styles.hasOwnProperty(styleName)) {
      continue;
    }
    var isCustomProperty = styleName.indexOf('--') === 0;
    {
      if (!isCustomProperty) {
        warnValidStyle$1(styleName, styles[styleName]);
      }
    }
    // 获取样式的值
    var styleValue = dangerousStyleValue(styleName, styles[styleName], isCustomProperty);
    if (styleName === 'float') {
      styleName = 'cssFloat';
    }
		// 最终覆盖node节点原本的值
    if (isCustomProperty) {
      style.setProperty(styleName, styleValue);
    } else {
      style[styleName] = styleValue;
    }
  }
}

// 为真实dom添加innerHTML的逻辑
var setTextContent = function (node, text) {
  if (text) {
    var firstChild = node.firstChild;

    if (firstChild && firstChild === node.lastChild && firstChild.nodeType === TEXT_NODE) {
      firstChild.nodeValue = text;
      return;
    }
  }
  // 为真实dom设置文本信息
  node.textContent = text;
};

那么到这里,其实我们的组件P已经准备完毕,包括真实dom也都创建好了,就等插入到页面了,那这些dom什么时候插入到页面的呢?后面我又跟了下调用栈,根据我页面啥时候绘制的111一步步断点缩小范围,最终定位到了insertOrAppendPlacementNodeIntoContainer方法,直译过来就是将节点插入或者追加到容器节点中:

function insertOrAppendPlacementNodeIntoContainer(node, before, parent) {
  var tag = node.tag;
  var isHost = tag === HostComponent || tag === HostText;
  if (isHost || enableFundamentalAPI ) {
    var stateNode = isHost ? node.stateNode : node.stateNode.instance;
    if (before) {
      // 在容器节点前插入
      insertInContainerBefore(parent, stateNode, before);
    } else {
      // 在容器节点后追加
      appendChildToContainer(parent, stateNode);
    }
  } else if (tag === HostPortal) ; else {
    var child = node.child;
		// 只要子节点不为null,继续递归调用
    if (child !== null) {
      insertOrAppendPlacementNodeIntoContainer(child, before, parent);
      var sibling = child.sibling;
			// 只要兄弟节点不为null,继续递归调用
      while (sibling !== null) {
        insertOrAppendPlacementNodeIntoContainer(sibling, before, parent);
        sibling = sibling.sibling;
      }
    }
  }
}

insertOrAppendPlacementNodeIntoContainer中,react会根据当前节点是否有子节点,或者兄弟节点进行递归调用,然后分别根据insertInContainerBeforeappendChildToContainer做最终的节点插入页面操作,这里我们看看appendChildToContainer的实现:

function appendChildToContainer(container, child) {
  var parentNode;

  if (container.nodeType === COMMENT_NODE) {
    parentNode = container.parentNode;
    parentNode.insertBefore(child, container);
  } else {
    parentNode = container;
    // 将子节点插入到父节点中
    parentNode.appendChild(child);
  var reactRootContainer = container._reactRootContainer;

  if ((reactRootContainer === null || reactRootContainer === undefined) && parentNode.onclick === null) {
    // TODO: This cast may not be sound for SVG, MathML or custom elements.
    trapClickOnNonInteractiveElement(parentNode);
  }
}

由于我们定义的组件非常简单,P组件只有一个span标签,所以这里的parentNode其实就是容器根节点,当执行完parentNode.appendChild(child),可以看到页面就出现了111了。

1213309-20220609163803240-717628951.png

至此,组件的虚拟dom生成,真实dom的创建,加工以及渲染全部执行完毕。

可能大家对于这个过程还是比较迷糊,我大致画个图描述下这个过程:

1213309-20220609163805896-567787527.png

react是怎么知道谁是谁的子节点,谁是谁的父节点,这个就需要了解fiber对象了,其实我们在创建完真实dom后,它还是会被加工成一个fiber节点,而此节点中通过child可以访问到自己的子节点,通过sibling获取自己的兄弟节点,最后通过return属性获取自己的父节点,通过这些属性为构建dom树提供了支撑,当然fiber我会另开一篇文章来解释,这里不急。

前文,我们验证了Class组件是通过new得到组件实例,然后开展后续操作,那对于函数组件,是不是直接调用拿到子组件呢?这里我简单跟了下源码,发现了如下代码:

1213309-20220609163807783-2045803986.png
function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
  // ....
  var children = Component(props, secondArg);
}

可以发现确实如此,拿到子节点,然后后续还是跟之前一样,将虚拟dom转变成真实dom,以及后续的一系列操作。

不过有点意外的是,我以为我定义的函数组件在判断组件类型时,会走case FunctionComponent分支路线,结果它走的case IndeterminateComponent,也就是模糊定义的组件,不过影响不大,还是符合我们的推测。

好了,到这里,我已经写了一万字,关于虚拟dom如何转变成真实dom也介绍完毕了。

捌 ❀ 我是如何阅读源码的

在文章结束前,我顺带分享下我是如何阅读react源码的,本来在写这篇文章前,我也想着要不查查资料,看看大家都是怎么写的,结果部分高赞的文章基本发布时间都在19年,那时候的react版本基本都是15,连fiber的概念都没有,无奈之下我只能自己来尝试读源码并解决我自己提出的问题。如果将源码阅读理解成一次探险,我是这样做的。

捌 ❀ 确定阅读前的目标

react的源码比较多,一个react一个react-dom加起来代码量都几万行了,所以在读之前,一定要搞清楚自己的目标,这样你也能少受不重要逻辑的干扰,比如我在阅读之前初步定下的目标是:

  • 虚拟dom是怎么生成的?
  • 函数组件和class组件渲染有什么不同?
  • 为啥我之前尝试直接修改虚拟dom,添加属性没成功(对应后面typeof Symbol的解释)
  • 虚拟dom是怎么转变成真实dom的?
  • 啥时候才把真实dom插入到页面?

清晰了目标,那就可以找到起点开始看了,我要看渲染,那自然看render,但接下来就麻烦了,如果你跟着render一步步往下走,那估计你看不了五分钟,应该就没耐心看了,因为这里面存在大量你根本看不懂,或者对你帮助不大的代码,那么我是怎么做的呢?

捌 ❀ 以点成线

我要看虚拟dom转变真实domreact到头来还是要操作真实dom,那它就一定得通过原生的createElement来创建dom节点,所以我直接在源码中搜createElement,然后看看这些命名出现的上下文,根据语境大致推断是否是自己想要的,不确定也可以打个断点。

哎,然后我就发现我成功找到function createElement方法,而且它还真是我想要的方法,但是呢,此时逻辑距离render可谓是十万八千里,这中间究竟发生了什么?这时候就可以根据执行栈进行梳理:

1213309-20220609163809448-1216463734.jpg

比如上图就是我定位到给真实dom添加属性的方法,然后我根据调用栈命名,大致知道它在干嘛,同时排除那些没意义的函数的干扰,从终点反向走回起点,看看这一路react是怎么处理的。

同理,我在找最后react将真实dom插入到页面的逻辑时,我发现我跟不下去了,因为断点乱跳,于是我就看页面渲染111的时机,然后初略断点,如果这个断点还没走到111已经渲染了,说明这个操作在之前,通过这种方式不断缩小范围范围,最终定位到了insertOrAppendPlacementNodeIntoContainer方法,也解开了我前面的疑惑。

捌 ❀ 以线成面

通过以点连线的方式,你能非常快的理清一小段一小段的逻辑,而这些逻辑的交叉,阅读前的目标就逐渐清晰了。比如我在梳理了Class组件后,我就在想,函数组件又是怎么渲染的?于是非常快的定位到了函数组件渲染子节点的逻辑。

我们可以把源码理解成夜晚的星空,小时候总是喜欢选几个点练成线,再用线连成图案,什么北极星织女星,不就是这样画出来的吗,而现在只是将这种做法投射到了源码阅读中罢了。

写到这已经一万一千字,差不多一篇论文的长度了。而这篇文章,从查资料,读源码到写作结束,也差不多用了我一周的零碎时间。一开始只是想写写概念,写着写着对自己要求越来越高,于是一篇文章写得停不下来了,不过好在终于写到了尾声,我也松了口气了(下一篇fiber感觉也很难受的样子)。

通过本文,我们介绍了虚拟dom的概念,了解了究竟什么是虚拟dom。结合文章开头框架发展史,我们也解释了虚拟dom存在的价值以及它所具备的优势,而且框架之间也不应该盲目的去对比。在文章后半段,我们介绍了React.createElementReactDOM.render的源码,理解了虚拟dom的创建过程,以及react是如何将虚拟dom转变成真实dom的,如果有时间,我也推荐大家自行断点,根据我的提示来加深理解这个过程,它并不难,只是需要足够的耐心。

希望本文能为有缘的你提供一些帮助,那么本文到这里正式结束。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK