二.模板编译
在上一节中,我们已经实现了初始化数据,并且对数据进行劫持,现在需要将数据渲染到页面上,即实现模板编译。
首先,我们在测试的index.html中增加一些模板。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<--!-->增加一个模板</-->
<div id="app" style="color:aqua; background: yellow">
<div style="color: red">{{ firstname }}</div>
</div>
<script src="vue.js"></script>
<script>
const vm = new Vue({
data: {
firstname: '珠',
lastname: '峰'
},
el: '#app', //将数据解析到el元素上
})
console.log(vm)
</script>
</body>
</html>
然后在init.js文件中实现挂载el:$mount
import { initState } from './state'
import { compileToFunction } from './compiler/index'
export function initMixin(Vue) { //就是给Vue增加init方法的
Vue.prototype._init = function(options){ //用于初始化话操作
// vm.$options 就是获取用户的配置
const vm = this;
vm.$options = options; //将用户的选项挂载到实例上
//初始化状态:就是挂载属性,方法,计算属性...
initState(vm);
if(options.el){
vm.$mount(options.el); //实现数据的挂载
}
}
Vue.prototype.$mount = function(el) {
const vm = this;
el = document.querySelector(el);
let ops = vm.$options;
//优先级:render > template > el
//如果没有render
if(!ops.render) { //先进行查找有没有render函数
let template; //没有render查看是否写了template,没有写template就用外部的template
if(!ops.template && el) { //没有写模板,但是写了 el
template = el.outerHTML;
}else { //如果没有模板
if(el) { //如果有el,采用模板的内容
template = ops.template;
}
}
//写了template,就用写了的template
if(template && el){
//这里需要对模板进行编译
const render = compileToFunction(template); //根据template生成一个render函数
ops.render = render; //挂载render属性
}
}
//现在我们就获得了render方法 ops.render
}
}
我们需要将template
编译成render函数
。
如何转成render函数?
- 先解析模板(使用正则匹配标签和内容)
- 生成ast语法树
- ast语法树生成render函数的类似字符串
- 将字符串转化成代码
一.解析标签和内容
上面说到,我们需要对模板进行处理来生成一个render函数 -> (compileToFunction函数)
在这里进行实现。
export function compileToFunctions(template){
// 1.解析模板,并生成ast语法树
let ast = parseHTML(template); //解析后返回的是一个ast语法树
// 2.ast语法树生成render函数的类似字符串
// 3.将字符串转化成代码
return ast;
}
二.生成ast语法树
parseHTML
函数就是用来解析模板(模板解析使用正则匹配标签和内容)。
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 匹配标签开头 捕获的内容是标签名
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配标签结尾的 </div>
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性的,第一个分组就是属性的key, value就是 分组3、分组4、分组5
const startTagClose = /^\s*(\/?)>/; // 匹配开始标签结束的 >
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g //匹配的是表达式的变量 {{ name }} 这里会匹配出name
将模板解析成ast语法树(parseHTML函数)
思路:匹配一部分,然后删掉这部分,直到删除完毕,使用while函数
export function parseHTML(html) { //html最开始肯定是一个<
const ELEMENT_TYPE = 1; //标签类型
const TEXT_TYPE = 3; //文本类型
const stack = [];//构建父子关系(因为标签之间也有层级关系)
let currentParent; //指向的是栈中的最后一个:当前的父亲
let root;
//创建一个元素
function createASTElement(tag, attrs){
return {
tag,
type: ELEMENT_TYPE,
children: [],
attrs,
parent: null
}
}
//下面三个函数用来处理匹配到的标签和属性
function start(tag, attrs){ //遇到开始标签:创建一个元素
let node = createASTElement(tag, attrs)
if(!root){ //看一下是否为空树
root = node; //如果为空则当前是数的根节点
}
if(currentParent){
node.parent = currentParent
currentParent.children.push(node)
}
stack.push(node);
currentParent = node; //currentParent是最近的父节点
}
function end(tag){
let node = stack.pop();
currentParent = stack[stack.length - 1]
}
function chars(text) {
text = text.replace(/\s/g,'') //替换掉空文本
text && currentParent.children.push({
type: TEXT_TYPE,
text,
parent: currentParent
})
}
//实现向前走:匹配一部分,然后删掉这部分
function advance(n){
html = html.substring(n);
}
//该函数用来匹配开始标签
function parseStartTag(){
const start = html.match(startTagOpen);
// console.log(start); start是一个数组,打印查看
if(start) { //如果是开始标签
const match = {
tagName: start[1], //标签名(其实对于start数组我们可以将值打印出来看)
attrs: [] //标签属性
}
advance(start[0].length); //前进:将已经匹配了的删除
// console.log(match, html);
//走到这一步:已经将开始标签匹配完了:就是将 <div 匹配到,接下来该匹配开始标签里面的属性了
// 如果不是开始标签的结束,说明是属性:就一直匹配下去(一直在匹配属性)
let attr; //一个变量:存储属性
let end; //一个变量:存储开始标签的结束> eg: <div id='app'> 这里匹配的是右边的 >
while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))){
advance(attr[0].length) //删掉属性
match.attrs.push({name: attr[1], value: attr[3] || attr[4] || attr[5]})
}
//删掉开始标签的右>
if(end){
advance(end[0].length)
}
return match;
}
return false; //不是开始标签
}
//-------------------上面都是一些功能函数,下面才是真正的执行流程------------------
//使用while循环:一直匹配直到html解析完
while(html){
// 如果textEnd 为0, 说明是一个开始标签或者 是结束标签(如果是结束标签的话,那么就说明结束标签前面的字符全部被匹配完毕,并且也被删除了,因为我们的匹配会将匹配完的地方逐渐删除掉)
// 如果textEnd 大于0,说明是这样的: hello</div> , 此时还在匹配文本串
let textEnd = html.indexOf('<');
if(textEnd == 0){ //如果是开始标签
const startTagMatch = parseStartTag(); //开始标签的匹配结果
if(startTagMatch){
start(startTagMatch.tagName, startTagMatch.attrs)//start,end,chars函数用来处理对应标签的内容
// 在这里,开始标签已经截取结束了。那么就continue,跳出循环然后继续向后匹配
continue;
}
// 如果不是开始标签,那就是匹配开始标签的结束标签(因为上面直接continue了,所以开始和结束只会匹配一个)
let endTagMatch = html.match(endTag)
if(endTagMatch){
advance(endTagMatch[0].length)
end(endTagMatch[1])
continue;
}
}
if(textEnd > 0){ //如果是文本内容
let text = html.substring(0,textEnd)
if(text){
chars(text)
advance(text.length)//移出解析到的文本
}
}
}
// console.log("解析到的ast树:", root)
return root;
}
此时,我们就得到了一棵ast抽象语法树。
{
tag:'div',
type:1,
children:[{tag:'span',type:1,attrs:[],parent:'div对象'}],
attrs:[{name:'zf',age:10}],
parent:null
}
//...
三.生成代码字符串
template
转化成render函数的结果应该为:
<div style="color:red">hello {{name}} <span></span></div>
//转化为:
render(){
return _c('div',{style:{color:'red'}},_v('hello'+_s(name)),_c('span',undefined,''))
}
// _c:创建元素 _v:创建文本 _s: JSON.stringify
上面就是这部分要实现的效果。
ast语法树生成render函数的类似字符串
export function compileToFunction(template) {
// 第一步:将template 转换为 ast语法树
let ast = parseHTML(template)
console.log('生成的ast树:',ast) //验证ast树
// 第二步:生成render方法(render方法执行后返回的结果就是 虚拟DOM)
/**我们最终要转换成的render函数
render(){
return _c('div',{style:{color:'red'}},_v('hello'+_s(name)),_c('span',undefined,''))
}
_c:创建元素 _v:创建文本 _s: JSON.stringify
*/
let code = codegen(ast); //生成了render函数的字符串
}
实现ast转化成render字符串 -> codegen(ast)
// codegen函数用来将ast生成render函数(render函数其实就是一个字符串,用with调用来使这个字符串变成函数,通过调用可以生成虚拟DOM)
function codegen(ast){
let children = genChildren(ast.children);
let code = (`_c('${ ast.tag }', ${ ast.attrs.length > 0 ? genProps(ast.attrs) : 'null' }${ ast.children.length ? `,${ children }` : '' })`)
// 上面分别是自己的标签,属性,孩子(属性和孩子之间的,放在了孩子那里再加)
return code
}
// genProps函数用来解析属性
function genProps(attrs){
let str = '' // {name. value} //str返回所有属性拼接后的字符串
for(let i=0; i<attrs.length; i++){
let attr = attrs[i];
if(attr.name === 'style'){ //style属性特殊处理,因为要对style属性加一个{},style标签返回的是一个对象
let obj = {};
// color:red;background:red => {color:'red'}
// 每一项用;分割,然后每一项item用:分割出属性和属性名
//用;分割出来的是一个属性名加属性值
attr.value.split(';').forEach(item => {
let [key, value] = item.split(':')
obj[key] = value;
});
attr.value = obj;
}
str += `${attr.name}:${JSON.stringify(attr.value)},` //这里是加上每个属性
}
return `{${str.slice(0,-1)}}`; //因为每个属性直接都是用 , 隔开,所以最后一个属性多了一个逗号 所以进行截取
//最终返回的结果样子:{id:"app",style:{"color":"aqua"," background":" yellow"}}
}
//genChildren函数处理孩子
function genChildren(children){
return children.map(child => gen(child))
}
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g //匹配的是表达式的变量 {{ name }} 这里会匹配出name
function gen(node){
if(node.type === 1){ //是节点
return codegen(node); //继续递归遍历
} else { //是文本
let text = node.text;
if(!defaultTagRE.test(text)){ //如果没有匹配到双括号的变量,说明是一个纯文本,直接使用JSON.stringify
return `_v(${JSON.stringify(text)})`
} else { //说明出现了变量:比如是{{name}}
//eg: _v(_s(name) + 'hello' + _s(name))
let tokens = []; //最后匹配到的多个变量
let match; //每次匹配到的变量
defaultTagRE.lastIndex = 0; //将lastIndex属性置为0:因为exec匹配只会每次重头开始匹配,我们需要让它继续上一次的位置进行匹配
let lastIndex = 0;
while(match = defaultTagRE.exec(text)){ //循环匹配,因为有多个变量
let index = match.index;
if(index > lastIndex){ // 这里是用来匹配 {{name}} hello {{age}} 中的hello
tokens.push(JSON.stringify(text.slice(lastIndex,index))) //自己的普通字符串直接stringify就行
}
tokens.push(`_s(${match[1].trim()})`) //对于变量,要用_s包裹
lastIndex = index + match[0].length //lastIndex每次都要向前进一步:超过当前变量的长度
}
if(lastIndex < text.length){ //这里是用来匹配 {{name}} hello {{age}} hello 中的第二个hello
tokens.push(JSON.stringify(text.slice(lastIndex))) //最后出现的普通字符串也是只用stringify就行
}
return `_v(${tokens.join('+')})`
}
}
}
四.生成render函数
export function compileToFunction(template) {
let code = codegen(ast); //现在已经生成了render函数的字符串,接下来我们需要让字符串可以运行
code = `with(this){return ${code}}` //包了一层with,那么code中的代码都会去this上取值,那么一会儿我们准备执行的时候,可以绑定个this,这样我们就可以随意的指定要执行的对象了
//因为我们的render函数上的变量比如name,age都在vm上,所以一会儿我们执行render函数的时候可以设置.call(vm),这样就拿到了vm身上的变量值
let render = new Function(code);//生成render函数
/**
* with是干嘛的?
* let obj = {a:1}
* with(obj){
* console.log(a)
* }
* 对于上面这段代码:在{}里面的代码都会去obj上取值,也就是说:with可以限定取值的对象是谁
*/
return render;
}