Virtual DOM 引发一些思考

前言

早在以前了解 VueReact 渲染页面的原理,俩者基本都是使用 Virtual DOM 技术来更新页面内容。但当时也没有进行深入了解,正好这段时间关注下,看了一些资料后记下来,以便以后用到。这边就不关注俩个框架具体实现,只关注于 Virtual DOM 的实现原理,为了下文方便,直接把 Virtual DOM 叫做虚拟节点

虚拟节点是什么

一个技术或者设计思想的出现一般来说是解决问题,那么虚拟节点这个技术解决痛点是页面渲染性能问题,至于为什么会出现性能问题下面会提到。

那么这里的提到的虚拟节点和浏览器的文档对象模型(DOM)有什么区别?浏览器的 DOM 就是从 HTML 渲染的节点,包含一些节点操作方法,例如 getElementById 相关方法。像以前前端 MVP/MVP 时代基本直接用节点操作方法或者第三方类库例如 jQuery 完成对节点渲染操作。

近来大数据时代来临,上面提到的架构设计模式无法满足现在的需求,例如接受几千条数据要更新前端内容,直接渲染节点会出现卡顿(直接卡死、白屏等)的问题,所以也就现在的 MVVM 时代来临,而其中核心的虚拟节点就是解决这个问题,来避免浏览器节点操作低性能操作的问题。

那么问题来了,虚拟节点到底是什么?你完全可以把它当做是只存在内存的 JavaScript 对象,它可以和浏览器节点完全同步,如果局部发生变化,那么就只更新浏览器节点局部节点而不是更新整个浏览器节点。如果不了解虚拟节点具体实现,可以参考下面的例子:

{
  tag: 'div',
  attr: {
    id: 'app',
    class: 'app-area'
  },
  child: [
    {
      tag: 'h1',
      attr: {
        class: 'title'
      }
    },
    {
      tag: 'h2',
      attr: {
        class: 'desc'
      }
    }
  ]
}

然后再经过特定的方法将它转换浏览器所需要的真实的节点,所以上面的例子就是所谓的虚拟节点。

实现虚拟节点

VueReact 框架中都有自己方法可以实现自己想要的虚拟节点,这边就参考俩者方法,我们自己写一个方法来生成虚拟节点:

const m = (...args)=>{
    // 从 args 解构获取标签,属性以及class类名相关数据
    let [attrs, [head, ...tail]] = [{}, args]
    let [tag, ...classes] = head.split('.')
    if (tail.length && !m.isRenderable(tail[0])) [attrs, ...tail] = tail
    if (attrs.class) classes = [...classes, ...attrs.class]
    // 删除无用的数据
    attrs = {...attrs}; delete attrs.class
    // 递归数据得到子节点数据对象
    const children = []
    const addChildren = v=>v === null? null : Array.isArray(v)? v.map(addChildren) : children.push(v)
    addChildren(tail)
    return {__m: true, tag: tag || 'div', attrs, classes, children}
}

// 校验数据类型
// 允许节点渲染
// null
// 字符串
// 数字
// 虚拟节点
// 数组和上面数据类型数据组成的数组
m.isRenderable = v =>v === null || ['string', 'number'].includes(typeof v) || v.__m || Array.isArray(v)

根据上面的函数,假使传入:

m(
  'h1.title.desc',
  { onclick: f },
  'text',
  otherNode,
  null,
  [['nested'], 'stuff']
  )

那么返回得到的虚拟节点如下:

{
  __m: true,
  tag: 'h1',
  attrs: { onclick: f },
  classes: ['title', 'desc'],
  children: [
    'text',
    otherNode,
    'nested',
    'stuff'
  ]
}

得到上面的数据后下面再把虚拟节点的内容解析成真实节点。

渲染真实节点

实现渲染节点主要在于要实现一个 Diff 算法,来计算出节点之间的差异然后将把差异反映到真实节点上:

  • 根据索引和旧元素进行匹配,如果没有则新建一个新的元素
  • 没有匹配到任何元素,则在父元素追加元素
  • 如果标签名称不匹配,则需要新建新的元素并且替换父元素的匹配项
  • 更新元素上的所有类,属性等
  • 如果有 Chindren 进行递归继续执行上述操作
