今天解析 v-text、v-html、v-pre、v-once 等指令的底层实现原理,以具体的例子为出发点进行讲解。
v-text 的官方文档点击这里。
v-text 的底层实现原理是更新目标元素的 textContent 属性。
首先看下例子:
new Vue({
el: '#app',
data() {
return {
name: 'tom'
}
},
template: `
`
})
该例子渲染的页面如下所示:
解析出来的抽象语法树如下所示:
{
attrs: [{name: "id", value: "\"app\""}],
attrsList: [{name: "id", value: "app"}],
attrsMap: {
id: "app"
},
children: [
{
attrsList: [{name: "v-text", value: "name"}],
attrsMap: {v-text: "name"},
children: [],
directives: [
{name: "text", rawName: "v-text", value: "name", arg: null, modifiers: undefined}
],
hasBindings: true,
plain: false,
tag: "h1",
type: 1
}
],
parent: undefined,
plain: false,
tag: "div",
type: 1
}
解析出来的抽象语法树与 v-text 指令有关的内容是 h1 AST 节点中的 directives 数组中的一个对象。
directives: [
{name: "text", rawName: "v-text", value: "name", arg: null, modifiers: undefined}
],
该对象中的内容将用于 v-text 有关代码字符串的生成。
上面抽象语法树生成的代码字符串如下所示:
with(this){
return _c(
'div',
{attrs:{"id":"app"}},
[_c(
'h1',
{domProps:{"textContent":_s(name)}}
)
]
)
}
与 v-text 有关的代码字符串是:
{domProps:{"textContent":_s(name)}}
生成这段代码字符串的 Vue 源码在 genData() 中。
export function genData (el: ASTElement, state: CodegenState): string {
let data = '{'
// 为 el 添加 props 属性,值是 [{name: "textContent", value: "_s(name)"}]
const dirs = genDirectives(el, state)
if (el.props) {
// 如果 el 有 props 属性的话,拼接 domProps:{"textContent":_s(name)} 代码字符串
data += `domProps:{${genProps(el.props)}},`
}
return data
}
生成的简要 vnode 如下所示:
{
children: [{
data: {
domProps: {textContent: "tom"}
},
tag: "h1"
}],
data: {
attrs: {id: "app"}
},
isRootInsert: true,
tag: "div"
}
与 v-text 有关的属性是:
data: {
domProps: {textContent: "tom"}
}
我们知道,vnode 是 render 函数结合当前的状态所生成的,所以,如上面所示,_s(name) 已经被替换成了 "tom" 字符串。
接下来看看 domProps: {textContent: "tom"} 是如何渲染到页面上的。
function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested) {
// data == { domProps: {textContent: "tom"} }
const data = vnode.data
// 创建 h1 AST 节点的真实 DOM,并将其赋值到 vnode.elm 上
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
if (isDef(data)) {
// 调用上一篇博客中说的 cbs.create 函数数组中的函数
invokeCreateHooks(vnode, insertedVnodeQueue)
}
// 将 h1 真实 DOM 插入到父节点中
insert(parentElm, vnode.elm, refElm)
}
cbs.create 是一个数组,数组中的内容是一个个的函数,具体内容如下图所示:
invokeCreateHooks 函数的作用就是遍历触发执行 cbs.create 数组中的函数。在这里,与本节内容有关的是数组中的第四个函数,他的作用是处理 DOM 的 props。
function invokeCreateHooks (vnode, insertedVnodeQueue) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, vnode)
}
i = vnode.data.hook // Reuse variable
if (isDef(i)) {
if (isDef(i.create)) i.create(emptyNode, vnode)
if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
}
}
function updateDOMProps (oldVnode: VNodeWithData, vnode: VNodeWithData) {
if (isUndef(oldVnode.data.domProps) && isUndef(vnode.data.domProps)) {
return
}
let key, cur
const elm: any = vnode.elm
const oldProps = oldVnode.data.domProps || {}
let props = vnode.data.domProps || {}
for (key in oldProps) {
if (isUndef(props[key])) {
elm[key] = ''
}
}
for (key in props) {
cur = props[key]
// key:"textContent"
// cur:"tom"
elm[key] = cur
}
}
该函数的最后,执行 elm[key] = cur,也就是执行 h1Element.textContent = "tom",将 "tom" 字符串设置到了 h1 元素的内部,实现目标。
v-html 的官方文档点击这里
v-html 的底层实现原理是更新目标元素的 innerHTML 属性,和 v-text 几乎一模一样,唯一的差别是 v-html 最终生成的 vnode 是 domProps: {innerHTML: "Hello span"},例如下面的模板字符串。
new Vue({
el: '#app',
data() {
return {
htmlContent: 'Hello span'
}
},
template: `
`
})
最终生成的 vnode 是:
{
children: [{
data: {
domProps: {innerHTML: "Hello span"}
},
tag: "h1"
}],
data: {
attrs: {id: "app"}
},
isRootInsert: true,
tag: "div"
}
v-html 将内容渲染到页面上的源码和 v-text 一样。
function updateDOMProps (oldVnode: VNodeWithData, vnode: VNodeWithData) {
if (isUndef(oldVnode.data.domProps) && isUndef(vnode.data.domProps)) {
return
}
let key, cur
const elm: any = vnode.elm
const oldProps = oldVnode.data.domProps || {}
let props = vnode.data.domProps || {}
for (key in oldProps) {
if (isUndef(props[key])) {
elm[key] = ''
}
}
for (key in props) {
cur = props[key]
// key:"innerHTML"
// cur:"Hello span"
elm[key] = cur
}
}
v-pre 的官方文档点击这里
v-pre 的作用是跳过这个元素和它的子元素的编译过程,底层实现的源码很简单,主要看解析器的处理。
在这里,分为三种情况进行讨论:
对应的源码是:src/compiler/parser/index.js >>> parse() >>> start(),源码解析都在注释中。
export function parse (
template: string,
options: CompilerOptions
): ASTElement | void {
let root
// inVPre 是一个重要的标志变量,如果它为 true 的话,
// 说明当前处理的节点使用了 v-pre,或者当前节点的父节点使用了 v-pre
let inVPre = false
// 调用 parseHTML 开始解析模板字符串
parseHTML(template, {
// 解析开始标签
start (tag, attrs, unary) {
// 此时 inVPre == false,所以需要先对 v-pre 进行解析,看看当前的元素有没有使用 v-pre
if (!inVPre) {
// 进行 v-pre 指令的解析
processPre(element)
// 如果 element.pre 为 true 的话,说明当前的节点使用了 v-pre 指令
// 此时需要将 inVPre 标识变量设置为 true,这很重要
if (element.pre) {
inVPre = true
}
}
// 接下来就用到 inVPre 变量了
// 如果 inVPre 为 true 的话,则当前元素开始标签上的特性就不用解析了
if (inVPre) {
processRawAttrs(element)
} else if (!element.processed) {
// 如果 inVPre 为 false 的话,则需要对开始标签上的 v-for、v-if、v-once 等内容进行解析
// 处理 v-for
processFor(element)
// 处理 v-if
processIf(element)
// 处理 v-once
processOnce(element)
// element-scope stuff
processElement(element, options)
}
}
})
return root
}
// 用于解析 el 节点有没有使用 v-pre 指令
function processPre (el) {
if (getAndRemoveAttr(el, 'v-pre') != null) {
// 如果使用了的话,el.pre 设为 true
el.pre = true
}
}
// 该函数只是简单的将开始标签上的内容转换成对象数组的形式,并赋值到 el.attrs 上
function processRawAttrs (el) {
const l = el.attrsList.length
if (l) {
const attrs = el.attrs = new Array(l)
for (let i = 0; i < l; i++) {
attrs[i] = {
name: el.attrsList[i].name,
value: JSON.stringify(el.attrsList[i].value)
}
}
} else if (!el.pre) {
// non root node in pre blocks with no attributes
el.plain = true
}
}
此处的涉及到的源码和 3-1 一样,不过执行过程不太一样,因为在执行 start() 方法时,inVPre 变量已经是 false 了。
export function parse (
template: string,
options: CompilerOptions
): ASTElement | void {
let root
// 因为当前处理节点的父节点使用了 v-pre,所以在 start 方法中,inVPre 为 true
let inVPre = false
// 调用 parseHTML 开始解析模板字符串
parseHTML(template, {
// 解析开始标签
start (tag, attrs, unary) {
// 此时 inVPre == true,所以无需对 v-pre 进行解析
if (!inVPre) {
processPre(element)
if (element.pre) {
inVPre = true
}
}
// inVPre == true
if (inVPre) {
// 因为 inVPre == true,所以代码执行到这里,并不对子节点开始标签中的内容进行解析。
// 以此就实现了:跳过子元素编译的效果
processRawAttrs(element)
} else if (!element.processed) {
processFor(element)
processIf(element)
processOnce(element)
processElement(element, options)
}
}
})
return root
}
对应的源码是:src/compiler/parser/index.js >>> parse() >>> chars(),源码解析都在注释中。
export function parse (
template: string,
options: CompilerOptions
): ASTElement | void {
let root
// 因为当前处理节点的父节点使用了 v-pre,所以在 chars 方法中,inVPre 为 true
let inVPre = false
// 调用 parseHTML 开始解析模板字符串
parseHTML(template, {
// 解析文本内容
chars (text: string) {
// 获取到父元素的 children 属性
const children = currentParent.children
text = inPre || text.trim()
? isTextTag(currentParent) ? text : decodeHTMLCached(text)
: preserveWhitespace && children.length ? ' ' : ''
if (text) {
let expression
// 下面是重点,此时 inVPre == true,所以代码会执行 else if 分支
// 即使文本使用了 Mustache 标签,例如 {{name}},也会进入到 else if 分支
if (!inVPre && text !== ' ' && (expression = parseText(text, delimiters))) {
children.push({
type: 2,
expression,
text
})
} else if (text !== ' ') {
// 在该分支中,任何文本都会被当做普通文本(type == 3) push 到 children 数组中
// 以此就实现了,父节点使用了 v-pre,文本子节点即使使用了 Mustache 标签,也不会被解析的效果
children.push({
type: 3,
text
})
}
}
},
})
return root
}
以下面的代码为例:
new Vue({
el: '#app',
data() {
return {
name: 'tom',
activeColor: 'red',
fontSize: 30
}
},
template: `
{{name}}
`
})
代码字符串 >>> 抽象语法树
可以发现 h1 标签的 v-bind:style 没有被解析,只是作为普通的属性放置在 attrs、attrsList、attrsMap 中。而且 h1 标签中的文本节点 type 是 3,这是普通的文本节点类型,不会被解析。这也就实现了 v-pre 指令会跳过元素和子元素编译过程的效果。
{
attrs: [{name: "id", value: "\"app\""}],
attrsList: [{name: "id", value: "app"}],
attrsMap:: {id: "app"},
children: [{
attrs: [{name: "v-bind:style", value: "\"{ color: activeColor, fontSize: fontSize + 'px' }\""}],
attrsList: [{name: "v-bind:style", value: "{ color: activeColor, fontSize: fontSize + 'px' }"}],
attrsMap: {
v-bind:style: "{ color: activeColor, fontSize: fontSize + 'px' }",
v-pre: ""
},
children: [{type: 3, text: "{{name}}", static: true}],
pre: true,
static: true,
tag: "h1",
type: 1
}],
parent: undefined,
plain: false,
static: true,
staticInFor: false,
staticProcessed: true,
staticRoot: true,
tag: "div",
type: 1
}
v-once 的官方文档点击这里
v-once 可以使元素和组件只渲染一次。随后的重新渲染,元素/组件及其所有的子节点将被视为静态内容并跳过。底层源码的实现主要看生成的代码字符串,我们以一个例子开始解析。
new Vue({
el: '#app',
data() {
return {
name: 'tom'
}
},
mounted(){
setInterval(() => {
this.name = `tom-${Math.random()}`
}, 1000)
},
template: `
{{name}}
`
})
生成的抽象语法树如下图所示:
与 v-once 有关的属性是:h1 AST 节点中的 once 属性为 true,这将作为一个标识用于生成对应的代码字符串。
生成的代码字符串如下图所示:
这里,我们发现,除了 render 代码字符串,还有一个 staticRenderFns,这个属性是一个字符串数组,数组中的字符串也是一个个的代码字符串。
Vue 中的静态根节点和 v-once 节点有他们自己专用的代码字符串,这些代码字符串都存储在 staticRenderFns 数组中,在 render 函数初次执行的时候,可以通过 _m(index) 调用这些静态节点的代码字符串生成对应的 vnode。这些静态节点的代码字符串只会在初次渲染的时候执行渲染页面一次,当组件重新渲染的时候,对这些静态节点会直接跳过,不予处理,因为静态节点的内容是不会改变的,以此能够提高性能。
所以 v-once 的本质就是:将使用了 v-once 的元素和组件看做静态节点一样进行处理。