在做好一定的预备知识的学习后,本篇我们只研究一个问题:
React 是如何把 Component 中的 JSX 映射到页面上真正的 DOM 节点的。这个流程是怎样的?。
我们首先写一个小 demo,用于测试我们最终的代码:
const Dilithium = require('../dilithium')
class App extends Dilithium.Component {
render() {
return (
<div>
<div>
<h1 style={{ color: 'red' }} >Heading 1</h1>
<SmallHeader />
<h2 style={{ color: 'yellow' }} >Heading 2</h2>
</div>
<h3>Heading 3</h3>
</div>
)
}
}
class SmallHeader extends Dilithium.Component {
render() {
return (
<h5>SmallHeader</h5>
)
}
}
Dilithium.render(<App />, document.getElementById('root'))
可以看出,基本用法是跟 React 一致的。
至于 Dilithium.render
函数,我们有如下的实现:
function render(element, node) {
// todo: add update
mount(element, node)
}
在我们开始分析之前,首先给出这个答案的流程图:
根据这个流程,我们给出 mount
的实现:
function mount(element, node) {
const component = instantiateComponent(element)
const renderedNode = component.mountComponent()
// these are just helper functions of native DOM functions
// you can check them out in dilithium/src/DOM.js
DOM.empty(node)
DOM.appendChildren(node, renderedNode)
}
然后根据流程的各个环节逐步开始分析。
在 React 中,我们使用的组件有两种,class component 或是 functional component. 对于 class component 来说,render 函数是组件内必不可少的;而对于 functional component,组件没有生命周期和 local state,组件函数返回值等同于 class component 中 render 函数的返回值。
无论是 render 函数的返回值,还是函数是组件的返回值,它们都是 JSX。JSX 是 React.createElement(type, props, ...children)
函数的语法糖,如果你还不熟悉,建议先阅读JSX in Depth,然后可以在 Try it out 中试一下 JSX 和 createElement
的映射关系。
我们知道,JSX 只是调用了函数 React.createElement
,并把对应的 JSX 结构映射到了 createElement
相应的参数中去。例如:
<div className="container">
good
<span>Hello world</span>
</div>
会被编译成:
React.createElement(
"div",
{ className: "container" },
"good",
React.createElement(
"span",
null,
"Hello world"
)
);
那么 createElement
这个函数又做了什么事情呢?
function createElement(type, config, children) {
const props = Object.assign({}, config)
const childrenLength = [].slice.call(arguments).length - 2
if (childrenLength > 1) {
props.children = [].slice.call(arguments, 2)
} else if (childrenLength === 1) {
props.children = children
}
return {
type,
props
}
}
一言以蔽之,createElement
就是将 children
合并进了当前 Element 对象,成为了其中的 children
属性。
这样,对于一个 JSX 结构,我们最终得到了一个数据结构如下的纯 JS Object,也就是 Element。
{
type: string | function | class
props: {
children
}
}
Note: 暂时不支持函数式组件
有了 Element 后,我们需要将 Element 中对应的组件类型(type
)实例化,也就是 instantiateComponent
。在前文提到,element type 有三种:
- string, 例如
"div", "ul"
等原生 DOM 结构。 - function, 函数式组件(暂不支持)
- class component
但是我们也要考虑另一种情况,element 本身是一个字符串或数字(并没有被组件包裹)。这样,我们根据不同情况,分别生成不同的组件类型:
function instantiateComponent(element) {
let componentInstance
if (typeof element.type === 'function') {
// todo: add functional component
// only supports class component for now
componentInstance = new element.type(element.props)
componentInstance._construct(element)
} else if (typeof element.type === 'string') {
componentInstance = new DOMComponent(element)
} else if (typeof element === 'string' || typeof element === 'number') {
// to reduce overhead, we wrap the text with a span
componentInstance = new DOMComponent({
type: 'span',
props: { children: element }
})
}
return componentInstance
}
在讨论这点之前,我们先讨论一下“多态”(polymorphism)。这是 OOP 中很重要的一个概念,在 instantiateComponent
中,我们根据参数 element
类型的不同,调用了不同的方法,本质上是一种“函数多态”。而在这一环节,我们专门将多态抽离出来,构成一个 `Reconciller:
// Reconciller
function mountComponent(component) {
return component.mountComponent()
}
同时,我们在不同类型中的 component 中,分别实现同名的方法(mountComponent
):
在 Class Component 中,我们看到,mountComponent
和实际的 mount
流程非常相似,都是 element -> component -> node。这里由于 class component 本身没有对应的 DOM 映射,所以 mount 的过程 defer 到了下一层组件。
// Component
class Component {
constructor(props) {
this.props = props
this.currentElement = null
this._renderedComponent = null
this._renderedNode = null
}
_construct(element) {
this.currentElement = element
}
mountComponent() {
// we simply assume the render method returns a single element
const renderedElement = this.render()
const renderedComponent = instantiateComponent(renderedElement)
this._renderedComponent = renderedComponent
const renderedNode = Reconciler.mountComponent(renderedComponent)
this._renderedNode = renderedNode
return renderedNode
}
}
class DOMComponent {
constructor(element) {
this._currentElement = element
this._domNode = null
}
mountComponent() {
// create real dom nodes
const node = document.createElement(this._currentElement.type)
this._domNode = node
this._updateNodeProperties({}, this._currentElement.props)
this._createInitialDOMChildren(this._currentElement.props)
return node
}
}
我们暂不分析 _updateNodeProperties
和 _createInitialDOMChildren
这两个函数方法的细节(留到下篇博客),从字面意思可以看出,这两个函数分别是将 element.props
挂载到真正的 DOM 节点上,以及递归 mount 子节点。最终返回当前这个 DOM 节点。
回顾一下 mount
函数:
function mount(element, node) {
const component = instantiateComponent(element)
const renderedNode = component.mountComponent()
DOM.empty(node)
DOM.appendChildren(node, renderedNode)
}
到这里 const renderedNode = component.mountComponent()
,我们已经拿到了真正的 DOM 节点,剩下的工作非常简单。首先清空 container 里的内容,然后将 renderedNode append 上去。
如下是两个 DOM helper function:
function empty(node) {
[].slice.call(node.childNodes).forEach((child) => {
node.removeChild(child)
})
}
function appendChildren(node, children) {
if (Array.isArray(children)) {
children.forEach((child) => {
node.appendChild(child)
})
} else {
node.appendChild(children)
}
}
至此,我们已经走完了 mounting 整个的流程。完整的代码实现(仅 mounting 部分)在这里
在理解这个流程的时候,我个人认为有这几个个关键点,你也可以把它们作为检验你是否真正理解这个过程的几个题目。
- Element, Component, Instance 的区别是什么
- 四种不同的 Element 类型分别是怎样 mount 成真正的 DOM 节点的
- Class Component 是怎样 defer mount 的
- DOM Component 是怎样实现真正的 mount 的
但是,在最后的 DOM Component 中,我们有两个问题/函数还没有讲,分别是:
updateNodeProperties
createInitialDOMChildren
其中正是 createInitialDOMChildren
实现了 Element tree 的递归 mount。我们将在下一篇博客中完成最后这部分的分析。