m.update = (el, v) => {
    // 判断文本数据
    if (!v.__m) return el.data === `${v}` || (el.data = v)
    // 设置节点类名
    for (const name of v.classes) if (!el.classList.contains(name)) el.classList.add(name)
    for (const name of el.classList) if (!v.classes.includes(name)) el.classList.remove(name)
    // 设置节点属性
    for (const name of Object.keys(v.attrs)) if (el[name] !== v.attrs[name]) el[name] = v.attrs[name]
    for (const { name } of el.attributes) if (!Object.keys(v.attrs).includes(name) && name !== 'class') el.removeAttribute(name)
}

// 挂载节点
m.makeEl = v=>v.__m? document.createElement(v.tag) : document.createTextNode(v)

m.render = (parent, v) => {
    // 真实节点元素
    const olds = parent.childNodes || []
    // 虚拟节点元素
    const news = v.children || []
    // 比较节点 ElementNode 对象长度进行操作
    for (const _ of Array(Math.max(0, olds.length - news.length))) parent.removeChild(parent.lastChild)
    // 更新节点元素
    for (const [i, child] of news.entries()) {
        let el = olds[i] || m.makeEl(child)
        if (!olds[i]) parent.appendChild(el)
        const mismatch = (el.tagName || '') !== (child.tag || '').toUpperCase()
        if (mismatch) (el = m.makeEl(child)) && parent.replaceChild(el, olds[i])
        m.update(el, child)
        m.render(el, child)
    }
}

然后根据上面封装的代码简单实现一个日历案例:

const months = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
const days = ['星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日']
const now = new Date()
function firstDayOfMonth(year, month) {
    const date = new Date(year, month, 1)
    return (date.getDay() + 6) % 7
}
function lengthOfMonth(year, month) {
    const date = new Date(year, (month + 1) % 12, 0)
    return date.getDate()
}
function toString(year, month, date) {
    return `${year}-${(month + 1).toString().padStart(2, '0')}-${date.toString().padStart(2, '0')}`
}
function getRows(year, month) {
    let dayI = firstDayOfMonth(year, month)
    let rowI = 0
    const rows = [0, 1, 2, 3, 4, 6].map(_ => days.map(_ => false))
    for (let i in [...Array(lengthOfMonth(year, month))]) {
        rows[rowI][dayI] = parseInt(i) + 1
        if (dayI === 6) { dayI = 0; rowI += 1 }
        else { dayI += 1 }
    }
    return rows
}

const state = { picked: {}, year: now.getFullYear(), month: now.getMonth() }
function incMonth() {
    if (state.month == 11) { state.month = 0; state.year += 1 }
    else { state.month += 1 }
    renderCalendar()
}
function decMonth() {
    if (state.month === 0) { state.month = 11; state.year -= 1 }
    else { state.month -= 1 }
    renderCalendar()
}
function toggle(year, month, date) {
    const dateStr = toString(year, month, date)
    if (state.picked[dateStr]) { delete state.picked[dateStr] }
    else { state.picked[dateStr] = true }
    renderCalendar()
}

const DateButton = date => m('button.date-button',
    {
        class: state.picked[toString(state.year, state.month, date)] ? ['date-button-picked'] : [],
        onclick: _ => toggle(state.year, state.month, date),
    },
    m('span',
        { class: (state.month === now.getMonth() && date === now.getDate()) ? ['date-button-now'] : [] },
        date,
    ),
)
const Month = () => m('.month',
    months[state.month], ' ', state.year,
)
const Cal = () => m('',
    Month(),
    m('.arrows',
        m('button.day-button', { onclick: decMonth }, '⟵'),
        m('button.day-button', { onclick: incMonth }, '⟶')),
    m('table.calendar',
        m('thead', days.map(day => m('th.day', day))),
        m('tbody', getRows(state.year, state.month).map(row => m('tr', row.map(
            date => m('td', date ? DateButton(date) : null)))))),
    m('ul', Object.keys(state.picked).sort().map(k => state.picked[k] ? m('li', k) : null)),
)
const renderCalendar = () => m.render(document.getElementById('calendar'), { children: [Cal()] })
renderCalendar()

后语 & 参考资料

VueReact 组件写法包括现在主流的 JSX 最终会生产虚拟节点,然后再生成真实节点,至于中间运行那是作者们的想法了,所以多少可以做出属于自己的渐变式框架。

文中如有不正确之处还望指出,谢谢观赏~

参考资料如下: