React学习第七天—Virtual DOM 及 Diff 算法(ref属性实现和key比对DOM更新)(六)

这是我参与更文挑战的第19天
源码

大家好我是小村儿,我们今天是React的Virtual DOM及diff算法的结尾:

  • 实现ref属性获取元素DOM对象,获取组件实例对象
  • 使用key属性进行节点对比
  • 删除节点

以上是今天主要学习内容

实现ref属性,获取元素DOM对象,获取组件实例对象

1. 了解ref的基本工功能

在React当中我们可以为元素使用ref属性,通过ref属性可以获取该元素DOM对象。我们看一段代码:

render() {
    <div>
        <input type="text" ref={input => (this.input = input)} />
        <button onClick={this.handClick}>按钮</button>
    </div>
}
复制代码

我们在input框上就可以使用ref,这个ref的值呢是一个函数,函数有一个形参,这个形参表示当前DOM对象,函数体里面this.input = input,就是将ref获取的inputDOM对象赋值组件state.input属性值。在点击按钮时获取用户在文本框中输入的内容。

2. 实现ref思路

在创建节点时判断其Virtual DOM对象中是否有ref属性,如果有就调用ref属性中所存储的方法并且将创建出来的DOM对象作为参数传递给ref方法,这样在渲染组件节点的时候就可以拿到元素对象并将对象存储为组件属性.

1)获取元素DOM对象
我们在哪一步开始创建DOM对象呢?我们在createDOMElement.js将virtualDOM创建成DOM对象,所以可以在这里将DOM对象传递给ref,下面是关键代码。

// createDOMElement.js
if(virtualDOM.props && virtualDOM.props.ref) {
    virtualDOM.props.ref(newElement)
}
复制代码

效果:

image.png
我们成功使用ref取到DOM对象啦!!!
2)获取组件实例对象
之前写了一个Alert组件

// src/index.js
class Alert extends TinyReact.Component {
  ...
}
复制代码

我们在DemoRef组件button下面调用Alert组件,给Alert组件添加ref属性ref={alert => this.alert = alert}

// src/index.js
handleClick() {
    console.log(this.input.value)
    console.log(this.alert)
}
render() {
    <div>
        <input type="text" ref={input => (this.input = input)} />
        <button onClick={this.handClick}>按钮</button>
        <Alert ref={alert => this.alert = alert} name="张三" age={20}/>
    </div>
}
复制代码

image.png

实现使用ref获取Alert组件实例的思路:

  • 在mountComponent函数中,如果判断了当前处理的是类组件,就通过类组件返回的VirtualDOM对象中获取组件实例对象,判断组件实例对象中的props属性中是否存在ref属性,如果存在就调用ref方法并且将组件实例对象传递给ref方法。

代码实现:

import isFunction from "./isFunction";
import isFunctionComponent from "./isFunctionComponent";
import mountNativeElement from "./mountNativeElement";

export default function mountComponent(virtualDOM, container, oldDOM) {
  let nextVirtualDOM = null
  let component = null
  ···
  } else {
    // 类组件
    nextVirtualDOM = buildClassComponent(virtualDOM)
    component = nextVirtualDOM.component; // 这里我们拿到组件实例
  }
  
  // 判断如果有组件实例
  if(component){
      // 如果有组件实例且在props中有ref属性则将组件实例传递给ref属性 调用ref
    if(component.props && component.props.ref) {
      component.props.ref(component) // 将组件传递给ref
    }
  }
   ...
}

...
function buildClassComponent(virtualDOM) {
  const component = new virtualDOM.type(virtualDOM.props || {});
  const nextVirtualDOM = component.render()
  nextVirtualDOM.component = component // 执行这个方法时候nextVirtualDOM存储了组件实例component
  return nextVirtualDOM
}
复制代码

效果:

image.png

注意:

我们通过ref获取到了组件的实例对象,我们还要处理一个边际情况,如果代码能够走到ref调用的时候,说明组件已经挂载完成了,所以在判断是否有组件的时候调用周期函数,这个函数很明显应该是componentDidMount周期函数。

代码实现:

···
// 判断如果有组件实例
  if(component){
  component.componentDidMount()
      // 如果有组件实例且在props中有ref属性则将组件实例传递给ref属性 调用ref
    if(component.props && component.props.ref) {
      component.props.ref(component) // 将组件传递给ref
    }
  }
  // src/index.js
  ...
  handleClick() {
    console.log(this.input.value)
    console.log(this.alert)
  }
  componentDidMount() {
    console.log('componentDidMount')
  }
  render() {
    return (
      <div>
        <input type="text" ref={input => (this.input = input)} />
        <button onClick={this.handleClick}>按钮</button>
        <Alert ref={alert => this.alert = alert} name="张三" age={20} />
      </div>)
  }
}
复制代码

效果:

image.png

小结

我们在DomeRef组件中给元素input和组件Alert添加ref属性,给元素添加ref属性目的是为了通过ref获取元素DOM对象,给组件添加ref属性目的是为了通过ref获取组件实例对象。我们知道生成DOM对象我们是在createDOMElemnt.js中生成的,生成DOM对象就渲染到页面当中,在创建元素之后我们要判断virtualDOM对象中有没有ref属性,如果有则调用ref传递过来的方法,并且将生成的DOM对象传递给他。这样就完成了ref实现DOM实例对象。我们之前实现mountComponnet.js使用buildClassComponent获取到组件实例对象的,并且将这个组件实例对象存储到nextVirtualDOM带渲染的虚拟DOM中,我们可以提取出来存储在一个变量中,然后判是否有component实例对象如果有,我们再判断这个组件实例对象component有没有ref属性,有的话调用ref方法,并且将组件实例对象传递进去,这样我们就完成了组件实例的获取。因为获取组件实例对象的时候,也正好完成了组件实例对象的挂载,我们这里还可以调用类组件的周期函数,componentDidMount.这样我们就很简单的完成类类组件,通过ref获取元素的DOM对象和类组件实例的工作。

使用key属性进行节点对比

没有Key的时候存在问题和知识点回顾

  1. 删除节点

在图中有两个UL,左边是旧节点,右边是新节点。在旧节点内容是1,2,3,4;在新节点内容是1,3,4;我们肉眼可以看到删除的是内容为2的节点。当时我们是先做的节点对比然后再做的节点删除,我们是用3替换了2,用4替换了3,然后判断最后一个节点,新节点不存在,对其进行删除。这样删除明显很麻烦,如果我们能够直接找到2节点进行删除,就会简单很多。使用key属性就可以解决该问题,我们可以直接通过key找到内容为2的节点然后直接删除,减少DOM操作,提高DOM操作的性能。

image.png

  1. 知识点回顾

在React中,渲染列表数据时通常会在被渲染的列表元素上添加key属性,key属性就是数据的唯一标识,帮助React识别哪些数据被修改或者被删除,从而达到最小化操作的目的

  • key属性不需要全局唯一,但是在同一个父节点下的兄弟节点之间必须是唯一的,
  • 在比对同一父节点下类型相同的子节点时需要用到key属性

节点对比

实现key属性之前,我们的关注点是,这个元素是否需要被重新渲染,或者这个元素的位置是否发生了变化。

  1. 实现思路

在两个元素进行对比时,如果类型相同,就循环旧DOM对象的子元素,查看其身上是否有key属性,如果就将这个子元素的DOM对象存储在一个javascript对象中,接着循环要渲染的VirtualDOM对象的子元素,在循环过程中获取这个子元素的key属性,然后使用这个key属性到javascript对象中查找DOM对象,如果能够找到就说明这个元素已经存在,是不需要重新渲染的。如果通过可以属性找不到这个元素,就说明这个元素是新增的是需要渲染的

  1. 代码实现

a. 将拥有key属性的子元素放置在一个单独的对象中
循环oldDOM的子元素,看是否设置了key属性,如果设置了key属性,我们将这个子元素添加到对象当中

// 1. 将拥有key属性的子元素放置在一个单独的对象中
    let keyedElements = {}
    for(let i = 0, len = oldDOM.childNodes.length;i< len;i++) {
      let domElement = old.childNodes[i]
      // 是元素节点才获取key值
      if(domElement.nodeType === 1) {
        let key = domElement.getAttribute("key")
        if(key) {
          keyedElements[key] = domElement
        }
      }

    }
复制代码

b. 循环 VirtualDOM的子元素,获取子元素的key属性

  • 如果子元素key属性存在,就去keyedElements对象查找对应的元素,使用domElement变量存储这个元素
  • 如果查找到了这个元素就说明这个元素是存在的,就不需要被重新渲染。
virtualDOM.children.forEach((child, i) => {
    let key = child.props.key
    // 判断该元素是否存在key属性
    if(key) {
      let domElement = keyedElements[key]
      // 判断keyedElements是否存在改元素
      if(domElement) {
      }
    }
  })

复制代码

c. 看一看当前位置的元素是不是我们期望的元素

我们获取当前位置元素:oldDOM.childNodes[i],如果当前dom元素存在,且当前元素和domElement不一样一样的话,则替换掉该元素,怎么替换么,我们可以使用insertBefore这个方法,插入到oldDOM.childNodes[i]当前位置元素的前面一位就可以了

