React源码浅探之一—初步研究React工作原理

435 阅读8分钟

近来学校封校,得以从实验中解脱,于是通过各类视频、书籍以及源码深入研究了一下React17的工作原理(React17与React16仅有小部分差别,如对JSX转译方式的处理等)。以下是个人总结与收获,希望与大家交流学习。

虚拟DOM

事实上,React本身只是一个DOM的抽象层。

什么是虚拟DOM?(What)

虚拟DOM实际上就是用JavaScript对象来表示真实DOM的信息和结构,当状态发生变化的时候,重新渲染这个JS对象的结构,进而引发真实DOM的变更。

import ReactDOM from 'react-dom'
const jsx = (
    <div className="jsx">
        <h1>jsx</h1>
        <a href="https://www.https://react.docschina.org/">react</a>
    </div>
)
console.log(jsx);
ReactDOM.render(jsx, document.getElementById('root'))

上图中的JSX已由React内部转化为js对象,在控制台打印出来的虚拟dom如下图所示,可见虚拟dom实质上是一个JS对象,其中的各种属性或方法已经由源码所定义。本节我们不追究React是如何将jsx转变为虚拟DOM,而着重于如何将虚拟DOM渲染成真实DOMimage.png

为什么要用虚拟DOM(vdom)?(Why)

  1. DOM操作很慢,轻微的操作都可能会导致页面重排重绘,非常消耗性能
  2. 相对于DOM对象,js对象处理起来更快
  3. diff算法能够对比新旧vdom之间的差异,可以批量并且最小化地处理dom,从而提高性能

在哪里用到了虚拟dom对象?(Where)

React中使用JSX语法来描述视图。在React16中,通过babel-loader转译后它们变为React.createElement(...)的形式,这个方法能够生成vdom来描述dom。如果之后状态发生变化,vdom也作出相应的变化,再通过diff算法对比新老vdom的区别,从而做出最终的dom操作。从React17以后,JSX转换不会将JSX转换为React.createElement,而是自动从React的package中引入新的入口函数并调用。(因此不用再引入React也能使用JSX语法)

JSX

在React中推荐使用JSX来代替JavaScript。以下代码可以直接在使用React脚手架生成的环境中运行,而不需要再引入React。

const jsx = (
    <div className="jsx">
        <h1>jsx</h1>
        <a href="https://www.https://react.docschina.org/">react</a>
    </div>
)

What

实质上是一种语法糖,JSX看起来很像是XML的JavaScript语法扩展。

Why

  1. 开发效率:使用JSX编写模板简单快速
  2. 执行效率:JSX编译为JavaScript代码后进行了优化,执行地更快
  3. 安全性:编译过程中就能发现错误

React中几个重要的API

Component-类组件

先上源码

function Component(props, context, updater) {
  this.props = props;
  this.context = context; // If a component has string refs, we will assign a different object later.

  this.refs = emptyObject; // We initialize the default updater but the real one gets injected by the
  // renderer.

  this.updater = updater || ReactNoopUpdateQueue;
}

Component.prototype.isReactComponent = {};

