Skip to content

8. 挂载与更新

8.1  挂载子节点和元素的属性

  • vnode.children 的值是字符串类型时,会把它设置为元素的文本内容。当它包含多个子节点时,需要将它设置成数组

  • 为了描述元素的属性,还需要为虚拟 DOM 定义新的 vnode.props 字段,它是一个对象,它的键代表元素的属性名称,它的值代表对应属性的值

js
const vnode = {
  type: "div",
  // 使用 props 描述一个元素的属性
  props: {
    id: "foo",
  },
  children: [
    {
      type: "p",
      children: "hello",
    },
  ],
};
js
function mountElement(vnode, container) {
  const el = createElement(vnode.type);
  //  children 的处理
  if (typeof vnode.children === "string") {
    setElementText(el, vnode.children);
  } else if (Array.isArray(vnode.children)) {
    // 如果 children 是数组,则遍历每一个子节点,并调用 patch 函数挂载它们
    vnode.children.forEach((child) => {
      patch(null, child, el);
    });
  }

  // 如果 vnode.props 存在才处理它
  if (vnode.props) {
    // 遍历 vnode.props
    for (const key in vnode.props) {
      // 调用 setAttribute 将属性设置到元素上
      el.setAttribute(key, vnode.props[key]);

      // 除了使用setAttribute 函数为元素设置属性之外,还可以通过 DOM 对象直接设置
      // el[key] = vnode.props[key];
    }
  }

  insert(el, container);
}

8.2   HTML Attributes 与 DOM Properties

重点:HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值

  1. HTML Attributes 指的就是定义在 HTML 标签上的属性
html
<input id="my-input" type="text" value="foo" />
<!--  id="my-input" 对应 el.id
      type="text" 对应 el.type
      value="foo" 对应 el.value  -->
  1. 但是 DOM Properties 与 HTMLAttributes 的名字不总是一模一样的
html
<input class="foo" />
<!-- class="foo" 对应的 DOM Properties 则是 el.className -->
html
<div aria-valuenow="75"></div>
<!-- aria-* 类的 HTML Attributes 没有与之对应的 DOM Properties -->
  1. HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。一旦值改变,那么 DOMProperties 始终存储着当前值。通过 getAttribute 函数得到的仍然是初始值,但仍然可以通过 el.defaultValue 来访问初始值
html
<input value="foo" />
js
const el = document.querySelector("input");
el.value = "bar";
console.log(el.getAttribute("value")); // 'foo'
console.log(el.value); // 'bar'
console.log(el.defaultValue); // 'foo'
  1. 如果你通过 HTML Attributes 提供的默认值不合法,那么浏览器会使用内建的合法值作为对应 DOM Properties 的默认值
js
<input type="foo" />;
console.log(el.type); // 'text'

8.3  正确地设置元素属性

对于按钮来说,它的 el.disabled 属性值是布尔类型的,并且它不关心具体的 HTML Attributes 的值是什么,只要 disabled 属性存在,按钮就会被禁用

  1. 情况一:需要禁用按钮
js
<button disabled>Button</button>;

// 解析模板得到的 vnode
const button = {
  type: "button",
  props: {
    disabled: "",
  },
};

// 渲染器调用 setAttribute 设置属性
el.setAttribute("disabled", ""); // 符合预期
  1. 情况二:不需要禁用按钮
js
 <button :disabled="false">Button</button>
// 解析模板得到的 vnode
 const button = {
  type: 'button',
  props: {
    disabled: false
  }
}
// 渲染器调用 setAttribute 设置属性
el.setAttribute("disabled", false);
// 但 `setAttribute` 函数设置的值总是会被字符串化,等价于
el.setAttribute("disabled", 'false'); // 禁用了按钮,不符合预期

要彻底解决这个问题,我们只能做特殊处理,即优先设置元素的 DOM Properties,但当值为空字符串时,要手动将值矫正为 true

