听说你想写个 React - virtual dom

大家好,我是微微笑的蜗牛,🐌。

上一篇文章介绍了 jsx 背后的实现,今天来介绍一下 virtual dom。

如何更新 dom

若采用现有的实现方式,想要更新 dom,只能先构造不同的节点描述信息,再重新调用 render 方法。

先看一个例子,计时器每隔 1s 刷新页面。

const rootDom = document.getElementById("root");

function tick() {
  const time = new Date().toLocaleString();
  const clockElement = <h1>{time}</h1>;
  SLReact.render(clockElement, rootDom);
}

tick();
setInterval(tick, 1000);

这个例子中,每秒都会调用一次 render 方法。

而以现有的实现方式,render 方法中总是在往 dom 树上添加节点。这样会造成节点不断的增加,就像下图这个样子。

image

但我们可稍微简单修改一下 render 的实现,将添加改成替换。

if (!parentDom.lastChild) {
    parentDom.appendChild(dom);     
} else {
    parentDom.replaceChild(dom, parentDom.lastChild);    
}

这样改过之后,比上面的实现方案要好一点。但是对于复杂的结构来说,仍是耗费巨大,因为在操作真实的 dom 树。

但如果试想有一种方案,只刷新差异部分,那么操作 dom 树的效率将会大大提升。

那么如何才能得到前后 dom 树的差异呢?

这就需要用到中间层,使用额外的结构来存储真实 dom 树的结构。在重新 render 的时候,进行新老对比,找出差异部分,再进行更新。

这种中间结构称之为 virtual dom,可简称 vdom。为什么叫做虚拟 dom,因为它只是内存中真实 dom 树的对照。

virtual dom

  1. 可想而知,vdom 与 dom 会有一种映射关系。vdom 需要跟真实 dom 进行关联,这样就可以方便的找到真实 dom,进行操作。那么在 vdom 的信息中就会包含 dom。

  2. 此外,vdom 还需包含节点描述信息,不然怎么做对比呢?

  3. vdom 也是树状结构,那么同样包含子节点。

根据上述分析,我们便可得到 vdom 的结构。它包括节点描述信息、关联的真实 dom、子 vdom 节点。每个 dom 节点都对应着一个 vdom 节点。

当在做 diff 时,根据新旧节点描述信息,找出差异部分,尽可能的重用 dom,减少开销。

那如何来构建 vdom 进行 diff 呢?下面我们来一步步的讲解。

vdom 结构

虚拟 dom 节点信息包括三部分:

  • 节点描述信息
  • 关联的真实 dom
  • 子虚拟 dom 节点

它的结构如下:

let vdom = { dom, element, childInstances };

根据之前的 render 方法,其实比较容易改造出这种结构。因为它返回的是真实 dom,我们只需将返回信息修改为 virtual dom 的结构就好。

改造过程如下:

  • 根据节点类型生成真实 dom
  • 更新 dom 属性
  • 递归处理子节点
  • 获取子节点真实 dom,逐个添加到父节点
  • 返回 virtual dom

代码如下所示:

// virtual dom,保存真实的 dom,element,childInstance
function instantitate(element) {

  const { type, props } = element;
  const isTextElement = type === TEXT_ELEMENT;

    // 生成真实 dom
  const dom = isTextElement
    ? document.createTextNode("")
    : document.createElement(type);

  // 更新属性
  updateDomProperties(dom, [], props);

  // 处理子节点
  const childElements = props.children || [];

    // 生成子虚拟 dom
  const childInstances = childElements.map(instantitate);

    // 获取子 dom
  const childDoms = childInstances.map((childInstance) => childInstance.dom);

    // 添加到 dom 树
  childDoms.forEach((childDom) => dom.appendChild(childDom));

    // 组成虚拟 dom 结构
  const instance = { dom, element, childInstances };

  return instance;
}

在得到 virtual dom 结构后,下一步需要做的就是更新真实 dom 节点。

此时,render 方法需要进行改造,变为比较前后 vdom 差异。

let rootInstance = null;