Component.prototype.setState = function (partialState, callback) {
  if (!(typeof partialState === 'object' || typeof partialState === 'function' || partialState == null)) {
    {
      throw Error( "setState(...): takes an object of state variables to update or a function which returns an object of state variables." );
    }
  }

  this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

这里要注意,如果想要判定组件是类组件还是函数组件,不能使用typeof,因为不管是类组件还是函数组件,都是function。我们应该看该组件上是否有 isReactComponent,有的话就属于类组件,没有的话就属于函数组件。

setState方法是用来更新组件的状态的,第一个参数必须是对象、函数或空值中的一种,不然React就会报错;第二个参数是一个回调,当前state发生改变后会执行该回调函数。setState的核心功能在于this.updater.enqueueSetState这个方法,这个方法细究起来很复杂,但是我们至少可以看出React更新状态是类似于队列的方式,并且其中进行了值的合并(从partialState-部分状态可以看出setState只会改变部分状态值)等功能,这也映证了setState的异步。

render()

render可传入三个函数,第一个是元素,第二个是容器,第三个是可选的回调函数。

function render(element, container, callback) {
  if (!isValidContainer(container)) {
    {
      throw Error( "Target container is not a DOM element." );
    }
  }

  {
    var isModernRoot = isContainerMarkedAsRoot(container) && container._reactRootContainer === undefined;

    if (isModernRoot) {
      error('You are calling ReactDOM.render() on a container that was previously ' + 'passed to ReactDOM.createRoot(). This is not supported. ' + 'Did you mean to call root.render(element)?');
    }
  }

  return legacyRenderSubtreeIntoContainer(null, element, container, false, callback);
}

当首次调用render()时,容器节点里的所有DOM元素都会被替换,而后续的调用则会使用React的DOM差分算法来进行高效的更新。

render()的核心在于legacyRenderSubtreeIntoContainer()方法。这个方法不仅要完成初次渲染,还要进行更新,这就导致必须要比较新旧的dom节点。初次渲染不需要进行diff,因此React中将两种情况分开讨论。if (!root)表明组件为初次渲染,应该进行非批量更新(unbatchedUpdates),可以保证更新效率与用户体验;否则应该进行使用diff算法进行批量更新(详细过程不进行讨论)。

function legacyRenderSubtreeIntoContainer(parentComponent, children, container, forceHydrate, callback) {
  {
    topLevelUpdateWarnings(container);
    warnOnInvalidCallback$1(callback === undefined ? null : callback, 'render');
  } // TODO: Without `any` type, Flow says "Property cannot be accessed on any
  // member of intersection type." Whyyyyyy.

  var root = container._reactRootContainer;
  var fiberRoot;

  if (!root) {
    // Initial mount
    root = container._reactRootContainer = legacyCreateRootFromDOMContainer(container, forceHydrate);
    fiberRoot = root._internalRoot;

    if (typeof callback === 'function') {
      var originalCallback = callback;

      callback = function () {
        var instance = getPublicRootInstance(fiberRoot);
        originalCallback.call(instance);
      };
    } // Initial mount should not be batched.


    unbatchedUpdates(function () {
      updateContainer(children, fiberRoot, parentComponent, callback);
    });
  } else {
    fiberRoot = root._internalRoot;

    if (typeof callback === 'function') {
      var _originalCallback = callback;

      callback = function () {
        var instance = getPublicRootInstance(fiberRoot);

        _originalCallback.call(instance);
      };
    } // Update


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

  return getPublicRootInstance(fiberRoot);
}

节点类型

React给每种节点定义了一个type值,常用的节点主要有文本节点、HTML标签节点、函数组件和类组件。

var FunctionComponent = 0;
var ClassComponent = 1;
var IndeterminateComponent = 2; // Before we know whether it is function or class
var HostRoot = 3; // Root of a host tree. Could be nested inside another node.
var HostPortal = 4; // A subtree. Could be an entry point to a different renderer.
var HostComponent = 5;
var HostText = 6;
var Fragment = 7;
var Mode = 8;
var ContextConsumer = 9;
var ContextProvider = 10;
var ForwardRef = 11;
var Profiler = 12;
var SuspenseComponent = 13;
var MemoComponent = 14;
var SimpleMemoComponent = 15;
var LazyComponent = 16;
var IncompleteClassComponent = 17;
var DehydratedFragment = 18;
var SuspenseListComponent = 19;
var FundamentalComponent = 20;
var ScopeComponent = 21;
var Block = 22;
var OffscreenComponent = 23;
var LegacyHiddenComponent = 24;

手写代码实现文本节点、HTML标签节点、函数组件以及类组件的初次渲染

以下代码默认我们已经获得了虚拟dom(vnode),实际上就是如何将虚拟dom转化为真实dom。下图为虚拟dom对象,我们只需要根据虚拟dom对象创建真实dom对象然后将其渲染到页面中。 image.png

//假设已经拿到了虚拟DOM,目标是将虚拟DOM转化为真实DOM节点
function render(vnode, container) {
    const node = createNode(vnode)
    container.appendChild(node)
}

其中createNode()是即将定义的一个方法,需要根据节点的不同执行不同的代码

function createNode(vnode) {
    let node
    const { type } = vnode
    //todo 根据组件类型的不同创建不同的node节点
    
    //原生标签节点
    if (typeof type === 'string') {
        node = updateHostComponent(vnode)
    } else if (typeof type === 'function') {
    //函数组件和类组件
        node = type.prototype.isReactComponent ? updateClassComponent(vnode) : updateFunctionComponent(vnode)
    } else {
        node = updateTextComponent(vnode) //文本节点
    }
    return node
}

原生标签节点

function updateHostComponent(vnode) {
    const { type, props } = vnode
    const node = document.createElement(type)
    return node
}

这样,我们就实现了原生标签节点。但是,我们还需要考虑原生标签的子结点。子结点的信息可以从props.children上获取,因此我们定义一个方法reconcileChildren()来渲染子结点。这里需要注意,如果标签内存在子标签,则子标签的信息以数组的形式保存在props.children中;如果标签内不存在子标签,则将标签内的文本以字符串的形式保存在props.children中。

function updateHostComponent(vnode) {
    const { type, props } = vnode
    const node = document.createElement(type)
    reconcileChildren(node,props.children)
    return node
}

//这里仅考虑children为数组和文本的情况
function reconcileChildren(parentNode, children) {
    const newChildren = Array.isArray(children) ? children : [children]
    for (let i = 0; i < newChildren.length; i++) {
        let child = newChildren[i]
        //vnode
        //vnode->node,node插入到parentNode
        render(child, parentNode)
    }
}

这样,我们就实现了原生标签嵌套的虚拟dom渲染。但是我们知道原生标签具有属性,我们能不能将属性也赋给真实dom呢?答案是肯定的。我们添加了一个自定义方法updateNode()来更新dom的属性,这里需要明确属性的信息保存在props中,而且还需要摒除children这个属性

image.png

function updateHostComponent(vnode) {
    const { type, props } = vnode
    const node = document.createElement(type)
    updateNode(node, props)  //更新属性
    reconcileChildren(node,props.children)
    return node
}

//这里仅考虑children为数组和文本的情况
function reconcileChildren(parentNode, children) {
    const newChildren = Array.isArray(children) ? children : [children]
    for (let i = 0; i < newChildren.length; i++) {
        let child = newChildren[i]
        //vnode
        //vnode->node,node插入到parentNode
        render(child, parentNode)
    }
}

//更新属性,过滤children
function updateNode(node, nextVal) {
    Object.keys(nextVal)
        .filter(key => key !== 'children')
        .forEach(key => node[key] = nextVal[key])
}

文本节点

function updateTextComponent(vnode) {
    const node = document.createTextNode(vnode)
    return node
}

函数组件

重点是需要执行该函数

//函数组件
function updateFunctionComponent(vnode) {
    const { type, props } = vnode
    const vvnode = type(props)  //执行函数组件内的代码,并返回一个虚拟dom
    const node = createNode(vvnode)
    return node
}

类组件

类组件都是继承于Component,因此需要先定义一个父类

function Component(props) {
    this.props = props
}
Component.prototype.isReactComponent = {}
//类组件转化为真实dom
function updateClassComponent(vnode) {
    const { type, props } = vnode
    const instance = new type(props)  //实例化
    const vvnode = instance.render()  //获取需要渲染的虚拟dom
    const node = createNode(vvnode)
    return node
}

总结

  1. 虚拟dom本质上是一个JavaScript对象,我们可以从对象的属性中获取一定的信息从而生成真实dom;
  2. ReactDOM.render(vdom,container)可以将vdom转换为dom并将其追加到comtainer中;
  3. 实际上,转换过程需要经过一个diff过程,本文只考虑初次渲染的过程而不考虑diff的过程。