一、目标
创建一个Vue实例时,我们可以传入el配置项,去指定一个DOM元素作为Vue容器,而这个Vue容器中,可以使用例如插值表达式等Vue框架提供的语法,并且能够渲染到浏览器页面上。
而浏览器并不能解析这些Vue语法,因此,Vue框架是通过获取到Vue容器,然后对容器内容进行解析,重新生成DOM元素,去替换掉容器内容。
二、执行流程
Vue是如何重新解析,并且生成新的DOM对象的呢?
1、拿到模板字符串,然后解析为语法树,语法树节点描述了这个DOM的父节点、属性、子节点、
节点类型等。
{
type,
tag,
attrs,
parent,
children: []
}
2、使用语法树生成虚拟DOM,虚拟DOM节点与语法树节点内容类似,不过虚拟DOM上面我们可以去挂一些自定义的属性,方便生成真实dom等等,而语法树节点,则是直接描述HTML内容,因此解析语法树,用第三方提供的包,也是可以完成的。
3、通过虚拟DOM生成真实DOM,然后替换Vue容器
三、模板解析
我们拿到HTML字符串后,可以通过正则去解析HTML内容,主要是要解析出标签、标签属性、文本内容。
解析HTML入口方法:parseHTML()
通过循环匹配,不断地按顺序解析HTML
例如:
下面这段html代码的解析流程:
初始状态
<div id="app">test<span></span></div>
树节点栈:【】
1、匹配到开始标签,生成一个树节点,类型为节点元素,设置tag和属性,然后截去解析完的内容,并把生成的节点放入栈中:
test<span></span></div>
树节点栈:【node{tag:'div', {id:'app'}, children: [], type: 'element'}】
2、匹配到文本内容,生成一个树节点,类型为文本元素,放入到栈顶元素的children数组中
<span></span></div>
树节点栈:【node{tag:'div', {id:'app'}, children: [node(text:'test', type: 'text')], type: 'element'}】
3、匹配到span的开始标签,生成节点,放入到栈顶元素的children数组中,并将自身放入栈中
</span></div>
树节点栈:【node{tag:'div', {id:'app'}, children: [node(text:'test', type: 'text'), node(span...)], type: 'element'}, node(span.....)】
4、匹配到span结束标签,栈中弹出span
</div>
树节点栈:【node{tag:'div', {id:'app'}, children: [node(text:'test', type: 'text'), node(span...)], type: 'element'}】
5、匹配到div结束标签,栈中弹出div
''
树节点栈:【】
最后得到的节点则是整颗语法树的根节点,顺着children往下找,可以生成整颗语法树
// Non-Colonized Name,xml元素和属性的名称
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;
// 标签名
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
// 匹配开始标签, 分组1为标签名
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 结束标签
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
// 标签属性,分组1:key,value: 分组3/分组4/分组5
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;
// 闭合标签
const startTagClose = /^\s*(\/?)>/;
export const AstNodeType = {
ELEMENT_NODE: 1,
TEXT_NODE: 3
}
/**
* 创建AST语法树节点
*/
function createASTElement(tag, attrs, parent) {
return {
type: AstNodeType.ELEMENT_NODE,
tag,
attrs,
parent,
children: []
}
}
/**
* 解析HTML字符串为抽象语法树
* @param {String} html Html字符串
* @returns 抽象语法树
*/
export function parseHTML(html) {
// 使用栈存储结构,逐层解析HTML
const stack = []
// 栈顶指针
let topNode = null
// 树根
let root = null
// 处理开始标签
function start(tag, attrs) {
const node = createASTElement(tag, attrs, topNode)
if (topNode) {
topNode.children.push(node)
} else {
root = node
}
if (tag != 'br') {
stack.push(node)
topNode = node
}
}
// 处理文本内容
function content(text) {
topNode.children.push({
type: AstNodeType.TEXT_NODE,
text,
parent: topNode
})
}
// 处理结束标签
function end(tag) {
if (topNode.tag === tag) {
stack.pop()
topNode = stack[stack.length - 1]
} else {
topNode.children.push(createASTElement(tag, null, topNode))
}
}
// html解析前进,截掉已解析内容
function advance(length) {
html = html.substring(length)
}
// 尝试解析开始标签,返回一个标签节点,包含标签名、属性等信息
function parseStartTag() {
let startTag = null
const match = html.match(startTagOpen)
if (match) {
startTag = {
tag: match[1],
attrs: {}
}
advance(match[0].length)
while (true) {
const attrMatch = html.match(attribute)
if (attrMatch) {
// 获取正则匹配到的分组值
startTag.attrs[[attrMatch[1]]] = attrMatch[3] || attrMatch[4] || attrMatch[5]
advance(attrMatch[0].length)
} else {
break
}
}
let end = html.match(startTagClose)
advance(end[0].length)
}
return startTag
}
// 解析完成的html代码片段会被截掉,一直循环到html所有内容都解析完毕
while (html) {
// 匹配到首个<,可能时开始标签,可能是结束标签,文本内容是不会包含的,需要被转义
const endIdx = html.indexOf('<')
// 如果当前已经解析到标签
if (endIdx === 0) {
// 尝试作为开始标签去解析
const startTag = parseStartTag()
if (startTag) {
start(startTag.tag, startTag.attrs)
// 如果是开始标签,则continue, 因为开始标签在parseStartTag中已经被截掉了
continue
}
// 如果走到这,证明是结束标签,则处理结束标签
const endTagMatch = html.match(endTag)
if (endTagMatch) {
end(endTagMatch[1])
advance(endTagMatch[0].length)
}
} else {
// 处理文本标签
const text = html.substring(0, endIdx).trim()
if (text) {
content(text)
}
advance(endIdx)
}
}
return root
}
四、生成渲染函数代码
渲染函数可以生成虚拟dom,渲染函数的格式:h(【tag标签名】,【属性节点】,【子节点】,【子节点】....... )
可以发现,上一步生成的抽象语法树,包含了渲染函数所需要的全部信息,而我们生成渲染函数代码,可以避免每次更新页面时,需要重新解析一遍html。
渲染函数示例:
模板:
<div id="app">test{{value}}<span></span></div>
解析为语法树后,根据语法树,生成的渲染函数代码为:
_c('div', {attrs:{id: 'app'}}, _v('test' + _s(value)), _c('span'))
// _c 创建虚拟dom节点
// _v 创建文本虚拟dom节点
// _s 包裹插值语法内容,将插值表达式的值转化为字符串
此时,如果实现了_c、_v、_s, 并且将这个表达式放入vm上下文中运行(_s中的value,是直接取值的,不是一个字符串),就可以获得一个虚拟节点。
生成表达式:
import { AstNodeType } from "../parser";
// 匹配 插值语法
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
/**
*
* @param {Object} ast ast语法树
*
* @returns 渲染函数字符串
* 例如_c('div', {id: 'app'}, _v('age:' + _s(age)), _c(......))
* _c 创建虚拟dom节点
* _v 创建文本虚拟dom节点
* _s 包裹插值语法内容,将插值表达式的值转化为字符串
*/
export function generate(ast) {
if (!ast) {
return null;
}
// 生成子节点
const childrenCodeList = []
if (ast.children) {
ast.children.forEach((child) => {
childrenCodeList.push(generate(child))
})
}
const childrenCode = childrenCodeList.join(',') || null
let code = ""
// 根据节点类型,生成对应虚拟DOM
switch (ast.type) {
case AstNodeType.ELEMENT_NODE:
code = `_c('${ast.tag}',${JSON.stringify(ast.attrs)}, ${childrenCode})`
break
case AstNodeType.TEXT_NODE:
let text = ast.text
let match
let tokens = []
// 匹配插值表达式,用_s包裹起来
while (match = defaultTagRE.exec(text)) {
if (match.index !== 0) {
const t = text.substring(0, match.index)
tokens.push(`'${t}'`)
}
text = text.substring(match.index + match[0].length)
tokens.push(`_s(${match[1].trim()})`)
}
if (text) {
tokens.push(`'${text}'`)
}
// 拼接文本块
code = `_v(${tokens.join('+')})`
default:
break;
}
return code
}
将字符串表达式生成渲染函数:
import { generate } from "./codegen";
import { parseHTML } from "./parser";
/**
* 解析模板,生成渲染函数
* @param {String} template 模板
* @returns 渲染函数
*/
export function compileToFunctions(template) {
// 1、解析模板,生成抽象语法树
const ast = parseHTML(template)
// 2、通过抽象语法树,生成渲染函数表达式,表达式执行的值为虚拟dom
const exp = generate(ast)
// 3、添加绑定作用域,添加返回语句,最后生成函数
// (调用这个函数的时候,需要使用call(this), 表达式中的响应式数据可以直接取到,不用通过this.XXX)
const code = `with(this){return ${exp}}`
return new Function(code)
}
五、执行渲染函数
执行渲染函数,需要实现c\v\s方法, 并挂到vue原型对象中去
$option._render 就是刚刚生成的渲染函数
// 生成虚拟DOM
Vue.prototype._render = function () {
return this.$options.render.call(this)
}
/**
* 给定标签名称、属性、子节点,生成虚拟DOM
* @param {String} tag 标签名
* @param {Object} attrs 属性集合
* @param {Array} children 子节点
* @returns 虚拟dom
*/
Vue.prototype._c = function (tag, attrs, ...children) {
return new VNode(tag, { attrs }, children, undefined, undefined, this)
}
// 生成文本虚拟节点
Vue.prototype._v = function (text) {
return new VNode(undefined, undefined, undefined, text)
}
/**
* 将插值表达式的值转化为字符串
* @param {any} value 任意值
* @returns 字符串
*/
Vue.prototype._s = function (value) {
let result = null
if (typeof value === 'object') {
result = JSON.stringify(value)
} else {
result = value
}
return result
}
/*** vnode.js *****/
// 虚拟节点定义
export class VNode {
tag
data
children
text
elm
context
constructor(tag, data, children, text, elm, context) {
this.tag = tag
this.data = data
this.children = children
this.text = text
this.elm = elm
this.context = context
}
}
六、生成真实DOM
根据虚拟DOM生成真实节点
/**
* 根据虚拟节点创建真实节点
* @param {vnode} vnode 虚拟节点
* @returns 真实节点
*/
export function createElm(vnode) {
const { children, data: { attrs } = {}, tag, text } = vnode || {}
let elm = null
if (tag) {
elm = document.createElement(tag)
Object.keys(attrs).forEach(key => {
elm.setAttribute(key, attrs[key])
})
children && children.forEach(child => {
elm.appendChild(createElm(child))
})
} else {
elm = document.createTextNode(text)
}
return elm
}
附
gitee提交: