近来学校封校,得以从实验中解脱,于是通过各类视频、书籍以及源码深入研究了一下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渲染成真实DOM。
为什么要用虚拟DOM(vdom)?(Why)
- DOM操作很慢,轻微的操作都可能会导致页面重排重绘,非常消耗性能
- 相对于DOM对象,js对象处理起来更快
- 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
- 开发效率:使用JSX编写模板简单快速
- 执行效率:JSX编译为JavaScript代码后进行了优化,执行地更快
- 安全性:编译过程中就能发现错误
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对象然后将其渲染到页面中。
//假设已经拿到了虚拟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这个属性
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
}
总结
- 虚拟dom本质上是一个JavaScript对象,我们可以从对象的属性中获取一定的信息从而生成真实dom;
- ReactDOM.render(vdom,container)可以将vdom转换为dom并将其追加到comtainer中;
- 实际上,转换过程需要经过一个diff过程,本文只考虑初次渲染的过程而不考虑diff的过程。