js
function shouldSetAsProps(el, key, value) {
  // 特殊处理 因为有一些 DOM Properties 是只读的,不能设置
  //<form id="form1"></form>
  // <input form="form1" />
  if (key === "form" && el.tagName === "INPUT") return false;
  // 兜底 ,用 in 操作符判断 key 是否存在对应的 DOM Properties
  return key in el;
}

function mountElement(vnode, container) {
  const el = createElement(vnode.type);
  // 省略 children 的处理

  if (vnode.props) {
    for (const key in vnode.props) {
      const value = vnode.props[key];
      // 使用 shouldSetAsProps 函数判断是否应该作为 DOM Properties 设置
      if (shouldSetAsProps(el, key, value)) {
        // 获取该 DOM Properties 的类型
        const type = typeof el[key];
        // 如果是布尔类型,并且 value 是空字符串,则将值矫正为 true
        if (type === "boolean" && value === "") {
          el[key] = true;
        } else {
          el[key] = value;
        }
      } else {
        // 如果要设置的属性没有对应的 DOM Properties,则使用 setAttribute 函数设置属性
        el.setAttribute(key, vnode.props[key]);
      }
    }
  }

  insert(el, container);
}

提取到渲染器里

js
const renderer = createRenderer({
  createElement(tag) {
    return document.createElement(tag);
  },
  setElementText(el, text) {
    el.textContent = text;
  },
  insert(el, parent, anchor = null) {
    parent.insertBefore(el, anchor);
  },
  // 将属性设置相关操作封装到 patchProps 函数中,并作为渲染器选项传递
  patchProps(el, key, prevValue, nextValue) {
    if (shouldSetAsProps(el, key, nextValue)) {
      const type = typeof el[key];
      if (type === "boolean" && nextValue === "") {
        el[key] = true;
      } else {
        el[key] = nextValue;
      }
    } else {
      el.setAttribute(key, nextValue);
    }
  },
});

function mountElement(vnode, container) {
  const el = createElement(vnode.type);
  if (typeof vnode.children === "string") {
    setElementText(el, vnode.children);
  } else if (Array.isArray(vnode.children)) {
    vnode.children.forEach((child) => {
      patch(null, child, el);
    });
  }

  if (vnode.props) {
    for (const key in vnode.props) {
      // 调用 patchProps 函数即可
      patchProps(el, key, null, vnode.props[key]);
    }
  }

  insert(el, container);
}

8.4   class 的处理

Vue.js 对 calss 属性做了增强。在 Vue.js 中为元素设置类名有以下几种方式

  1. 字符串
vue
<p class="foo bar"></p>
js
// 对应的 vnode
const vnode = {
  type: "p",
  props: {
    class: "foo bar",
  },
};
  1. 对象
vue
<p :class="cls"></p>
js
const cls = { foo: true, bar: false };
// 对应的 vnode
const vnode = {
  type: "p",
  props: {
    class: { foo: true, bar: false },
  },
};
  1. 数组
vue
<p :class="arr"></p>
js
const arr = ["foo bar", { baz: true }];
// 对应的 vnode
const vnode = {
  type: "p",
  props: ["foo bar", { baz: true }],
};

因为 class 的值可以是多种类型,所以在设置元素的 class 之前将值归一化为统一的字符串形式,再把该字符串作为元素的 class 值去设置

js
const vnode = {
  type: "p",
  props: {
    // 使用 normalizeClass 函数对值进行序列化
    class: normalizeClass(["foo bar", { baz: true }]),
  },
};

在浏览器中为一个元素设置 class 有三种方式,即使用 setAttributeel.classNameel.classList。其中 el.className 的性能最优

js
const renderer = createRenderer({
  // 省略其他选项

  patchProps(el, key, prevValue, nextValue) {
    // 对 class 进行特殊处理
    if (key === "class") {
      el.className = nextValue || "";
    } else if (shouldSetAsProps(el, key, nextValue)) {
      const type = typeof el[key];
      if (type === "boolean" && nextValue === "") {
        el[key] = true;
      } else {
        el[key] = nextValue;
      }
    } else {
      el.setAttribute(key, nextValue);
    }
  },
});

8.5  卸载操作

卸载操作发生在更新阶段。当挂载时在 vnode 与真实 DOM 元素之间建立联系。卸载时根据 vnode 对象获取与其相关联的真实 DOM 元素,然后使用原生 DOM 操作方法将该 DOM 元素移除

js
function mountElement(vnode, container) {
  // 让 vnode.el 引用真实 DOM 元素
  const el = (vnode.el = createElement(vnode.type));
  if (typeof vnode.children === "string") {
    setElementText(el, vnode.children);
  } else if (Array.isArray(vnode.children)) {
    vnode.children.forEach((child) => {
      patch(null, child, el);
    });
  }

  if (vnode.props) {
    for (const key in vnode.props) {
      patchProps(el, key, null, vnode.props[key]);
    }
  }

  insert(el, container);
}

function unmount(vnode) {
  const parent = vnode.el.parentNode;
  if (parent) {
    parent.removeChild(vnode.el);
  }
}

function render(vnode, container) {
  if (vnode) {
    patch(container._vnode, vnode, container);
  } else {
    if (container._vnode) {
      // // 根据 vnode 获取要卸载的真实 DOM 元素
      // const el = container._vnode.el;
      // // 获取 el 的父元素
      // const parent = el.parentNode;
      // // 调用 removeChild 移除元素
      // if (parent) parent.removeChild(el);

      umount(container._vnode);
    }
  }
  container._vnode = vnode;
}

8.6  区分 vnode 的类型

在真正执行更新操作之前,需要先检查新旧 vnode 所描述的内容是否相同

js
function patch(n1, n2, container) {
  // 如果 n1 存在,则对比 n1 和 n2 的类型
  if (n1 && n1.type !== n2.type) {
    // 如果新旧 vnode 的类型不同,则直接将旧 vnode 卸载
    unmount(n1);
    n1 = null;
  }

  // 代码运行到这里,证明 n1 和 n2 所描述的内容相同
  const { type } = n2;
  // 如果 n2.type 的值是字符串类型,则它描述的是普通标签元素
  if (typeof type === "string") {
    if (!n1) {
      mountElement(n2, container);
    } else {
      patchElement(n1, n2);
    }
  } else if (typeof type === "object") {
    // 如果 n2.type 的值的类型是对象,则它描述的是组件
  } else if (type === "xxx") {
    // 处理其他类型的 vnode
  }
}

8.7  事件的处理

vnode.props 对象中,凡是以字符串 on 开头的属性都视作事件

js
patchProps(el, key, prevValue, nextValue) {
  // 匹配以 on 开头的属性,视其为事件
  if (/^on/.test(key)) {
    // 根据属性名称得到对应的事件名称,例如 onClick ---> click
    const name = key.slice(2).toLowerCase()

     // 移除上一次绑定的事件处理函数
    prevValue && el.removeEventListener(name, prevValue)
    // 绑定事件,nextValue 为事件处理函数
    el.addEventListener(name, nextValue)
  } else if (key === 'class') {
    // 省略部分代码
  } else if (shouldSetAsProps(el, key, nextValue)) {
    // 省略部分代码
  } else {
    // 省略部分代码
  }
}

缓存事件函数