function render(element, parentDom) {
  const prevInstance = rootInstance;
  const nextInstance = reconcile(parentDom, prevInstance, element);
  rootInstance = nextInstance;
}

它主要工作是和上一个 virtual dom 实例做对比,然后进行 dom 树的更新。

这里我们将 diff 更新的过程叫做 reconcile,它会返回 virtual dom 节点。如下图所示:

image

diff 简单处理

reconcile 的入参有三个,分别是父 dom 节点、vdom、节点描述信息。

先来看一种简单的处理方式:

  • 如果传入的 vdom 为空,说明还没有 dom 节点,需要将真实 dom 添加到 dom 根节点上。
  • 如果不为空,则用新的 dom 节点替换原有 dom 节点。

如下所示:

function reconcile(parentDom, instance, element) {
  if (instance == null) {
        // 添加 dom
    const newInstance = instantiate(element);
    parentDom.appendChild(newInstance.dom);
    return newInstance;
  } else {
        // 替换 dom
    const newInstance = instantiate(element);
    parentDom.replaceChild(newInstance.dom, instance.dom);
    return newInstance;
  }
}

不知大家发现了没,上面的处理中,无论哪种条件下都调用了 instantiate 方法来重新创建 vdom 结构。

而 instantiate 中会创建真实 dom,这样在节点类型没变化时会产生不必要的开销。

重用 dom 节点

这里我们可以稍微优化一下,当节点类型一样时,可以不用重新创建 dom 节点,复用已有就行,然后再更新属性。

如下所示:

function reconcile(parentDom, instance, element) {
 if (instance.element.type === element.type) {
    // 复用 dom,更新属性
    updateDomProperties(instance.dom, instance.element.props, element.props);
    instance.element = element;
    return instance;
  }
}

子节点 diff

但是,还存在一个问题,子节点的 diff 还没有进行处理。

在 React 中,子节点会有一个额外的属性 key,以它为标识来对比之前的节点。

这里,我们将简单处理,只将每个位置上的子节点与新的节点信息进行对比,进行新增/更新/替换/删除操作。

如下所示:

// 对子节点做处理
function reconcileChildren(instance, element) {
  const dom = instance.dom;

    // 原有 virutal dom
  const childInstances = instance.childInstances;

    // 新的节点描述信息
  const nextChildElements = element.props.children || [];

  const newChildInstances = [];

  // 取最大的,若新子节点数 < 原节点数,需移除
  const count = Math.max(childInstances.length, nextChildElements.length);
  for (let i = 0; i < count; i++) {
    const childInstance = childInstances[i];
    const childElement = nextChildElements[i];

    const newChildInstance = reconcile(dom, childInstance, childElement);
    newChildInstances.push(newChildInstance);
  }

  return newChildInstances;
}

每个位置上的子节点 diff 仍然会调用到 reconcile 方法。

请注意:新旧子节点的数目可能是不一样的。

若新的子节点数目小于旧子节点数,需要删除旧子节点。

因为遍历次数是由新旧节点数目最大的那个决定。当遍历次数超出新子节点数时,这时,childElement 为 null。

如下图所示:

image

对应到 reconcile 中的处理,当 element 为空时,删除真实 dom 节点,然后 vdom 返回 null。

// dom 更新操作
function reconcile(parentDom, instance, element) {
    // 省略...
 if (element == null) {
    console.log("remove dom");

    // remove,若新子节点数 < 原节点数,需移除
    parentDom.removeChild(instance.dom);
    return null;

  } else if (instance.element.type == element.type) {

    console.log("reuse dom");

    // 重用节点,更新属性
    updateDomProperties(instance.dom, instance.element.props, element.props);

        // 子节点处理
    instance.childInstances = reconcileChildren(instance, element);
    instance.element = element;
    return instance;
  }
}

这样就完成了 dom diff 的简单处理。

完整代码可查看:https://github.com/silan-liu/slreact/tree/master/part3

总结

这篇文章主要介绍了如何构建 virutal dom 结构,以及进行简单的 diff 处理,以重用 dom 节点,减少不必要的开销。

下一篇将介绍 component 和 state 的实现,敬请期待~

参考资料

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容