Skip to content

渲染器核心功能

面试题:说一说渲染器的核心功能是什么?

渲染器的核心功能,是根据拿到的 vnode,进行节点的挂载更新

挂载属性

vnode:

js
const vnode = {
  type: 'div',
  // props 对应的就是节点的属性
  props: {
    id: 'foo'
  },
  children: [
    type: 'p',
    children: 'hello'
  ]
}

渲染器内部有一个 mountElement 方法:

js
function mountElement(vnode, container){
  // 根据节点类型创建对应的DOM节点
  const el = document.createElement(vnode.type);
  
  // 省略children的处理
  
  // 对属性的处理
  if(vnode.props){
    for(const key in vnode.props){
      el.setAttribute(key, vnode.props[key])
    }
  }
  
  insert(el, container);
}

除了使用setAttribute方法来设置属性以外,也可以使用DOM对象的方式:

js
if(vnode.props){
  for(const key in vnode.props){
    // el.setAttribute(key, vnode.props[key])
    el[key] = vnode.props[key];
  }
}

思考🤔:哪种设置方法好?两种设置方法有区别吗?应该使用哪种来设置?

HTML Attributes

Attributes 是元素的初始属性值,在 HTML 标签中定义,用于描述元素的初始状态

  • 在元素被解析的时候,只会初始化一次
  • 只能是字符串值,而且这个值仅代表初始的状态,无法反应运行时的变化
vue
<input type="text" id="username" value="John">

DOM Properties

Properties 是 JavaScript 对象上的属性,代表了 DOM 元素在 内存中 的实际状态。

  • 反应的是 DOM 元素的当前状态
  • 属性类型可以是字符串、数字、布尔值、对象之类的

很多 HTML attributes 在 DOM 对象上有与之相同的 DOM Properties,例如:

HTML attributesDOM properties
id="username"el.id
type="text"el.type
value="John"el.value

但是,两者并不总是相等的,例如:

HTML attributesDOM properties
class="foo"el.className

还有很多其他的情况:

  • HTML attributes 有但是 DOM properties 没有的属性:例如 aria-* 之类的HTML Attributes
  • DOM properties 有但是 HTML attributes 没有的属性:例如 el.textContent
  • 一个 HTML attributes 关联多个 DOM properties 的情况:例如 value="xxx" 和 el.value 以及 el.defaultValue 都有关联

另外,在设置的时候,不是单纯的用某一种方式,而是两种方式结合使用。因为需要考虑很多特殊情况:

  1. disabled
  2. 只读属性

1. disabled

模板:我们想要渲染的按钮是非禁用状态

vue
<button :disabled="false">Button</button>

vnode:

js
const vnode = {
  type: 'button',
  props: {
    disable: false
  }
}

通过 el.setAttribute 方法来进行设置会遇到的问题:最终渲染出来的按钮就是禁用状态

js
 el.setAttribute('disabled', 'false')

解决方案:优先设置 DOM Properties

遇到新的问题:本意是要禁用按钮

vue
<button disabled>Button</button>
js
const vnode = {
  type: 'button',
  props: {
    disable: ''
  }
}
js
el.disabled = ''

在对 DOM 的 disabled 属性设置值的时候,任何非布尔类型的值都会被转为布尔类型:

js
el.disabled = false

最终渲染出来的按钮是非禁用状态。

渲染器内部的实现,不是单独用 HTML Attribute 或者 DOM Properties,而是两者结合起来使用,并且还会考虑很多的细节以及特殊情况,针对特殊情况做特殊处理

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

  if (vnode.props) {
    for (const key in vnode.props) {
      // 用 in 操作符判断 key 是否存在对应的 DOM Properties
      if (key in el) {
        // 获取该 DOM Properties 的类型
        const type = typeof el[key];
        const value = vnode.props[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);
}

2. 只读属性

vue
<input form="form1"/>

例如 el.form,但是这个属性是只读的,所以这种情况,又只能使用 setAttribute 方法来设置

js
function shouldSetAsProps(el, key, value) {
  // 特殊处理
  // 遇到其他特殊情况再进行重构
  if (key === "form" && el.tagName === "INPUT") return false;
  // 兜底
  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];

      if (shouldSetAsProps(el, key, value)) {
        const type = typeof el[key];
        if (type === "boolean" && value === "") {
          el[key] = true;
        } else {
          el[key] = value;
        }
      } else {
        el.setAttribute(key, value);
      }
    }
  }
  insert(el, container);
}

shouldSetAsProps 这个方法返回一个布尔值,由布尔值来决定是否使用 DOM Properties 来设置。

还可以进一步优化,将属性的设置提取出来:

js
function shouldSetAsProps(el, key, value) {
  // 特殊处理
  if (key === "form" && el.tagName === "INPUT") return false;
  // 兜底
  return key in el;
}

/**
 *
 * @param {*} el 元素
 * @param {*} key 属性
 * @param {*} prevValue 旧值
 * @param {*} nextValue 新值
 */
function 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);
  // 省略 children 的处理

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

class处理

class 本质上也是属性的一种,但是在 Vue 中针对 class 做了增强,因此 Vue 模板中的 class 的值可能会有这么一些情况:

情况一:字符串值

vue
<template>
	<p class="foo bar"></p>
</template>
js
const vnode = {
  type: "p",
  props: {
    class: "foo bar",
  },
};

情况二:对象值

vue
<template>
	<p :class="cls"></p>
</template>
<script setup>
import { ref } from 'vue'
const cls = ref({
  foo: true,
  bar: false
})
</script>
js
const vnode = {
  type: "p",
  props: {
    class: { foo: true, bar: false },
  },
};

情况三:数组值

vue
<template>
	<p :class="arr"></p>
</template>
<script setup>
import { ref } from 'vue'
const arr = ref([
  'foo bar',
  {
    baz: true
  }
])
</script>
js
const vnode = {
  type: "p",
  props: {
    class: ["foo bar", { baz: true }],
  },
};

这里首先第一步就是需要做参数归一化,统一成字符串类型。Vue内部有一个方法 normalizeClass 就是做 class 的参数归一化的。

js
function isString(value) {
  return typeof value === "string";
}

function isArray(value) {
  return Array.isArray(value);
}

function isObject(value) {
  return value !== null && typeof value === "object";
}

function normalizeClass(value) {
  let res = "";
  if (isString(value)) {
    res = value;
  } else if (isArray(value)) {
    // 如果是数组,递归调用 normalizeClass
    for (let i = 0; i < value.length; i++) {
      const normalized = normalizeClass(value[i]);
      if (normalized) {
        res += (res ? " " : "") + normalized;
      }
    }
  } else if (isObject(value)) {
    // 如果是对象,则检查每个 key 是否为真值
    for (const name in value) {
      if (value[name]) {
        res += (res ? " " : "") + name;
      }
    }
  }
  return res;
}

console.log(normalizeClass("foo")); // 'foo'
console.log(normalizeClass(["foo", "bar"])); // 'foo bar'
console.log(normalizeClass({ foo: true, bar: false })); // 'foo'
console.log(normalizeClass(["foo", { bar: true }])); // 'foo bar'
console.log(normalizeClass(["foo", ["bar", "baz"]])); // 'foo bar baz'
js
const vnode = {
  type: "p",
  props: {
    class: normalizeClass(["foo bar", { baz: true }]),
  },
};
js
const vnode = {
  type: "p",
  props: {
    class: 'foo bar baz',
  },
};

设置class的时候,设置方法也有多种:

  1. setAttribute
  2. el.className:这种方式效率是最高的
  3. el.classList
js
function 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);
  }
}

子节点的挂载

除了对自身节点的处理,还需要对子节点进行处理,不过处理子节点时涉及到 diff 计算。

js
function mountElement(vnode, container) {
  const el = createElement(vnode.type);
  
  // 针对子节点进行处理
  if (typeof vnode.children === "string") {
    // 如果 children 是字符串,则直接将字符串插入到元素中
    setElementText(el, vnode.children);
  } else if (Array.isArray(vnode.children)) {
    // 如果 children 是数组,则遍历每一个子节点,并调用 patch 函数挂载它们
    vnode.children.forEach((child) => {
      patch(null, child, el);
    });
  }
  insert(el, container);
}

面试题:说一说渲染器的核心功能是什么?

参考答案:

渲染器最最核心的功能是处理从虚拟 DOM 到真实 DOM 的渲染过程,这个过程包含几个阶段:

  1. 挂载:初次渲染时,渲染器会将虚拟 DOM 转化为真实 DOM 并插入页面。它会根据虚拟节点树递归创建 DOM 元素并设置相关属性。
  2. 更新:当组件的状态或属性变化时,渲染器会计算新旧虚拟 DOM 的差异,并通过 Patch 过程最小化更新真实 DOM。
  3. 卸载:当组件被销毁时,渲染器需要将其从 DOM 中移除,并进行必要的清理工作。

每一个步骤都有大量需要考虑的细节,就拿挂载来讲,光是处理元素属性如何挂载就有很多需要考虑的问题,比如:

  1. 最终设置属性的时候是用 setAttribute 方法来设置,还是用给 DOM 对象属性赋值的方式来设置
  2. 遇到像 disabled 这样的特殊属性该如何处理
  3. class、style 这样的多值类型,该如何做参数的归一化,归一为哪种形式
  4. 像 class 这样的属性,设置的方式有哪种,哪一种效率高

另外,渲染器和响应式系统是紧密结合在一次的,当组件首次渲染的时候,组件里面的响应式数据会和渲染函数建立依赖关系,当响应式数据发生变化后,渲染函数会重新执行,生成新的虚拟 DOM 树,渲染器随即进入更新阶段,根据新旧两颗虚拟 DOM 树对比来最小化更新真实 DOM,这涉及到了 Vue 中的 diff 算法。diff 算法这一块儿,Vue2 采用的是双端 diff,Vue3 则是做了进一步的优化,采用的是快速 diff 算法。diff 这一块儿需要我展开说一下么?


-EOF-

Released under the MIT License.