js
patchProps(el, key, prevValue, nextValue) {
  if (/^on/.test(key)) {
    // 获取为该元素伪造的事件处理函数 invoker
    let invoker = el._vei
    const name = key.slice(2).toLowerCase()
    if (nextValue) {
      if (!invoker) {
        // 如果没有 invoker,则将一个伪造的 invoker 缓存到 el._vei 中
        // vei 是 vue event invoker 的首字母缩写
        invoker = el._vei = (e) => {
          // 当伪造的事件处理函数执行时,会执行真正的事件处理函数
          invoker.value(e)
        }
        // 将真正的事件处理函数赋值给 invoker.value
        invoker.value = nextValue
        // 绑定 invoker 作为事件处理函数
        el.addEventListener(name, invoker)
      } else {
        // 如果 invoker 存在,意味着更新,并且只需要更新 invoker.value 的值即可
        invoker.value = nextValue
      }
    } else if (invoker) {
      // 新的事件绑定函数不存在,且之前绑定的 invoker 存在,则移除绑定
      el.removeEventListener(name, invoker)
    }
  } else if (key === 'class') {
    // 省略部分代码
  } else if (shouldSetAsProps(el, key, nextValue)) {
    // 省略部分代码
  } else {
    // 省略部分代码
  }
}

为了防止事件覆盖的问题,重新设计 el._vei 的数据结构。将 el._vei 设计为一个对象。且同一个事件还能绑定多个事件处理函数

js
const vnode = {
  type: "p",
  props: {
    onClick: [
      // 第一个事件处理函数
      () => {
        alert("clicked 1");
      },
      // 第二个事件处理函数
      () => {
        alert("clicked 2");
      },
    ],
    onContextmenu: () => {
      alert("contextmenu");
    },
  },
  children: "text",
};
renderer.render(vnode, document.querySelector("#app"));
js
patchProps(el, key, prevValue, nextValue) {
  if (/^on/.test(key)) {
    // 定义 el._vei 为一个对象,存在事件名称到事件处理函数的映射
    const invokers = el._vei || (el._vei = {})
    //根据事件名称获取 invoker
    let invoker = invokers[key]
    const name = key.slice(2).toLowerCase()
    if (nextValue) {
      if (!invoker) {
        // 将事件处理函数缓存到 el._vei[key] 下,避免覆盖
        invoker = el._vei[key] = (e) => {
          // 如果 invoker.value 是数组,则遍历它并逐个调用事件处理函数
          if (Array.isArray(invoker.value)) {
            invoker.value.forEach(fn => fn(e))
          } else {
            // 否则直接作为函数调用
            invoker.value(e)
          }
        }
        invoker.value = nextValue
        el.addEventListener(name, invoker)
      } else {
        invoker.value = nextValue
      }
    } else if (invoker) {
      el.removeEventListener(name, invoker)
    }
  } else if (key === 'class') {
    // 省略部分代码
  } else if (shouldSetAsProps(el, key, nextValue)) {
    // 省略部分代码
  } else {
    // 省略部分代码
  }
}

8.8  事件冒泡与更新时机问题

js
const { effect, ref } = VueReactivity;

const bol = ref(false);

effect(() => {
  // 创建 vnode
  const vnode = {
    type: "div",
    props: bol.value
      ? {
          onClick: () => {
            alert("父元素 clicked");
          },
        }
      : {},
    children: [
      {
        type: "p",
        props: {
          onClick: () => {
            bol.value = true;
          },
        },
        children: "text",
      },
    ],
  };
  // 渲染 vnode
  renderer.render(vnode, document.querySelector("#app"));
});
  1. 在首次渲染完成之后,由于 bol.value 的值为 false,所以渲染器并不会为 div 元素绑定点击事件。当用鼠标点击 p 元素时,即使 click 事件可以从 p 元素冒泡到父级 div 元素,但由于 div 元素没有绑定 click 事件的事件处理函数,所以什么都不会发生。但父级 div 元素的点击事件处理函数执行了。
  2. 因为更新操作发生在事件冒泡之前,即为 div 元素绑定事件处理函数发生在事件冒泡之前
  3. 解决:屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行