virtualDOM.children.forEach((child, i) => {
    let key = child.props.key
    // 判断该元素是否存在key属性
    if(key) {
      let domElement = keyedElements[key]
      // 判断keyedElements是否存在改元素
      if(domElement) {
          // 如果当前dom元素存在,如果当前元素和domElement一样的话,则替换掉该元素,
          if(oldDOM.childNodes[i] && oldDOM.childNodes[i] !== domElement) {
             // 第一个是当前key的元素对象,第二个需要替换位置的元素dom对象
             oldDOM.insertBefore(domElement, oldDOM.childNodes[i])
          }
      }
    }
  })

复制代码

d. 判断keyedElements是否有值,如果没值直接使用diff进行比对,如果有值则进行key的diff比对

我们前面做删除节点的时候我们使用过diff进行节点的比对,完成更新和删除DOM操作

// 循环递归子节点,继续调用diff方法,比对子元素进行更新
virtualDOM.children.forEach((child, i) => {
    diff(child, oldDOM, oldDOM.childNodes[i]);
});

复制代码

所以当没有key 的时候执行以上的DOM更新操作,有key的时候则执行之前实现的dom更新操作

// keyedElements 是否有值
let hasNoKey = Object.keys(keyedElements).length === 0

// 无key,直接更新
if(hasNoKey) {
  // 循环递归子节点,继续调用diff方法,比对子元素进行更新
  virtualDOM.children.forEach((child, i) => {
    diff(child, oldDOM, oldDOM.childNodes[i]);
  });
} else {
  // 2. 循环 VirtualDOM 的子元素 获取子元素的 key属性
  virtualDOM.children.forEach((child, i) => {
    let key = child.props.key
    if(key) {
      let domElement = keyedElements[key]
      if(domElement) {
        // 3.看看当前位置的元素是不是我们期望的元素
        if(oldDOM.childNodes[i] && oldDOM.childNodes[i] !== domElement) {
          oldDOM.insertBefore(domElement, oldDOM.childNodes[i])
        }
      }
    }
  })
}

复制代码

实现了key更新,我们使用例子查看效果,大家可以拉取项目源代码,就不贴出来了,删除key属性进行比对

image.png
点击按钮完成替换,将第一个替换成了最后一个
image.png
e. 如果改key的dom元素是不存在的,则说明是新增元素,我们可以直接调用mountElement直接把他渲染到页面当中

  // 3.看看当前位置的元素是不是我们期望的元素
if(oldDOM.childNodes[i] && oldDOM.childNodes[i] !== domElement) {
  oldDOM.insertBefore(domElement, oldDOM.childNodes[i])
} else {
    // 新增元素
    mountElement(child, oldDOM)
}

// 案例Demo代码
handleClick() {
    const newState = JSON.parse(JSON.stringify(this.state))
    newState.persons.splice(1, 0, { id: 100, name: "李逵"})
    this.setState(newState)
  }

复制代码

效果:
image.png

我们是把新增的“李逵”新增插入,但是查看效果并没有达到我们效果,我们在mountNativeElement.js我们没有考虑任何情况,container.appendChild(newElement);将新增元素直接挂在父级元素中,这样是不行的。我们追加元素的时候应该看一下该位置的元素是谁,我们应该把新增的元素插入到该位置节点的前面,所以应该给mountElement传递第三个参数(该位置元素DOM对象oldDOM.childNodes[i])。在mountNativeElement当中添加一个判断,判断是否有oldDOM,有的话则在oldDOM前面添加新增元素

···
else {
    // 新增元素
    mountElement(child, oldDOM, oldDOM.childNodes[i])
}

   // mountNativeElement.js
 // 将转换之后的DOM对象放置在页面中
  if(oldDOM) {
    container.insertBefore(newElement, oldDOM)
  } else {
    container.appendChild(newElement);
  }


复制代码

image.png

这样就完成了有key属性是对比新插入元素。

通过key属性删除节点

在删除节点之前,我们看一下keyedElements是否存在值,如果为空则说明没有带key的子元素。这是后我们使用索引的方式删除子节点,如果有内容我们就要使用可以的方式删除节点。如何通过key的方式删除节点呢?

实现思路

循环旧节点,在循环旧节点的过程中获取旧节点对应key属性,然后根据key属性在新节点中查找这个旧节点,如果找到就说明这个节点没有被删除,如果没有找到,就说明节点被删除了,调用卸载节点的方法卸载节点即可。

// 删除节点
//  获取旧节点的数量

let oldChildNodes = oldDOM.childNodes;

