Skip to content

第八章:挂载与更新

挂载子节点和元素的属性

  1. 遍历 vnode 上的 props 完成属性的挂载
  2. 递归的处理 children,完成子节点的挂载
javascript
const vnode = {
  type: 'div',
  props: {
    id: 'foo'
  },
  children: [
    {
      type: 'p',
      children: 'hello'
    }
  ]
};

function mountElement(vnode, container) {
  const el = document.createElement(vnode.type);

  if (vnode.props) {
    for (const k in vnode.props) {
      // 👇 等同 => el.setAttribute(k, vnode.props[k])
      el[k] = vnode.props[k];
    }
  }

  if (typeof vnode.children === 'string') {
    el.textContent = vnode.children;
  } else if (Array.isArray(vnode.children)) {
    vnode.children.forEach(child => {
      // 因为是第一次挂载,旧节点仍然传递 null。 挂载的元素是新创建出来的 el
      patch(null, child, el);
    });
  }

  container.appendChild(el);
}

HTML Attributes 和 DOM Properties

HTML Attributes: <div id='foo'>

DOM Properties:

javascript
const el = document.querySelector('#foo');
el.id = 'xxx';

TIP

HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。因此后面设置值时应优先使用 DOM Properties。

javascript
// 一个 input 标签 <input value="foo" />
const el = document.querySelector('input');
el.value; // foo
el.getAttribute('value'); // foo
// -------- 将 value 修改为 bar -------------
el.value; // bar
el.getAttribute('value'); // foo
el.defaultValue; // foo

一些特殊处理

我们写两个禁用按钮的 dom

HTML
<button disable> buttonA </button>
<button :disable="false"> buttonB </button>

在 vue 模版编译后输出的 vnode

javascript
const buttonA = {
  type: 'button',
  props: { disable: '' }
};

const buttonB = {
  type: 'button',
  props: { disable: false }
};

render 时

javascript
//  -------------- 执行标签设置 ---------------
el.setAttribute('disable', ''); // 正常
// 设置属性时,总是会被 stringify,在 dom 处理时,再把 'false' => true,这样造成了错误
el.setAttribute('disable', 'false');

//  -------------- 执行属性设置 ---------------
el.disable = ''; // '' => false,引起错误
el.disable = false; // 正常

可以发现,无论使用哪种方式都有可能出现问题,对于这种情况只能做特殊处理。

javascript
function mountElement(vnode, container) {
  const el = document.createElement(vnode.type);
  if (vnode.props) {
    for (const k in vnode.props) {
      // 属性是否存在于 DOM Properties
      if (k in el) {
        // 取出数据类型方便后面特殊处理
        const type = typeof el[k];
        const value = vnode.props[k];
        // 特殊处理(button 的 disable)
        if (type === 'boolean' && value === '') {
          el[k] = true;
        } else {
          el[k] = value;
        }
      } else {
        el.setAttribute(k, vnode.props[k]);
      }
    }
  }
  /** ... */
}

有一些属性是只读的,不能对其修改,也需要特殊处理。

html
<form id="form1"></form>
<input form="form1" />
javascript
function shouldSetAsProps(el, key, val) {
  // 特殊处理
  if (key === 'form' && el.tagName === 'INPUT') return false;
  // 兜底
  return key in el;
}

function mountElement(vnode, container) {
  const el = document.createElement(vnode.type);
  if (vnode.props) {
    for (const k in vnode.props) {
      // 属性是否存在于 DOM Properties
      if (shouldSetAsProps(el, k, vnode.props[k])) {
        /** ... */
      } else {
        el.setAttribute(k, vnode.props[k]);
      }
    }
  }
  /** ... */
}

class、style 的特殊处理(增强)

为了方便开发,在 Vue 中使用 class、style 可以使用多种方式

html
<!-- 字符串 -->
<div class="foo bar"></div>
<!-- 对象 -->
<div :class="{foo: true}"></div>
<!-- 数组 -->
<div :class="[{foo: true}, 'bar']"></div>
javascript
const vnode = {
  type: 'div',
  props: {
    class: normalizeClass([{ foo: true }, 'bar']) // 序列化的结果 => 'foo bar'
  }
};

class 有多种设置方式,在设置 1000 次时的性能: el.className > el.setAttribute > classList, 因此 Vue 中使用了 className 的方式

卸载操作

