Open buxuku opened 3 years ago
在前面的insertChildAtt
和diff
方法里面,我们取的是children
值,这里有一个问题,children
取的是父元素的子元素,是被HTML标签包裹的元素,不包含文本节点的,所以对于如<div>hello:!<div>
这样的标签,是没办法在hello:
后面插入内容的.正确的应该采用childNodes
来获取.
function path(diffQueue){
// 1.删除要删除的
let deleteMap = {};
let deleteChildren = [];
diffQueue.forEach((item) => {
const {type, fromIndex, toIndex} = item;
if(type === MOVE || type === REMOVE){
- const oldChild = item.parentDom.children[fromIndex];
+ const oldChild = item.parentDom.childNodes[fromIndex];
deleteMap[fromIndex] = oldChild;
deleteChildren.push(oldChild);
}
});
deleteChildren.forEach(item => {
item.parentNode.removeChild(item);
});
diffQueue.forEach((item) => {
const { type, fromIndex, toIndex, parentDom, dom} = item;
if(type === INSERT){
insertChildAt(parentDom, dom, toIndex)
}
if(type === MOVE){
insertChildAt(parentDom, deleteMap[fromIndex], toIndex)
}
})
}
function insertChildAt(parentDom,dom, toIndex){
- let oldChild = parentDom.children[toIndex];
+ let oldChild = parentDom.childNodes[toIndex];
oldChild ? parentDom.insertBefore(dom, oldChild) : parentDom.appendChild(dom);
}
在正常情况下它工作是良好的,但如果节点是一个null
,undefined
或者说是布尔值,那么它是不会渲染到真实dom节点上面的.这就会导致我们之前的_mountIndex
不一定是准确的.
所以对于mountIndex
是应该按照真实的需要渲染的节点来设置的.这里单独维护一个mountIndex
数据.
首先调整一下createDom
里面对children
的处理
if (props) {
const {children} = props;
if (Array.isArray(children)) {
reconcileChildren(children, dom);
} else {
- render(children, dom);
+ reconcileChildren([children], dom);
}
}
在reconcileChildren
单独维护一个mountIndex
function reconcileChildren(childrenVdom, parentDOM) {
+ let mountIndex = 0;
for (let i = 0; i < childrenVdom.length; i++) {
- childrenVdom[i]._mountIndex = i;
- let childVdom = childrenVdom[i];
- render(childVdom, parentDOM);
+ if(!isNotNeedRender(childrenVdom[i])){
+ childrenVdom[i]._mountIndex = mountIndex ++;
+ let childVdom = childrenVdom[i];
+ render(childVdom, parentDOM);
+ }
}
}
对getOldChildrenMap
和getNewChildrenMap
进行调整,取key
的时候判断item
是否有值.
function getOldChildrenMap(elements){
let map = {};
elements.forEach((item, index) => {
- const key = item.key || index.toString();
+ const key = (item && item.key) || index.toString();
- map[key] = item;
});
return map;
}
function getNewChildrenMap(oldChildrenMap, elements){
let map = {};
elements.forEach((item, index) => {
- const key = item.key || index.toString();
+ const key = (item && item.key) || index.toString();
if(!isNotNeedRender(item)){ // 新节点不需要渲染
let oldElement = oldChildrenMap[key];
// 判断是否可以复用
if(canDeepCompare(oldElement, item)){
updateElement(oldElement, item); // 直接复用老的DOM节点,更新节点属性和子元素.
elements[index] = oldElement;
}
- map[key] = elements[index];
}
+ map[key] = elements[index];
})
return map;
}
最后对diff
方法也进行调整,并且之前在判断需要删除的旧元素时,只判断了key
,少了存在key,
但类型变了的情况.
function diff(parentDom, oldChildren, newChildren){
const oldChildrenMap = getOldChildrenMap(oldChildren);
const newChildrenMap = getNewChildrenMap(oldChildrenMap, newChildren);
let lastIndex = 0;
+ let mountIndex = 0;
newChildren.forEach((item, index) => {
if(!isNotNeedRender(item)){
const key = item.key || index.toString();
const oldElement = oldChildrenMap[key];
if(item === oldElement){ // 是相同节点
if(oldElement._mountIndex < lastIndex){ // 判断老元素是否需要移动
diffQueue.push({
parentDom,
type: MOVE,
fromIndex: oldElement._mountIndex,
- toIndex: index,
+ toIndex: mountIndex,
})
}
lastIndex = Math.max(oldElement._mountIndex, lastIndex);
}else{ // 属于新元素,直接插入
diffQueue.push({
parentDom,
type: INSERT,
- toIndex: index,
+ toIndex: mountIndex,
dom: createDom(item)
})
}
- item._mountIndex = index; // 更新挂载索引
+ item._mountIndex = mountIndex ++; // 更新挂载索引
}
})
for(let key in oldChildrenMap){
- if(!newChildrenMap.hasOwnProperty(key)){
- const oldElement = oldChildrenMap[key];
+ const oldElement = oldChildrenMap[key];
+ const notWithNew = !newChildrenMap.hasOwnProperty(key); // 新节点里面不存在该老元素
+ const notSame = newChildrenMap[key] !== oldElement; // 新节点该元素的类型变了
+ if(!isNotNeedRender(oldElement) && (notWithNew || notSame)){
diffQueue.push({
parentDom,
type: REMOVE,
fromIndex: oldElement._mountIndex
})
}
}
}
对之前的组件调整一下,看看是否能够正常渲染
render() {
return this.state.show ? (
<ul onClick={this.handleClick}>
<li key="A" id="1">A</li>
+ {true}
<li key="B">B</li>
<li key="C">C</li>
<li key="D">D</li>
</ul>
)
: (
<ul onClick={this.handleClick}>
<li key="A" id="2">A</li>
+ {null}
<li key="C">C</li>
<li key="B">B</li>
+ {undefined}
<li key="E">E</li>
<li key="F">F</li>
</ul>
)
}
另外,在前面对key
的取值时,并不严谨,没有考虑到key
为null
和0
的情况,这将会导致如下情况的key
出现错乱.
<ul>
<li key={1}>1<li>
<li key={0}>0<lii>
</ul>
因此,我们在src/utils.js
里面写一个getVdomKey
的方法
/**
* 获取vdom的key属性
* @param vdom
* @param index
* @returns {*|string}
*/
export function getVomKey(vdom, index){
return vdom && vdom.key !== undefined ? vdom.key : index.toString();
}
然后修改src/react-dom/index.js
文件里面的getOldChildrenMap
,getNewChildrenMap
,diff
三个方法里面的const key=...
的方法,统一修改为
const key = getVomKey(item, index);
另外,在之前的updateElement
方法里面,以于文本节点更新完成之后,需要将其的props
属性也更新,以避免每次更新oldVdom
上面的props
永远是最开始的值,另外对于原生的HTML元素,我们进行了节点属性和子元素的更新,它虽然可能复用了老的节点和子节点,但并没有把新的vdom
里面的props
属性更新过去,这会导致,在下一次更新的时候,还是会拿最原始的props
属性去对比更新.包括props
里面的children
属性.所以,在完成更新后,同时也需要把新的porps
属性也更新过去.我们在方法里面加上一句.
function updateElement(oldVdom, newVdom) {
if (oldVdom.type === REACT_TEXT && newVdom.type === REACT_TEXT ) {
const dom = newVdom.dom = findDom(oldVdom);
if(oldVdom.props.content !== newVdom.props.content){ // 当文本内容有变化才进行更新
dom.textContent = newVdom.props.content;
+ oldVdom.props = newVdom.props; // 更新完成成, 新的props属性复制到老的Vom上面,否则props不会更新
}
}
if (typeof oldVdom.type === 'string') { // 原生的HTML元素
const currentDom = newVdom.dom = findDom(oldVdom); // 把老的Dom节点直接复制过来
renderAttributes(currentDom, newVdom.props, oldVdom.props); // 更新节点属性
updateChildren(currentDom, oldVdom.props.children, newVdom.props.children);
+ oldVdom.props = newVdom.props; // 更新完成成, 新的props属性复制到老的Vom上面,否则props不会更新
} else if(typeof oldVdom.type === 'function'){
if (oldVdom.type.isReactComponent) {
updateClassComponent(oldVdom, newVdom)
}else{
updateFunctionComponent(oldVdom, newVdom);
}
}
}
假如有这样一个组件,它最后返回的虚拟Dom会长什么样子呢?
const NumberList = () => {
const numbers = [1, 2, 3, 4, 5];
return (<div>
<p>NumberList: </p>
{numbers.map(item => <p key={item}>{item}</p>)}
{numbers}
</div>)
}
我们知道,在jsx
里面可以直接放一个数组的.而它返回的虚拟Dom是长这个样子的.
可以看到,在div
的props.children
实际上只有三个顶级子元素,而第二和第三个子元素是包含子多个子元素的数组.因此,在这种情况下,我们似乎意识到了前面我们标记的_mountIndex
又可能不准确了.因为按照前面的标识来算的话,这里面的第二个children
的_mountIndex
只有一个1
,但它其实是在div
下面渲染了5个子元素的.
为了让前面的_mountIndex
能够正常工作,这里我们将这种数组类型的子元素进行打平处理,因为打平之后的渲染顺序和之前的渲染顺序依然还是一致的.所以我们可以放心地把它们给打平出来.
在src/utils/index.js
里面增加一个flatten
方法
/**
* 对于[].map类型的节点,进行扁平化处理
* @param children
* @returns {*[]}
*/
export function flatten(children){
const result = [];
(function loop(array){
array.forEach(child => {
if(Array.isArray(child)){
loop(child);
}else{
result.push(child);
}
})
})(children)
return result;
}
在src/react/index.js
里面导入这个方法,对并createElement
这个方法里面的children
应用上面的方法进行处理.
import {Component} from "./Component";
-import {wrapToVdom} from '../utils';
+import {wrapToVdom, flatten} from '../utils';
import {REACT_FORWARD_COMPONENT} from "../constants";
/**
* 生成虚拟DOM
* @param type
* @param props
* @param children
* @returns {{ref: null, $$typeof: symbol, text: null, type, key: null, props: {}}}
*/
const createElement = (type, config = {}, ...children) => {
const {ref, __source, __self, key, ...props} = config || {};
if (children.length) {
+ children = flatten(children);
props.children = children.length > 1 ? children.map(wrapToVdom) : wrapToVdom(children[0]);
}
return {
$$typeof: Symbol.for('react.element'),
type,
props,
key,
ref,
}
}
如此以来,我们便可以正常渲染前面的那个NumberList
组件了.
另外,在前面对
key
的取值时,并不严谨,没有考虑到key
为null
和0
的情况,这将会导致如下情况的key
出现错乱.<ul> <li key={1}>1<li> <li key={0}>0<lii> </ul>
因此,我们在
src/utils.js
里面写一个getVdomKey
的方法/** * 获取vdom的key属性 * @param vdom * @param index * @returns {*|string} */ export function getVomKey(vdom, index){ return vdom && vdom.key !== undefined ? vdom.key : index.toString(); }
然后修改
src/react-dom/index.js
文件里面的getOldChildrenMap
,getNewChildrenMap
,diff
三个方法里面的const key=...
的方法,统一修改为const key = getVomKey(item, index);
这里面的getVomKey
还是会存在一个问题,假如key
的值刚好有和index
值一样的话,那就会有冲突,导致在getOldChildrenMap
和getNewChildrenMap
里面生成 的map丢失数据.所以需要对index
添加一个前缀来避免.
export function getVomKey(vdom, index){
- return vdom && vdom.key !== undefined ? vdom.key : index.toString();
+ return vdom && vdom.key !== undefined ? vdom.key : `$$REACT_KEY_${index}`; // 添加前缀防止index和key冲突
}
另外,在之前的
updateElement
方法里面,以于文本节点更新完成之后,需要将其的props
属性也更新,以避免每次更新oldVdom
上面的props
永远是最开始的值,另外对于原生的HTML元素,我们进行了节点属性和子元素的更新,它虽然可能复用了老的节点和子节点,但并没有把新的vdom
里面的props
属性更新过去,这会导致,在下一次更新的时候,还是会拿最原始的props
属性去对比更新.包括props
里面的children
属性.所以,在完成更新后,同时也需要把新的porps
属性也更新过去.我们在方法里面加上一句.function updateElement(oldVdom, newVdom) { if (oldVdom.type === REACT_TEXT && newVdom.type === REACT_TEXT ) { const dom = newVdom.dom = findDom(oldVdom); if(oldVdom.props.content !== newVdom.props.content){ // 当文本内容有变化才进行更新 dom.textContent = newVdom.props.content; + oldVdom.props = newVdom.props; // 更新完成成, 新的props属性复制到老的Vom上面,否则props不会更新 } } if (typeof oldVdom.type === 'string') { // 原生的HTML元素 const currentDom = newVdom.dom = findDom(oldVdom); // 把老的Dom节点直接复制过来 renderAttributes(currentDom, newVdom.props, oldVdom.props); // 更新节点属性 updateChildren(currentDom, oldVdom.props.children, newVdom.props.children); + oldVdom.props = newVdom.props; // 更新完成成, 新的props属性复制到老的Vom上面,否则props不会更新 } else if(typeof oldVdom.type === 'function'){ if (oldVdom.type.isReactComponent) { updateClassComponent(oldVdom, newVdom) }else{ updateFunctionComponent(oldVdom, newVdom); } } }
这个地方的修改其实应该放在getNewChildrenMap
这个方法里面.在这个方法里面,如果节点可以复用,就用老的虚拟Dom来替换新的虚拟Dom,对应这一句代码elements[index] = oldElement;
,所以应该是在这个地方把新的虚拟Dom的props
属性更新过来.
function updateElement(oldVdom, newVdom) {
if (oldVdom.type === REACT_TEXT && newVdom.type === REACT_TEXT ) {
const dom = newVdom.dom = findDom(oldVdom);
if(oldVdom.props.content !== newVdom.props.content){ // 当文本内容有变化才进行更新
dom.textContent = newVdom.props.content;
- oldVdom.props = newVdom.props; // 更新完成成, 新的props属性复制到老的Vom上面,否则props不会更新
}
}
if (typeof oldVdom.type === 'string') { // 原生的HTML元素
const currentDom = newVdom.dom = findDom(oldVdom); // 把老的Dom节点直接复制过来
renderAttributes(currentDom, newVdom.props, oldVdom.props); // 更新节点属性
updateChildren(currentDom, oldVdom.props.children, newVdom.props.children);
- oldVdom.props = newVdom.props; // 更新完成成, 新的props属性复制到老的Vom上面,否则props不会更新
} else if(typeof oldVdom.type === 'function'){
if (oldVdom.type.isReactComponent) {
updateClassComponent(oldVdom, newVdom)
}else{
updateFunctionComponent(oldVdom, newVdom);
}
}
}
function getNewChildrenMap(oldChildrenMap, elements){
let map = {};
elements.forEach((item, index) => {
const key = getVomKey(item, index);
if(!isNotNeedRender(item)){ // 新节点不需要渲染
let oldElement = oldChildrenMap[key];
// 判断是否可以复用
if(canDeepCompare(oldElement, item)){
updateElement(oldElement, item); // 直接复用老的DOM节点,更新节点属性和子元素.
+ oldElement.props = item.props; // 更新完成成, 新的props属性复制到老的Vom上面,否则props不会更新
elements[index] = oldElement;
}
}
map[key] = elements[index];
})
return map;
}
在上一节中, 我们没有考虑元素移动的问题,比如子元素没有变更,只是移动了位置,那么是没办法来复用这个元素的.我们知道,在React中,处理这样的场景,是通过
key
来实现的.我们可以通过key
来判断是否是同一个元素.在处理大量的子元素时,React也正是通过这样一个key
来提高dom-diff的性能.假如有这个一个组件
在点击的时候,这个列表元素会由A,B,C,D变成A,C,B,E,F.在执行这一变化的时,Ract会尽可能复用老的元素,并且遵守一个原则就是,以新元素的基准,对老的元素仍做向右移动.
对于上面这个组件,A元素不变,直接复用,B,C元素只是进行了位置的移动,这个移动我们可以移动C元素到B元素前面,也可以移动B元素到C元素后面,那么根据向右移动的原则,所以我们应该移动B元素的.当然,这并一定是最优的移动方法,比如元素ABCD变成DABC,最优的移动方法就是把D向左移动到最前面的就可以,但遵循React的向右原则的话,需要保持D不动,而要移动ABC三次.所以这种方案并不比Vue里面的两边向中间比较的方案高效.
后面的元素就比较简单了,删除D元素,插入E,F新元素.
为了判断元素是否需要移动,我们需要维护一个游标
lastPlaceholder
,每个元素对应有一个index
的序号来标识是属于第几个元素,我们遍历新元素,如果在老元素中找到有相同的key,并且元素的序号大于lastPlaceholder
的话,就将lastPlaceholder
向右移动到老元素对应的序号,证明该元素可以复用且不需要进行移动,如果小于lastPlaceholder
,那么就证明该元素是需要进行移动操作的.因为元素只能向右移动,那么lastPlaceholder
也永远是只能向右移动的.通过这个游标,我们就可以遍历出哪些元素是可以不需要移动的,哪些是需要进行移动的.lastPlaceholder
默认为0,比如在遍历新元素过程中1:第一个元素A,序号为0,
lastPlaceholder
也为0,不需要做任何操作.2.第二个元素C,对应老元素的序号为2,大于
lastPlaceholder
,则将lastPlaceholder
更新为2,C元素不需要移动操作3.第三个元素B,对应老元素的序号为1,小于
lastPlaceholder
,则证明B元素是需要进行向右移动操作的,同时lastPlaceholder
保持不变.4.第四,五个元素E,F没有对应的老元素,属于新增操作.
5.最后遍历老元素,发现D元素在新元素里面没有,当属于删除操作.
这一轮对比下来之后,我们就可以生成一个补丁包,通过这个补丁包记录下来本次对比下来需要执行的哪些操作,比如哪些是需要删除的,哪些是需要从某个位置移动到另一个位置的,哪些是新增的.最后在应用这个补丁包来完成整个更新操作.
开始Codeing
首先
createElement
需要把key
加上了在
src/react-dom/index.js
里面的方法,给子元素增加一个_mountIndex
的属性,用来记录元素处于第几个位置.为了能用
key
进行对比,我们需要把新旧虚拟Dom里面的key
提取出来,做成一张映射表,来方便查看是否存在相同的key
.就好比如下新元素的map表,除了不仅仅提取出所有的
key
,如果有可复用的老节点,就key
对应的值就直接复用老节点的.就好比如下这里需要注意两个点:
1.
key
相同并不一定代表就可以直接复用,如果元素类型变了,也是不可以复用的.2.
key
相同,元素类型也相同,也还需要考虑props
和子元素更新了的情况.基于以上,我们在
src/react-dom/index.js
里面新增一个diff
方法,来替代以前updateChildren
里面的粗暴逻辑这样就得到了两张新旧元素的map表.接下来遍历这两张表,来生成我们需要的补丁包.
在
src/constant/index.js
里面创建三种不同类型的操作项来代表移动,删除,新增在
src/react-dom/index.js
里面导入,并维护一个diffQueue
的补丁包.在之前的
diff
方法里面来遍历新旧两张表,生成这个补丁包,移动的元素记录下需要从哪个位置移动到哪个位置,插入的元素记录插入到哪个位置,删除的元素则记录需要从第几个删除.在前面
getNewChildrenMap
方法里面,如果元素下面继续挂载着子元素,则会递归再次执行到diff方法里面去.为了确保整个子节点递归对比完成了,我们需要维护一个变量updateDepth
,来代表当前对比所处在的层级,每次进入diff
方法刚加1,执行完一轮出来,刚减1,当updateDepth
等于0的时候,刚代表当前元素及其子元素已经对比完成了.打印看一下我们获取到的这个补丁包:
这个补丁包符合我们的预期,接下来就是来应用这个补丁包.
首先,找出所有需移动和删除的元素,,把它们从原来的位置上面删除掉,并把老元素缓存一份,因为如果是移动的元素,后面在插入的需要进行利用.
在这里,我们需要删除B,D元素.剩下AC元素.然后在索引2的位置移入原来的B元素,在索引3,4的位置插入新增的元素.
打开控制台,查看最开始我们写的那个组件,试着点击一下,看看A,C元素是否成功复用了.并且元素的属性也有了更新.