18.2 将虚拟 DOM 渲染为 HTML 字符串
同构渲染在服务端不需要真实 DOM,只需把标签类型的 vnode 递归拼接成 HTML 字符串;难点全在边界处理:void element、属性合法性、HTML 转义。
一、最小实现:拼接标签
给一个标签类型的 vnode:
const ElementVNode = {
type: 'div',
props: { id: 'foo' },
children: [
{ type: 'p', children: 'hello' }
]
}
不考虑任何边界条件时,直接按「开始标签 → 属性 → 子节点 → 结束标签」拼接:
function renderElementVNode(vnode) {
const { type: tag, props, children } = vnode
// 开始标签的头部
let ret = `<${tag}`
// 处理标签属性
if (props) {
for (const k in props) {
// 以 key="value" 的形式拼接字符串
ret += ` ${k}="${props[k]}"`
}
}
ret += `>`
// 处理子节点
if (typeof children === 'string') {
// 文本子节点,直接拼接
ret += children
} else if (Array.isArray(children)) {
// 一组子节点,递归渲染
children.forEach(child => {
ret += renderElementVNode(child)
})
}
ret += `</${tag}>`
return ret
}
这版实现只演示核心原理,不满足生产要求,缺陷有四类:自闭合标签、属性名称合法性与属性值转义、子节点类型多样(Fragment / 组件 / 函数式组件 / 文本)、文本子节点也需要转义。
二、处理 void element(自闭合标签)
像 area、br、img、input 这类标签在 HTML 中不允许有子节点,也没有结束标签,统称 void element:渲染时自闭合、直接返回,不再处理 children。
const VOID_TAGS = 'area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr'.split(',')
// 判断是否是 void element
const isVoidElement = VOID_TAGS.includes(tag)
// 是 void element 就自闭合
ret += isVoidElement ? `/>` : `>`
// 没有 children,无需继续处理
if (isVoidElement) return ret
三、严谨地处理属性
属性不能只做 key="value" 的拼接,需要分三类处理。
1. 忽略不该渲染的属性
vnode 的 props 里往往混着仅供运行时使用的字段,服务端渲染必须忽略:
key:只服务于虚拟 DOM 的 Diff 算法;ref:只用于实现 template ref;- 以
on+ 非小写字母开头的属性(onClick等):事件,服务端渲染不考虑事件绑定。
const shouldIgnoreProp = ['key', 'ref']
// 事件或应忽略的属性,跳过
if (shouldIgnoreProp.includes(key) || /^on[^a-z]/.test(key)) continue
2. boolean attribute
checked、disabled 这类 boolean attribute 的渲染规则与普通属性不同:
| 属性值 | 渲染结果 |
|---|---|
false | 什么都不渲染 |
其他值(含 true) | 只渲染属性名,如 disabled |
3. 属性名称的安全校验
属性名称必须由一个或多个「非下述字符」组成,否则不安全且不合法:
- 控制字符(control character):码点
[0x01, 0x1f]与[0x7f, 0x9f]; U+0020(SPACE)、U+0022(")、U+0027(')、U+003E(>)、U+002F(/)、U+003D(=);- noncharacters:Unicode 永久保留的码点,范围
[0xFDD0, 0xFDEF],以及0xFFFE、0xFFFF、0x1FFFE、0x1FFFF、0x2FFFE、0x2FFFF… 等。
模板编译器在编译阶段已经处理过 noncharacters 与控制字符,所以运行时只需小范围校验。
// 属性名称是否合法且安全
const isSSRSafeAttrName = key => !/[>/="'\u0009\u000a\u000c\u0020]/.test(key)
4. 属性的统一渲染
class / style 在 vnode 中可能用数组、对象等结构表示(如 class: ['foo', 'bar']),序列化成字符串表示后再走通用逻辑即可。
const isBooleanAttr = key =>
(`itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly` +
`,async,autofocus,autoplay,controls,default,defer,disabled,hidden,` +
`loop,open,required,reversed,scoped,seamless,` +
`checked,muted,multiple,selected`).split(',').includes(key)
function renderDynamicAttr(key, value) {
if (isBooleanAttr(key)) {
// boolean attribute:false 不渲染,否则只渲染属性名
return value === false ? `` : ` ${key}`
} else if (isSSRSafeAttrName(key)) {
// 安全属性:属性值必须做 HTML 转义
return value === '' ? ` ${key}` : ` ${key}="${escapeHtml(value)}"`
} else {
// 不安全属性:跳过并告警
console.warn(
`[@vue/server-renderer] Skipped rendering unsafe attribute name: ${key}`
)
return ``
}
}
function renderAttrs(props) {
let ret = ''
for (const key in props) {
// 忽略事件与运行时专用属性
if (shouldIgnoreProp.includes(key) || /^on[^a-z]/.test(key)) continue
ret += renderDynamicAttr(key, props[key])
}
return ret
}
四、HTML 转义
转义就是把特殊字符换成对应的 HTML 实体,这是防御 XSS 攻击的关键。
| 场景 | 需转义字符 | 实体 |
|---|---|---|
| 普通文本内容 | & | & |
| 普通文本内容 | < | < |
| 普通文本内容 | > | > |
| 属性值 | " | " |
| 属性值 | ' | ' |
属性值除了前三个,还要额外转义
"与',否则可能被拼出新的属性。
const escapeRE = /["'&<>]/
function escapeHtml(string) {
const str = '' + string
const match = escapeRE.exec(str)
if (!match) return str
let html = ''
let escaped
let index
let lastIndex = 0
for (index = match.index; index < str.length; index++) {
switch (str.charCodeAt(index)) {
case 34: escaped = '"'; break // "
case 38: escaped = '&'; break // &
case 39: escaped = '''; break // '
case 60: escaped = '<'; break // <
case 62: escaped = '>'; break // >
default: continue
}
if (lastIndex !== index) {
html += str.substring(lastIndex, index)
}
lastIndex = index + 1
html += escaped
}
return lastIndex !== index ? html + str.substring(lastIndex, index) : html
}
五、整合后的 renderElementVNode
function renderElementVNode(vnode) {
const { type: tag, props, children } = vnode
const isVoidElement = VOID_TAGS.includes(tag)
let ret = `<${tag}`
if (props) {
// 属性统一交给 renderAttrs 严谨处理
ret += renderAttrs(props)
}
ret += isVoidElement ? `/>` : `>`
if (isVoidElement) return ret
if (typeof children === 'string') {
// 文本子节点同样需要转义
ret += escapeHtml(children)
} else if (Array.isArray(children)) {
children.forEach(child => {
ret += renderElementVNode(child)
})
}
ret += `</${tag}>`
return ret
}
该函数只处理标签类型的 vnode,子节点里一旦出现组件类型就无法继续,需要由统一的分发函数接手。
相关
- 18.3 将组件渲染为 HTML 字符串:向上补齐一层,让组件类型的 vnode 也能被渲染为字符串