javascript
function render(vnode, container) {
  if (vnode) {
    // 新 vnode 存在,将其与旧 vnode 一起传递给 patch 函数,进行打补丁
    patch(container._vnode, vnode, container);
  } else {
    if (container._vnode) {
      // 旧 vnode 存在,新的不存在,说明是卸载操作 - unmount
      container.innerHTML = '';
    }
  }
  container._vnode = vnode;
}

在之前我们是通过 container.innerHTML = ''; 的方式完成组件卸载,但是这样做存在几个问题:

  • 容器执行卸载时,应调用组件对应的 beforeUnmount、unmounted 生命周期
  • 有的元素存在自定义指令,应调用对应指令的钩子函数
  • innerHTML 不会移除绑定在 DOM 上的事件

正确的卸载方式:根据 vnode 获取与其相关联的真实 DOM,通过原生方法将元素移除。

javascript
function mountElement(vnode, container) {
  const el = document.createElement(vnode.type);
  vnode.el = el; // 将真实 DOM 挂载到 vnode 节点,方便后面使用
  /** ... */
}

function unmount(vnode){
  /** 有了虚拟节点,我们可以调用指令钩子和组件的生命周期了 */
  const parent = vnode.el.parentNode;
  if(parent){
    parent.removeChild(vnode.el)
  }
}

function render(vnode, container) {
  if (vnode) {
    // 新 vnode 存在,将其与旧 vnode 一起传递给 patch 函数,进行打补丁
    patch(container._vnode, vnode, container);
  } else {
    if (container._vnode) {
      unmount(container._vnode)
    }
  }
  container._vnode = vnode;
}

事件的处理

在绑定事件时,我们绑定一个事件处理函数 invoker,然后把真正事件处理的函数设置为 invoker.value,这样更新时就不用调用 removeEventListener 来移除上次绑定的事件,只需要更新 invoker.value的值即可。

vei:vue event invoker

javascript
function patchProps(el, key, pervValue, nextValue) {
  if (/^on/.text(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 => {
          invoker.value(e);
        };
        invoker.value = nextValue;
        el.addEventListener(name, invoker);
      } else {
        invoker.value = nextValue;
      }
    } else if (invoker) {
      el.removeEventListener(name, invoker);
    }
  }
}

更新子节点

html
<!-- 没有子节点:vnode.children: null -->
<div></div>
<!-- 文本子节点:vnode.children: 'Text'(文本内容字符串) -->
<div>Text</div>
<!-- 多个子节点:vnode.children: [](多个子节点数组)-->
<div>
  <p />
  <p />
</div>
javascript
// n1 是 old vnode
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);
    }
  }
  // 更新子节点
  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 算法 */
    } 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, '');
    }
  }
}

处理文本节点、注释节点

vnode.type 代表了节点的类型,如果值为字符串就代表其为普通标签,那么注释节点、文本节点怎么表示呢?

html
<div>
  <!-- 我是注释 -->
  我是文本
</div>

Symbol() 创建一个唯一标识,渲染时通过判断是否等于 TextComment 来做特殊处理

javascript
const Comment = Symbol();
const vnode = {
  type: Comment,
  children: '我是注释'
};

const Text = Symbol();
const vnode = {
  type: Text,
  children: '我是文本'
};

Fragment

用来解决 vue 模版中只能存在一个 Root 标签的问题。

vue
<template>
  <li>1</li>
  <li>2</li>
  <li>3</li>
</template>
javascript
const Fragment = Symbol();
const vnode = {
  type: Fragment,
  children: [
    {type: 'li', children: '1'},
    {type: 'li', children: '2'}.
    {type: 'li', children: '3'}
  ]
};
javascript
function patch(n1, n2, container) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1);
    n1 = null;
  }
  const { type } = n2;
  if (type === 'string') {
    /** 执行普通的标签 patch */
  } else if (type === Text) {
    /** 处理文本节点 */
  } else if (type === Fragment) {
    // Fragment 本身是不需要渲染的 所以只处理 children
    if (!n1) {
      n2.children.forEach(c => patch(null, c, container));
    } else {
      patchChildren(n1, n2, container);
    }
  }
}

// 卸载时也要处理
function unmount(vnode) {
  if (vnode.type === Fragment) {
    vnode.children.forEach(c => unmount(c));
    return;
  }

  /** 有了虚拟节点,我们可以调用指令钩子和组件的生命周期了 */
  const parent = vnode.el.parentNode;
  if (parent) {
    parent.removeChild(vnode.el);
  }
}