js
 patchProps(el, key, prevValue, nextValue) {
   if (/^on/.test(key)) {
     const invokers = el._vei || (el._vei = {})
     let invoker = invokers[key]
     const name = key.slice(2).toLowerCase()
     if (nextValue) {
       if (!invoker) {
         invoker = el._vei[key] = (e) => {
           // e.timeStamp 是事件发生的时间
           // 如果事件发生的时间早于事件处理函数绑定的时间,则不执行事件处理函数
           if (e.timeStamp < invoker.attached) return
           if (Array.isArray(invoker.value)) {
             invoker.value.forEach(fn => fn(e))
           } else {
             invoker.value(e)
           }
         }
         invoker.value = nextValue
         // 添加 invoker.attached 属性,存储事件处理函数被绑定的时间
         invoker.attached = performance.now()
         el.addEventListener(name, invoker)
       } else {
         invoker.value = nextValue
       }
     } else if (invoker) {
       el.removeEventListener(name, invoker)
     }
   } else if (key === 'class') {
     // 省略部分代码
   } else if (shouldSetAsProps(el, key, nextValue)) {
     // 省略部分代码
   } else {
     // 省略部分代码
   }
 }

8.9  更新子节点

字节点的三种情况

html
<!-- 没有子节点 -->
<div></div>
<!-- 文本子节点 -->
<div>Some Text</div>
<!-- 多个子节点 -->
<div>
  <p />
  <p />
</div>
js
// 没有子节点
vnode = {
  type: "div",
  children: null,
};
// 文本子节点
vnode = {
  type: "div",
  children: "Some Text",
};
// 其他情况,子节点使用数组表示
vnode = {
  type: "div",
  children: [{ type: "p" }, "Some Text"],
};
js
function patchElement(n1, n2) {
  const el = (n2.el = n1.el);
  const oldProps = n1.props;
  const newProps = n2.props;
  // 第一步:更新 props
  for (const key in newProps) {
    if (newProps[key] !== oldProps[key]) {
      patchProps(el, key, oldProps[key], newProps[key]);
    }
  }
  for (const key in oldProps) {
    // 旧有新无
    if (!(key in newProps)) {
      patchProps(el, key, oldProps[key], null);
    }
  }

  // 第二步:更新 children
  patchChildren(n1, n2, el);
}

function patchChildren(n1, n2, container) {
  // 判断新子节点的类型是否是文本节点
  if (typeof n2.children === "string") {
    // 旧子节点的类型有三种可能:没有子节点、文本子节点以及一组子节点
    // 只有当旧子节点为一组子节点时,才需要逐个卸载,其他情况下什么都不需要做
    if (Array.isArray(n1.children)) {
      n1.children.forEach((c) => unmount(c));
    }
    // 最后将新的文本节点内容设置给容器元素
    setElementText(container, n2.children);
  } else if (Array.isArray(n2.children)) {
    // 说明新子节点是一组子节点

    // 判断旧子节点是否也是一组子节点
    if (Array.isArray(n1.children)) {
      // 代码运行到这里,则说明新旧子节点都是一组子节点,这里涉及核心的 Diff 算法
      // 将旧的一组子节点全部卸载
      n1.children.forEach((c) => unmount(c));
      // 再将新的一组子节点全部挂载到容器中
      n2.children.forEach((c) => patch(null, c, container));
    } else {
      // 此时:
      // 旧子节点要么是文本子节点,要么不存在
      // 但无论哪种情况,我们都只需要将容器清空,然后将新的一组子节点逐个挂载
      setElementText(container, "");
      n2.children.forEach((c) => patch(null, c, container));
    }
  } else {
    // 代码运行到这里,说明新子节点不存在
    // 旧子节点是一组子节点,只需逐个卸载即可
    if (Array.isArray(n1.children)) {
      n1.children.forEach((c) => unmount(c));
    } else if (typeof n1.children === "string") {
      // 旧子节点是文本子节点,清空内容即可
      setElementText(container, "");
    }
    // 如果也没有旧子节点,那么什么都不需要做
  }
}

8.10  文本节点和注释节点

