在正式进入实现以前,咱们先来了解一下几个概念。首先,“映射”这个概念已经在“第一篇文章里”里面介绍过了,这里就不在赘述了。咱们来说讲这里所说的“整树”和“协调”到底指的是什么?javascript
熟悉react的读者都知道,完整的react应用是能够用一颗组件树来表示的。而组件树背后对应的归根到底仍是virtual DOM对象树。react官方推荐仅调用一次ReactDOM.render()来将这颗virtual DOM对象树挂载在真实的文档中去。因此,这里,咱们就将调用render方法时传入的第一参数称之为“整树”(整一颗virtual DOM对象树):html
const rootNode = document.getElementById('root')
const app = (
<div id="test" onClick={() => { alert('I been clicked')}> 我是文本节点1 <a href="https://www.baidu.com">百度一下</a> </div>
)
render(app,rootNode)
复制代码
在上面的示例代码中,app变量所指向的virtual DOM对象树就是咱们所说的“整树”。java
那“协调”又是啥意思呢?协调,原概念来自于英文单词“Reconciliation”,你也能够翻译为“调和”,“和解”或者什么的。在react中,“Reconciliation”是个什么样的定义呢?官方文档好像也没有给出,官方文档只是给出了一段稍微相关的解释而已:react
React provides a declarative API so that you don’t have to worry about exactly what changes on every update. This makes writing applications a lot easier, but it might not be obvious how this is implemented within React. This article explains the choices we made in React’s “diffing” algorithm so that component updates are predictable while being fast enough for high-performance apps.算法
同时,官方提醒咱们,reconciliation算法的实现常常处于变更当中。咱们能够把这个提醒这理解为官方也难以给reconciliation算法下一个准确的定义。可是reconciliation算法的目标是明确的,那就是“在更新界面的过程当中尽量少地进行DOM操做”。因此,咱们能够把react.js中“协调”这个概念简单地理解为:编程
“协调”,是指在尽可能少地操做DOM的前提下,将virtual DOM 映射为真实文档树的过程。数据结构
综上所述,咱们这一篇章要作的事就是:“在将整颗virtual DOM对象树映射为真实文档过程当中,如何实现尽可能少地操做DOM”。为何咱们总在强调要尽可能少地操做DOM呢?这是由于,javascript是足够快的,慢的是DOM操做。在更新界面的过程,越是少地操做DOM,UI的渲染性能越好。app
在上一个篇章里面,咱们实现了重头戏函数-render。若是将render函数的第一次调用,称做为“整树的初始挂载”,那么日后的调用就是“整树的更新”了。拿咱们已经实现的render函数来讲,若是我要更新界面,我只能传入一个新的element,重复调用render:dom
const root = document.getElementById('root')
const initDivElement = (
<div id="test" onClick={() => { alert('I been clicked')}> 我是文本节点 </div>
)
const newDivElement = (
<div id="test" onClick={() => { alert('I been clicked')}> 我是更新后的文本节点 </div>
)
// 初始挂载
render(initDivElement,root)
// 更新
render(newDivElement,root)
复制代码
代码一执行,你发现运行结果明显不是咱们想要的。由于,目前render函数只会往容器节点里面追加子元素,而不是替代原有的子元素。因此咱们得把最后一块代码的逻辑改一改:ide
function render(element, domContainer) {
// ......
if(domContainer.hasChildNodes()){
domContainer.replaceChild(domNode,domContainer.lastChild)
}else {
domContainer.appendChild(domNode)
}
}
复制代码
以最基本的要求看上面的实现,它是没问题的。可是若是用户只更新整树的根节点上的一个property,又或者更新一颗深度很深,广度很广的整树呢?在这些状况下,再这么粗暴地直接替换一整颗现有的DOM树显得太没有技术追求了。咱们要时刻记住,DOM操做是相对消耗性能的。咱们的目标是:尽可能少地操做DOM。因此,咱们还得继续优化。
鉴于virtual DOM字面量对象所带来的声明范式,咱们能够把一个react element看作是屏幕上的一帧。渲染是一帧一帧地进行的,因此咱们能想到的作法就是经过对比上一帧和如今要渲染的这一帧,找出二者之间的不一样点,针对这些不一样点来执行相应的DOM更新。
那么问题来啦。程序在运行时,咱们该如何访问先前的virtual DOM对象呢?咱们该如何复用已经建立过的原生DOM对象呢?想了好久,我又想到字面量对象了。是的,咱们须要建立一个字面量对象,让它保存着先前virtual DOM对象的引用和已建立原生DOM对象的引用。咱们还须要保存一个指向各个子react element的引用,以便使用递归法则对他们进行“协调”。综合考虑一下,咱们口中的这个“字面量对象”的数据结构以下:
// 伪代码
const 字面量对象 = {
dom: element对应的原生DOM对象,
element:上一帧所对应的element,
children:[子virtual DOM对象所对应的字面量对象]
}
复制代码
熟悉react概念的读者确定会知道,咱们口中的这种“字面量对象”,就是react.js源码中的instance
的概念。注意,这个instance
的概念跟面向对象编程中instance
(实例)的概念是不一样的。它是一个相对的概念。若是讲react element(virutal DOM对象)是“虚”的,那么使用这个react element来建立的原生DOM对象(在render函数的实现中有相关代码)就是“实”的。咱们把这个“实”的东西挂载在一个字面量对象上,并称这个字面量对象为instance
,称这个过程为“实例化”,好像也说得过去。加入instance
概念以后,值得强调的一点是:“一旦一个react element建立过对应的原生DOM对象,咱们就说这个element被实例化过了”。
如今,咱们来看看react element,原生DOM对象和instance三者之间的关系吧:
是的,它们之间是一一对应的关系。 instance概念的引入很是重要,它是咱们实现Reconciliation算法的基石。因此,在这,咱们有必要从新整理一下它的数据结构:
const instance = {
dom: DOMObject,
element:reactElement,
childInstances:[childInstance1,childInstance2]
}
复制代码
梳理完毕,咱们就开始重构咱们的代码。 万事从头起,对待树状结构的数据更是如此。是的,咱们须要一个root instance,而且它应该是一个全局的“单例”。同时,咱们以instance
这个概念为关注点分离的启发点,将原有的render函数根据各自的职责将它分离为三个函数:(新的)render函数,reconcile函数和instantiate函数。各自的函数签名和职责以下:
// 主要对root element调用reconcile函数,维护一份新的root instance
render:(element,domContainer) => void
// 主要负责对根节点执行一些增删改的DOM操做,而且经过调用instantiate函数,
// 返回当前element所对应的新的instance
reconcile:(instance,element,domContainer) => instance
// 负责具体的“实例化”工做。
// “实例化”工做大概包含两部分:
// 1)建立相应的DOM对象 2)为建立的DOM对象设置相应的属性
instantiate:(element) => instance
复制代码
下面看看具体的实现代码:
let rootInstance = null
function render(element,domContainer){
const prevRootInstance = rootInstance
const newRootInstance = reconcile(prevRootInstance,element,domContainer)
rootInstance = newRootInstance
}
function reconcile(instance,element,domContainer){
let newInstance
// 对应于整树的初始挂载
if(instance === null){
newInstance = instantiate(element)
domContainer.appendChild(newInstance.dom)
}else { // 对应于整树的更新
newInstance = instantiate(element)
domContainer.replaceChild(newInstance.dom,instance.dom)
}
return newInstance
}
//大部分复用原render函数的实现
function instantiate(element){
const { type, props } = element
// 建立对应的DOM节点
const isTextElement = type === 'TEXT_ELEMENT'
const domNode = isTextElement ? document.createTextNode('') : document.createElement(type)
// 给DOM节点的属性分类:事件属性,普通属性和children
const keys = Object.keys(props)
const isEventProp = prop => /^on[A-Z]/.test(prop)
const eventProps = keys.filter(isEventProp) // 事件属性
const normalProps = keys.filter((key) => !isEventProp(key) && key !== 'children') // 普通属性
const children = props.children // children
// 对事件属性,添加对应的事件监听器
eventProps.forEach(name => {
const eventType = name.toLowerCase().slice(2)
const eventHandler = props[name]
domNode.addEventListener(eventType,eventHandler)
})
// 对普通属性,直接设置
normalProps.forEach(name => {
domNode[name] = props[name]
})
// 对children element递归调用instantiate函数
const childInstances = []
if(children && children.length){
childInstances = children.map(childElement => instantiate(childElement))
const childDoms = childInstances.map(childInstance => childInstance.dom)
childDoms.forEach(childDom => domNode.appendChild(childDom)
}
const instance = {
dom:domNode,
element,
childInstances
}
return instance
}
复制代码
从上面的实现能够看出,reconcile函数主要负责对root element进行映射来完成整树的初始挂载或更新。在条件分支语句中,第一个分支实现的是初始挂载,第二个分支实现的是更新,对应的是“增”和“改”。那“删除”去去哪啦?好吧,咱们补上这个分支:
function reconcile(instance,element,domContainer){
let newInstance
if(instance === null){// 整树的初始挂载
newInstance = instantiate(element)
domContainer.appendChild(newInstance.dom)
}else if(element === null){ // 整树的删除
newInstance = null
domContainer.removeChild(instance.dom)
}else { // 整树的更新
newInstance = instantiate(element)
domContainer.replaceChild(newInstance.dom,instance.dom)
}
return newInstance
}
复制代码
还记得咱们的上面提到的目标吗?因此,咱们在仔细审视一下本身的代码,看看还能有优化的空间不?果不其然,对待“更新”,咱们直接一个“replaceChild”操做,未免也显得太简单粗暴了吧?细想一下,root element的映射过程当中的更新,也能够分为两种状况,第一种是root element的type属性值变了,另一个种是type属性值不变,变的是另外两个属性-props和children。在补上另一个分支以前,咱们不妨把对DOM节点属性的操做的实现逻辑从instantiate函数中抽出来,封装一下,使之可以同时应付属性的设置和更新。咱们给它命名为“updateDomProperties”,函数签名为:
updateDomProperties:(domNode,prevProps,currProps) => void
复制代码
下面,咱们来实现它:
function updateDomProperties(domNode,prevProps,currProps){
// 给DOM节点的属性分类:事件属性,普通属性
const isEventProp = prop => /^on[A-Z]/.test(prop)
const isNormalProp = prop => { return !isEventProp(prop) && prop !== 'children'}
// 若是先前的props是有key-value值的话,则先作一些清除工做。不然容易致使内存溢出
if(Object.keys(prevProps).length){
// 清除domNode的事件处理器
Object.keys(prevProps).filter(isEventProp).forEach(name => {
const eventType = name.toLowerCase().slice(2)
const eventHandler = props[name]
domNode.removeEventListener(eventType,eventHandler
})
// 清除domNode上的旧属性
Object.keys(prevProps).filter(isNormalProp).forEach(name => {
domNode[name] = null
})
}
// current props
const keys = Object.keys(currProps)
const eventProps = keys.filter(isEventProp) // 事件属性
const normalProps = keys.filter(isNormalProp) // 普通属性
// 挂载新的事件处理器
eventProps.forEach(name => {
const eventType = name.toLowerCase().slice(2)
const eventHandler = props[name]
domNode.addEventListener(eventType,eventHandler)
})
// 设置新属性
normalProps.forEach(name => {
domNode[name] = currProps[name]
})
}
复制代码
同时,我也更新一下instantiate的实现:
function instantiate(element){
const { type, props } = element
// 建立对应的DOM节点
const isTextElement = type === 'TEXT_ELEMENT'
const domNode = isTextElement ? document.createTextNode('') : document.createElement(type)
// 设置属性
updateDomProperties(domNode,{},props)
// 对children element递归调用instantiate函数
const children = props.children
let childInstances = []
if(children && children.length){
childInstances = children.map(childElement => instantiate(childElement))
const childDoms = childInstances.map(childInstance => childInstance.dom)
childDoms.forEach(childDom => domNode.appendChild(childDom))
}
const instance = {
dom:domNode,
element,
childInstances
}
return instance
}
复制代码
updateDomProperties函数实现完毕,最后咱们去reconcile函数那里把前面提到的那个条件分支补上。注意,这个分支对应的实现的成本很大一部分是花在对children的递归协调上:
function reconcile(instance,element,domContainer){
let newInstance = {}
// 整树的初始挂载
if(instance === null){
newInstance = instantiate(element)
domContainer.appendChild(newInstance.dom)
}else if(element === null){ // 整树的删除
newInstance = null
domContainer.removeChild(instance.dom)
}else if(element.type === instance.element.type){ // 整树的更新
newInstance.dom = instance.dom
newInstance.element = element
// 更新属性
updateDomProperties(instance.dom,instance.element.props,element.props)
// 递归调用reconcile来更新children
newInstance.childInstances = (() => {
const parentNode = instance.dom
const prevChildInstances = instance.childInstances
const currChildElement = element.props.children || []
const nextChildInstances = []
const count = Math.max(prevChildInstances.length,element.props.children.length)
for(let i=0 ; i<count ; i++){
const childInstance = prevChildInstances[i]
const childElement = currChildElement[i]
// 增长子元素
if(childInstance === undefined){
childInstance = null
}
// 删除子元素
if(childElement === undefined){
childElement = null
}
const nextChildInstance = reconcile(childInstance,childElement,parentNode)
//过滤为null的实例
if(nextChildInstance !== null){
nextChildInstances.push(nextChildInstance)
}
}
return nextChildInstances
})()
}else { // 整树的替换
newInstance = instantiate(element)
domContainer.replaceChild(newInstance.dom,instance.dom)
}
return newInstance
}
复制代码
咱们用四个函数就实现了整一个virtual DOM映射过程当中的协调了。咱们能够这么说:
协调的对象是virtual DOM对象树和real DOM对象树;协调的媒介是
instance
;协调的路径是始于根节点,终于末端节点。
到目前为止,若是咱们想要更新界面,咱们只能对virtual DOM对象树的根节点调用render函数,协调便会在整颗树上发生。若是这颗树深度很深,广度很广的话,即便有了协调,渲染性能也不会太可观。下一篇章,咱们一块儿来运用react分而治之的理念,引入“component”概念,实现更细粒度的协调。