// 如果纠结点的数量多于要渲染的新节点的长度
if (oldChildNodes.length > virtualDOM.children.length) {
  if (hasNoKey) {
  // 这里是没有key的删除
   ...
  } else {
    // 通过key属性删除节点
    for(let i = 0; i < oldChildNodes.length; i++) {
      let oldChild = oldChildNodes[i];
      let oldChildKey = oldChild._virtualDOM.props.key;
      let found = false
      for (let n = 0; n<virtualDOM.children.length;n++) {
        if(oldChildKey === virtualDOM.children[n].props.key) {
          found = true;
          break;
        }
      }
      if(!found) {
        unmountNode(oldChild)
      }
    }
  }

// 案例Demo代码
handleClick() {
    const newState = JSON.parse(JSON.stringify(this.state))
    newState.persons.pop()
    this.setState(newState)
  }
复制代码

image.png
删除赵六

image.png
删除成功

删除节点需要考虑什么

我们真的完成删除操作了么,我们还是需要考虑以下几种情况:

  1. 如果要删除的节点时文本节点的话可以直接删除
  2. 如果要删除的节点由组件生成,需要调用组件卸载生命周期函数
  3. 如果删除节点中包含了其他组件生成的节点,需要调用其他组件的卸载生命周期函数
  4. 如果要删除的节点身上有ref属性,还需要删除通过ref属性传递给组件的DOM节点对象
  5. 如果要删除的节点身上有事件,需要删除事件对应的事件处理函数

代码实现:

export default function unmountNode(node) {
  // 获取节点的 _virtualDOM 对象
  const virtualDOM = node._virtualDOM
  // 1. 文本节点可以直接删除
  if(virtualDOM.props.type === "text"){
    // 直接删除
    node.remove()
    // 阻止程序向下执行
  }
  // 2. 看一下节点是否是由组件生成的
  let component = virtualDOM.component

  if(component){
    component.componentWillUnmount();
  }

  // 3. 看一下节点身上是否有ref属性
  if(virtualDOM.props && virtualDOM.props.ref){
    virtualDOM.props.ref(null)
  }

  // 4. 看一下节点的属性中是否有事件属性
  Object.keys(virtualDOM.props).forEach(propName => {
    if(propName.slice(0, 2) === "on") {
      const eventName = propName.toLowerCase().slice(0, 2);
      const eventHandler = virtualDOM.props[propName];
      node.removeEventListener(eventName, eventHandler)
    }
  })
  // 递归删除子节点
  if(node.childNodes.length > 0) {
    for(let i = 0;i<node.childNodes.length;i++) {
      unmountNode(node.childNodes[i]);
      // 删除元素之后一定要让i--
      i--
    }
  }
  // 删除节点
  node.remove()
}

复制代码

总结:

  1. ref的实现

今天我们学习了实现ref,可以通过ref属性获取元素节点DOM对象 和 获取组件实例对象,在createDOMElement.js文件中判断元素是否有ref属性,有则将节点DOM对象传递给ref函数。在mountComponnet.js查看是否有ref属性,将component实例传递给ref属性,在这个节点刚好是组件挂载节点,调用类组件生命周期函数componentDidMount.

  1. 使用key完成节点对比

遍历oldDOM对象,遍历存储含有key属性的子元素,存储到对象keyedElements中,然后查看keyedElements是否有内容,没有则按照索引进行更新和删除,如果有则按照key的方式进行更新和删除

  • key方式更新,如果是相同元素则不重新更新渲染,如果是不相同元素则在这个需要更新的元素前一个位置完成插入替换。如果是插入的新增元素,则直接调用mountElement渲染到页面中。
  • key方式删除,遍历当前子元素节点,如果这个节点的key在virtualDOM中找不到则删除该节点
  1. 删除的时候还要注意一些情况需要处理,处理完才能算真正的删除:

    1. 如果要删除的节点时文本节点的话可以直接删除

    2. 如果要删除的节点由组件生成,需要调用组件卸载生命周期函数

    3. 如果删除节点中包含了其他组件生成的节点,需要调用其他组件的卸载生命周期函数

    4. 如果要删除的节点身上有ref属性,还需要删除通过ref属性传递给组件的DOM节点对象

    5. 如果要删除的节点身上有事件,需要删除事件对应的事件处理函数

今天为止:学习React的第七天,我们完成一个tinyReact,希望大家对react实现原理有一个基本的认知。也可以跟着笔记和提供的源码一起学习讨论,源码里面也有tinyReact模板可以使用模板完成操作,谢谢!希望点赞关注评论,看还需要什么补充的地方!!!第八天我们开始学习Fiber算法,敬请期待.

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享