注释节点与文本节点不同于普通标签节点,它们不具有标签名称,所以需要人为创造一些唯一的标识,并将其作为注释节点和文本节点的 type 属性值

js
// 文本节点的 type 标识
const Text = Symbol();
const newVNode = {
  // 描述文本节点
  type: Text,
  children: "我是文本内容",
};

// 注释节点的 type 标识
const Comment = Symbol();
const newVNode = {
  // 描述注释节点
  type: Comment,
  children: "我是注释内容",
};
js
function patch(n1, n2, container) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1);
    n1 = null;
  }

  const { type } = n2;

  if (typeof type === "string") {
    if (!n1) {
      mountElement(n2, container);
    } else {
      patchElement(n1, n2);
    }
  } else if (type === Text) {
    // 如果新 vnode 的类型是 Text,则说明该 vnode 描述的是文本节点
    // 如果没有旧节点,则进行挂载
    if (!n1) {
      // 使用 createTextNode 创建文本节点
      const el = (n2.el = document.createTextNode(n2.children));
      // 将文本节点插入到容器中
      insert(el, container);
    } else {
      // 如果旧 vnode 存在,只需要使用新文本节点的文本内容更新旧文本节点即可
      const el = (n2.el = n1.el);
      if (n2.children !== n1.children) {
        el.nodeValue = n2.children;
      }
    }
  }
}

createTextNodeel.nodeValue 这两个操作 DOM 的 API 封装到渲染器的选项中

js
const renderer = createRenderer({
  createElement(tag) {
    // 省略部分代码
  },
  setElementText(el, text) {
    // 省略部分代码
  },
  insert(el, parent, anchor = null) {
    // 省略部分代码
  },
  createText(text) {
    return document.createTextNode(text);
  },
  setText(el, text) {
    el.nodeValue = text;
  },
  patchProps(el, key, prevValue, nextValue) {
    // 省略部分代码
  },
});

function patch(n1, n2, container) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1);
    n1 = null;
  }

  const { type } = n2;

  if (typeof type === "string") {
    if (!n1) {
      mountElement(n2, container);
    } else {
      patchElement(n1, n2);
    }
  } else if (type === Text) {
    if (!n1) {
      // 调用 createText 函数创建文本节点
      const el = (n2.el = createText(n2.children));
      insert(el, container);
    } else {
      const el = (n2.el = n1.el);
      if (n2.children !== n1.children) {
        // 调用 setText 函数更新文本节点的内容
        setText(el, n2.children);
      }
    }
  }
}

8.11   Fragment

Fragment 本身并不渲染任何内容,只需要处理它的子节点

js
const Fragment = Symbol();
const vnode = {
  type: Fragment,
  children: [
    { type: "li", children: "1" },
    { type: "li", children: "2" },
    { type: "li", children: "3" },
  ],
};

function patch(n1, n2, container) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1);
    n1 = null;
  }

  const { type } = n2;

  if (typeof type === "string") {
    // 省略部分代码
  } else if (type === Text) {
    // 省略部分代码
  } else if (type === Fragment) {
    // 处理 Fragment 类型的 vnode
    if (!n1) {
      // 如果旧 vnode 不存在,则只需要将 Fragment 的 children 逐个挂载即可
      n2.children.forEach((c) => patch(null, c, container));
    } else {
      // 如果旧 vnode 存在,则只需要更新 Fragment 的 children 即可
      patchChildren(n1, n2, container);
    }
  }
}

unmount 函数也需要支持 Fragment 类型的虚拟节点的卸载

js
function unmount(vnode) {
  // 在卸载时,如果卸载的 vnode 类型为 Fragment,则需要卸载其 children
  if (vnode.type === Fragment) {
    vnode.children.forEach((c) => unmount(c));
    return;
  }
  const parent = vnode.el.parentNode;
  if (parent) {
    parent.removeChild(vnode.el);
  }
}

上次更新于: