Virtual DOM 引发一些思考
前言
早在以前了解 Vue
和 React
渲染页面的原理,俩者基本都是使用 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'
}
}
]
}
然后再经过特定的方法将它转换浏览器所需要的真实的节点,所以上面的例子就是所谓的虚拟节点。
实现虚拟节点
在 Vue
和 React
框架中都有自己方法可以实现自己想要的虚拟节点,这边就参考俩者方法,我们自己写一个方法来生成虚拟节点:
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()
后语 & 参考资料
像 Vue
和 React
组件写法包括现在主流的 JSX
最终会生产虚拟节点,然后再生成真实节点,至于中间运行那是作者们的想法了,所以多少可以做出属于自己的渐变式框架。
文中如有不正确之处还望指出,谢谢观赏~
参考资料如下: