1. 前言
万字长文,建议收藏哦!
书接上回,Vue2中在进行数据初始化与监听之后(当然还有状态初始化、事件初始化等,但不影响流程,随后再说,咱们先来个大的),接下来就是进行模板编译了。这块考验的是对字符串拼接的理解,有点麻烦,相关源码已开源:手写Vue2。在开始之前,先上一张流程图。
2. 编译内部流程
2.1 获取编译内容
这一步主要考虑各种兼容情况,有以下几种场景:
- 无el属性
- 无render
- 无template
预设以上场景,下面开始codeing:
编译内容有三个来源:render、template和el中的内容;相信我们对以下代码不会陌生:
这里手动执行了$mount方法,进行编译,那如果有el属性,则自动执行。而对于template和render就好办了,有render就是用render,无render则将template转换成render,如果没template,则将el中的内容转换成template,再转换成render。简写代码如下:
具体源码:
2.2 将编译内容转换为抽象语法树(AST)
举个栗子,html内容如下:
解析html:使用$mount中的compileToFunction方法进行解析。
这里先就获取开始标签及标签属性做一个示范:
// 正则匹配:以下正则注意起始位置
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ //匹配属性的,比如:aaa='aaa' aaa="aaa" aaa=aaa
const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+?\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*` //标签名
const qnameCapture = `((?:${ncname}\\:)?${ncname})` //命名空间标签名<aa:aa>
const startTagOpen = new RegExp(`^<${qnameCapture}`) //包括尖括号在内的字符串:<div(首位)
const startTagClose = /^\s*(\/?)>/ //匹配最后的闭合尖括号:/> 、>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) //匹配整个闭合标签:</div>
const doctype = /^<!DOCTYPE [^>]+>/i
// #7298: escape - to avoid being passed as HTML comment when inlined in page
const comment = /^<!\--/
const conditionalComment = /^<!\[/
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g //匹配花括号里面的 内容
function star(tagName,attrs){
console.log(tagName);
console.log(attrs);
}
// 解析html:原理是利用正则,匹配html字符串,拿出各种标签,组成需要的数据
function parseHTML(html){;
while(html){ //匹配一段,删除一段
let textEnd=html.indexOf("<")
if(textEnd===0){ //说明是开始标签
const startTagMatch=parseStartTag() //开始标签的匹配结果
if(startTagMatch){
star(startTagMatch.tagName,startTagMatch.attrs)
}
break;
}
}
function parseStartTag(){
const starMatch=html.match(startTagOpen) //如果匹配上了,返回数组第一位是标签名+尖括号,第二位是标签名
if(starMatch){
// 结果是为了获取这个match:比如{tagName:"div",attrs:[{name:"id",value:"app"},{name:"class",value:"xxx"}]}
const match={
tagName:starMatch[1],
attrs:[]
}
advance(starMatch[0].length)
let end,attr;
//在开始标签结束以前都在循环,为了获取属性:改正则返回6位数组,第一位为完整属性,第二位为属性名,第三位位“=”,第四位/第五/第六位为属性值
while(!(end=html.match(startTagClose)) && (attr=html.match(attribute))){
match.attrs.push({
name:attr[1],
value:attr[3]||attr[4]||attr[5]
})
advance(attr[0].length)
}
if(end){ //end匹配的是结束位置的尖角符号'>'
advance(end[0].length)
return match
}
}
}
// 截取字符串:匹配完之后,将前面的字符串删掉,赋值新的字符串
function advance(index){ //参数为下标
html=html.substring(index)
}
}
// html模板-->变为render函数
export function compileToFunction(template){
// 1、将html转为"AST"语法树,注意AST语法树与虚拟dom的区别:虚拟dom是来描述dom的,AST是来描述语言的。
parseHTML(template)
}
复制代码
这部分代码很长,主要功能是获取开始标签及开始标签身上的属性。
总体的流程就是:使用一堆正则-->进行字符串匹配-->处理匹配到的字符串(进行属性获取等操作)-->从原字符串中将匹配到的字符串删除获取新的字符串(advance()方法简短有效)
举个栗子:
接下来为完整逻辑:循环整个html字符串-->匹配到开始节点、内容节点、结束节点--->处理节点并生成对应的树结构(依靠栈,先进来的就是父节点,再进一个就是上一位的子节点)-->最终得到整个AST对象
// 正则匹配:以下正则注意起始位置
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ //匹配属性的,比如:aaa='aaa' aaa="aaa" aaa=aaa
const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+?\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*` //标签名
const qnameCapture = `((?:${ncname}\\:)?${ncname})` //命名空间标签名<aa:aa>
const startTagOpen = new RegExp(`^<${qnameCapture}`) //包括尖括号在内的字符串:<div(首位)
const startTagClose = /^\s*(\/?)>/ //匹配最后的闭合尖括号:/> 、>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) //匹配整个闭合标签:</div>
const doctype = /^<!DOCTYPE [^>]+>/i
// #7298: escape - to avoid being passed as HTML comment when inlined in page
const comment = /^<!\--/
const conditionalComment = /^<!\[/
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g //匹配花括号里面的 内容
// 解析html:原理是利用正则,匹配html字符串,拿出各种标签,组成需要的数据
// 这里先不管指令、<!DOCTYPE、注释等解析
export function parseHTML(html) {
function createASTElement(tagName, attrs) {
return {
tag: tagName,
type: 1, //'1'表示标签元素类型
children: [],
attrs,
parent: null
}
}
let root; //根节点
let currentParent; //当前父节点
let stack=[] //存储节点数组
//处理开始节点
function star(tagName, attrs) {
let element=createASTElement(tagName,attrs)
if(!root){
root=element
}
// 当前获取的开始节点即作为父节点出现
currentParent=element
stack.push(element) //将开始节点放进去,在节点结尾处,将这个节点删掉
}
//处理文本节点
function chars(text) {
text=text.replace(/s/g,'') //空格去掉
if(text){ //如果去掉空格还有字符串,则收入父节点的children中
currentParent.children.push({
type:3, //3代表文本节点
text
})
}
}
//处理结尾标签并组建父子关系
function end(tagName) {
let element=stack.pop() //删除结尾标签,重新赋值currentParent
currentParent=stack[stack.length-1]
if(currentParent){
element.parent=currentParent //数组的上一位是下一位的父节点
currentParent.children.push(element)
}
}
while (html) { //匹配一段,删除一段
let textEnd = html.indexOf("<")
if (textEnd === 0) { //说明是开始标签/结束标签
const startTagMatch = parseStartTag() //开始标签的匹配结果
if (startTagMatch) {
star(startTagMatch.tagName, startTagMatch.attrs)
continue;
}
const endTagMatch = html.match(endTag) //结束标签匹配结果
if (endTagMatch) {
advance(endTagMatch[0].length)
end(endTagMatch[1])
continue
}
}
// 获取下一段尖括号之前的内容,其实就是文本
let text;
if (textEnd > 0) {
text = html.substring(0, textEnd);
}
if (text) {
advance(text.length)
chars(text)
}
}
function parseStartTag() {
const starMatch = html.match(startTagOpen) //如果匹配上了,返回数组第一位是标签名+尖括号,第二位是标签名
if (starMatch) {
// 结果是为了获取这个match:比如{tagName:"div",attrs:[{name:"id",value:"app"},{name:"class",value:"xxx"}]}
const match = {
tagName: starMatch[1],
attrs: []
}
advance(starMatch[0].length)
let end, attr;
//在开始标签结束以前都在循环,为了获取属性:改正则返回6位数组,第一位为完整属性,第二位为属性名,第三位位“=”,第四位/第五/第六位为属性值
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
match.attrs.push({
name: attr[1],
value: attr[3] || attr[4] || attr[5]
})
advance(attr[0].length)
}
if (end) { //end匹配的是结束位置的尖角符号'>'
advance(end[0].length)
return match
}
}
}
// 截取字符串:匹配完之后,将前面的字符串删掉,赋值新的字符串
function advance(index) { //参数为下标
html = html.substring(index)
}
return root //返回ast树
}
复制代码
获得最终AST数据结构如下:
2.3 将AST解析成render函数
render函数中,对AST中各种type类型的标签,提供对应的方法。比如type=1的节点类型,使用_c()方法进行标签创建;type=3的文本类型,使用_v()方法进行文本创建;使用_s()方法进行变量转换……所以,这一节就是对AST进行递归遍历,进行字符串拼接。下面是具体的方法(简写)。
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g //匹配花括号里面的 内容
function gen(node) {
if (node.type === 1) { //标签节点
return generate(node)
} else if (node.type === 3) { //暂定为文本节点
let text = node.text;
if (!defaultTagRE.test(text)) { //没有花括号,则直接导出
return `_v(${JSON.stringify(text)})`
}
// 切割并拼接字符串,处理花括号里的内容
let tokens = [];
let lastIndex = defaultTagRE.lastIndex = 0;
let match, index; //每次匹配到的结果与下标
// 正则匹配到的结果:第一位为全字符,第二位为括号内容
while (match = defaultTagRE.exec(text)) {
index = match.index;
if (index > lastIndex) { //将非括号内的也加入进去
tokens.push(JSON.stringify(text.slice(lastIndex, index)))
}
// 预设此处_s为变量处理方法
tokens.push(`_s(${match[1].trim()})`)
lastIndex = index + match[0].length //重设lastIndex
}
// 最后的最后,将括号后边的也加入进来
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)))
}
return `_v(${tokens.join("+")})`
}
}
//针对标签属性
function genProps(attrs) {
let str = '';
for (let attr of attrs) {
if (attr.name === 'style') {
let obj={}
// attr.value:"color:red;diplay:block"
attr.value.split(";").forEach(item=>{
let [key,value]=item.split(":")
obj[key]=value
})
attr.value=obj
}
str += `${attr.name}:${JSON.stringify(attr.value)},` //加JSON.stringify是为了加入引号
}
return `{${str.slice(0, -1)}}` //删除最后一个逗号
}
function genChildren(ast) {
const children = ast.children
if (children) {
return children.map(child => gen(child)).join(",")
}
}
/**
* @desc: 这是要生成的render函数,预设_c方法:就是创造标签节点,_v方法:创造文本节点 _s转换变量等
* render(){
* return _c('div',{id:'app',style:{padding:'10px'}},_v('姓名:'+_s(information.name))……)
* }
**/
export function generate(ast) {
let children = genChildren(ast)
let code = `_c(${JSON.stringify(ast.tag)},${ast.attrs.length ? genProps(ast.attrs) : 'undefined'}${children ? (',' + children) : ''})`
console.log(code);
return code
}
复制代码
上述代码中,有个gen()方法,gen()方法中,主要麻烦的一点就是对花括号的处理。使用 defaultTagRE.exec(text)进行花括号内容的循环匹配,目的是将花括号替换成_s方法,并将前后字符串进行拼接。比如:这是一段字符串:我的名字是{{name}},今年{{age}}岁
,最终解析成:这是一段字符串:我的名字是_s(name),今年_s(age)岁
需要注意的是,lastIndex问题。
最终解析出来的字符串为:
将生成的字符串生成一个render函数
这里使用了with方法,with方法可以改变作用域,这里,根据上文中$mount方法,可以预知,this最理想的指向就是Vue实例,这样就可以拿到data等其中的变量,所以,自然而然,_c、_v、_s等方法都应该挂载在Vue原型上。上述步骤结束以后,我们就得到一个新鲜的render函数:
(function anonymous(
) {
with(this){return _c("div",{id:"app",class:"xxx"},_v("\n 人员信息\n "),_c("ul",{style:{"color":"red","padding":"10px"}},_v("\n "),_c("li",undefined,_v("姓名:"+_s(information.name))),_v("\n "),_c("li",undefined,_v("年龄:"+_s(information.age))),_v("\n "),_c("li",undefined,_v("职位:"+_s(information.job))),_v("\n ")),_v("\n "))}
})
复制代码
2.4 将render函数解析成虚拟dom
生成render之后,就是将render转换成虚拟dom-->转成真实节点,然后挂载到el中去。
我们导入mountComponent方法,并在原型上挂载_update方法(Vue中最重要的两个函数就是_update和_render)。mountComponent如下:
import {patch } from "./vdom/patch"
export function lifecycleMixin(Vue) {
Vue.prototype._update=function (vnode) {
let vm=this
patch(vm.$el,vnode)
}
}
export function mountComponent(vm,el) {
// 生成真实的dom,放进el中
// 先调用render方法创建虚拟节点,再将虚拟节点渲染到页面上
vm._update(vm._render())
}
复制代码
patch方法是打补丁,将节点渲染到页面中。而在此之前,我们要生成虚拟dom。让我们看下_render方法和_s、_c、_v等方法吧。
export function renderMixin(Vue) {
// 创建虚拟dom节点
Vue.prototype._c = function () {
return createElement(...arguments)
}
// 创建虚拟dom文本节点
Vue.prototype._v = function (text) {
return createTextElement(text)
}
// 处理花括号中的变量
Vue.prototype._s = function (val) {
return val === null ? '' : (typeof val === 'object') ? JSON.stringify(val) : val
}
// 调用的就是Vue中的render方法
Vue.prototype._render = function () {
const vm = this;
const render = vm.$options.render
const vnode = render.call(vm)
console.log(vnode);
// debugger
return vnode
}
}
// _c('div',{id:'app',style:{padding:'10px'}},child1,child2……)
function createElement(tag, data = {}, ...children) { //data是属性
return vnode(tag, data, data.key, children)
}
function createTextElement(text) {
return vnode(undefined, undefined, undefined, undefined, text)
}
// 生成虚拟node
function vnode(tag, data, key, children, text) {
return {
tag,
data,
key,
children,
text
}
}
复制代码
可以看出_render方法其实就是咱们之前生成的render。这里call(vm),劫持了render中的this,再通过_c、_v、_s等方法,生成虚拟dom。
2.5 将虚拟dom解析成真实节点
终于来到了最后一步,生成真实节点。vue中利用patch方法,生成真实节点,并将原节点进行一波替换。这里简写下:真实的patch并不是这样的哈,但原理类似。
export function patch(oldVnode,vnode) {
// 将虚拟节点转换为真实节点
let el=createElement(vnode) //真实节点
// 获取el的父节点
let parentEle=oldVnode.parentNode
// 当前元素插入app的后边
parentEle.insertBefore(el,oldVnode.nextSibling);
// 删除老的节点
parentEle.removeChild(oldVnode)
}
function createElement(vnode) {
let {tag,children,key,data={},text}=vnode
if(typeof tag ==='string'){ //说明是标签,则创建标签
vnode.el=document.createElement(tag)
// 设置属性源码中有单独的方法,这里只简单考虑下style和类名id等
for(let key in data){
if(key==='style'){
let str=''
for(let styleItem in data[key]){
str+=`${styleItem}:${data[key][styleItem]};`
}
vnode.el.setAttribute(key,str)
}else{
vnode.el.setAttribute(key,data[key])
}
}
// 递归渲染子节点
children.forEach(item=>{
vnode.el.appendChild(createElement(item))
})
}else{ //文本节点
vnode.el=document.createTextNode(text)
}
return vnode.el
}
复制代码
步骤很简单,就是创建节点-->找到父节点-->将新节点插入原节点后边-->再删除原节点。而且,咱们也真正实现了效果。
3. 总结
咱们的编译流程就是:初始化数据-->模板编译-->生成code字符串-->转换为render函数-->生成虚拟dom-->生成真实节点并进行节点替换。
你学废了吗?
参考资料: