目录
1,解析模板参数
我们先增加一个模板,想在页面取值可以用{{ }},但是要对这个模板进行编译,所以新增一个el属性,我们要将数据 解析到el元素上。
<div id="app">
<div>{{name}}</div>
<span> {{age}} </span>
</div>
<script src="vue.js"></script>
<script>
const vm = new Vue({
data:{ //代理数据
name:'zf',
age:20,
address:{
num:30,
content:'回龙观'
},
hobby:['eat','drink',{a:1}]
},
template:'<div>hello</div>'
// el:'#app', //我们要将数据 解析到el元素上,也可以不写el,手动挂载vm.$mount('#app');
});
vm.$mount('#app');
<script>
指向一个元素后,现在要做的就是将模板里的name和age进行数据的替换
1,模板引擎,就是每次把模板拿到,用数据来替换。这样操作性能差,需要正则匹配替换, Vue1.0 没有引入虚拟DOM的改变。
2,采用虚拟DOM,数据变化后比较虚拟DOM的差异,最后更新需要更新的地方。
3,核心就是我们需要将模板变成我们的js语法,通过js语法生成虚拟DOM
我们需要先变成语法树再重新组装代码成为新的语法,我们的目标就是将template语法转换成render函数。
状态初始完,要看用户有没有给一个el属性,如果有,就要去挂载应用,调用vm.$mount。
init.js
// 初始化状态 props,data,computed....
initState(vm);
if(options.el){
vm.$mount(options.el); //实现数据的挂载
}
接着就给Vue.prototype写方法。要先获取el元素,先看下用户有没有写render,没有的话是否在vm.$options里写了template,没有的话就用外面的模板。最后再判断一下,有模板再去进行模板编译。新建compiler文件夹,把compileToFunction方法写在index.js里。
Vue.prototype.$mount = function(el){
const vm = this ;
el = document.querySelector(el);
let ops = vm.$options;
if(!ops.render){ //先进行查找有没有render函数
let template; //没有render看一下是否写了template,没写template采用外部的template
if(!ops.template && el ){ //没有写模板,但是写了el
template = el.outerHTML
}else{
if(el){
template = ops.template //如果有el 则采用模板的内容
}
}
//写了template 就用写了的template
// console.log(template)
if(template){
//这里需要对模板编译成render函数
const render = compileToFunction(template);
ops.render = render; //jsx最终会被编译成h('xxx')
}
}
ops.render; //最终就可以获取render方法
}
扩展:安装插件npm install @rollup/plugin-node-resolve,可以在引入方法的时候不用在文件后面补充index。然后在rollup.config.js 更改配置。
import resolve from '@rollup/plugin-node-resolve'
plugins:[
babel({
exclude: 'node_modules/**'
}),
resolve()
]
2,实现模板转化成ast语法树
接下来就要对模板进行编译,如何处理:
1,就是将template转化成ast语法树
2,生成render方法(render方法执行后的返回结果就是虚拟DOM)
这里我们需要拿到一个ast,写一个方法parseHTML,来解析HTML,把template传进来,它就会返回一个语法树。每解析一个标签,就把它从字符串里删掉,直到字符串都被截取完。
先在compiler index.js里匹配正则
const ncname = `[a-zA-Z][\\-\\.0-9_a-zA-Z]*`;
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 他匹配到的分组是一个 标签名 <xxx 匹配到的是开始 标签的名字
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配的是</xxxx> 最终匹配到的分组就是结束标签的名字
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性 color = "xxx" a='bb' c=d
// 第一个分组就是属性的key value 就是 分组3/分组4/分组五
const startTagClose = /^\s*(\/?)>/; // <div> <br/>
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // {{ asdsadsa }} 中间可以换行回车,匹配到的内容就是我们表达式的变量
由于html最开始肯定是一个<,因为Vue要求模板不能是字符串(Vue3可以在template里直接写字符串,hello</div>)。先判断如果是 < 且索引是0的话,则可能是开始标签或者结束标签。假设是开始标签,写个方法parseStartTag解析这个开始标签。用html去匹配正则看是否是开始标签,如果匹配到了这个标签,则是开始标签,如果不是,return false。这里就把结果组成一个对象match,里面把当前的标签名放进去,还需要标签属性,这个要等下一次匹配。另外还要写一个截取的方法advance,把刚刚匹配成功的部分删除掉。
接下来就是匹配属性,这里要考虑的是,在匹配属性的过程中,只要不是开始标签的结束,就一直匹配。所以可以加一个while循环,每次匹配同时也希望把这个属性保留起来,之后再用advance截取删除。
到最后会剩一个开始标签的结束>,定义变量end来接收这个>,也要把它截取删除。这里输出match,可以看到到这就是一个开始标签的解析。最后就会返回一个解析到的结果,就可以拿着这个结果startTagMatch接着处理文本和内容。
function parseHTML(html) { //html最开始肯定是一个<
//截取匹配成功的部分
function advance(n) {
html = html.substring(n);
}
function parseStartTag() {
const start = html.match(startTagOpen);
// console.log(start) ---0: "<div" 1: "div"
if (start) {
const match = {
tagName: start[1], //标签名(分组就是标签名)
attrs: [] //还需要有标签的属性
}
advance(start[0].length);
let attr, end //end就是>
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length);
// console.log(attr) 0:' id="app"', 1:'id', 2:'=', 3:'app',
match.attrs.push({name:attr[1],value:attr[3] || attr[4] || attr[5] ||true})
}
if (end) {
advance(end[0].length)
}
}
return false; //不是开始标签
};
while (html) {
// 如果textEnd 为0,那说明是一个开始标签或者结束标签 hello</div>
// 如果textEnd >0,说明就是文本的结束位置
let textEnd = html.indexOf('<');
if (textEnd == 0) {
const startTagMatch = parseStartTag(); //开始标签的匹配结果
if(startTagMatch){ //解析到的开始标签
continue
}
}
}
}
如果开始标签startTagMatch有值,就不用走循环,直接跳过本轮操作。见上图,有可能开始标签截取之后还会有开始标签,但总有一天开始标签会被全部截掉到达文本位置。当textEnd > 0时,就代表标签前面有文本,就从0截取到textEnd,这部分就是文本内容,删除。下图打印输出的部分,截取的文本内容是空格和换行。
等到最后就是结束标签。按照一开始关于textEnd = 0即最前面是<的判断,已经排除是开始标签,接下来就是结束标签,则直接用html匹配正则后返回结束标签名,截取删除,将判断写在开始标签下面。
整个思路就是遇到开始标签处理开始标签,遇到文本处理文本,遇到结束标签处理结束标签。最后打印html则为空。解析开始标签后跳过本地循环,原因是开始标签后可能就跟着结束标签,所以重新走while循环就好。假设<div></div>,解析完开始标签,重新再解析结束标签就好,不需要走textEnd > 0 的代码。
while (html) {
let textEnd = html.indexOf('<');
if (textEnd == 0) {
const startTagMatch = parseStartTag(); //开始标签的匹配结果
if(startTagMatch){ //解析到的开始标签
continue
}
let endTagMatch = html.match(endTag);
if(endTagMatch){ //结束标签
advance(endTagMatch[0].length);
continue;
}
}
if(textEnd > 0){ //有文本
let text = html.substring(0,textEnd); //从0截取到textEnd文本内容
if(text){
advance(text.length);//解析到的文本
console.log(html)
}
}
}
console.log(html)
目前到这里只把字符串删掉,没有替换文本,我们期望的是把文本稍作处理。比如匹配好开始标签,文本和结束标签,那就在外面写三个方法start,chars和end暴露出去,每次调用的时候,会把对应的内容传给方法,调用对应的方法即可。
由于最终需要转化成一棵抽象语法树,这里可以搞一个栈型结构。把div标签放进来,[div],之后当匹配到下一个div的时候,就可以知道第二个div是栈中最后一个的孩子,继续把第二个div放进去 [div div],当匹配到结束标签的时候,再把儿子抽取出来 [div],假设span中还有a标签,则是 [div span a],解析a的时候就知道a是span的孩子,就是匹配括号的算法题。简而言之,栈中的最后一个元素是当前匹配到开始标签的父亲。
<div id="app">
<div style="color: red">{{name}}hello</div>
<span> {{age}} </span>
</div>
这里主要有两种类型,一种是文本,一种是元素,先定义两个变量ELEMENT_TYPE和TEXT_TYPE,再定义栈来存放元素,而且还需要一个指针currentParent来指向栈中的最后一个。接着开始进行,遇到开始标签的时候,就创建一个ast元素。这里写一个方法createASTElement,当遇到开始标签时,就调这个方法,即start方法里调用这个方法,这样就产生了节点。但同时我们需要知道这个是不是根节点,所以还要定义一个属性root。判断如果没有根节点,那这个节点就是根节点。如果currentParent有值,就让当前节点的parent为currentParent,并且给children赋值。同时放进栈中,并且还要将当前这个人指向栈中的最后一位
const ELEMENT_TYPE = 1;
const TEXT_TYPE = 3;
const stack = []; //定义栈用于存放元素
let currentParent; //指向的是栈中的最后一个
let root; //根节点
//最终需要转化成一棵抽象语法树
function createASTElement(tag,attrs){
return{
tag,
type:ELEMENT_TYPE,
children:[], //开始I标签时还不知道孩子是谁
attrs,
parent:null
}
}
function start(tag,attrs){
let node = createASTElement(tag,attrs); //创造一个ast节点
if(!root){ //看一下是否是空树
root = node; //如果为空则当前是树的根节点
}
if(currentParent){
node.parent = currentParent;
}
stack.push(node);
currentParent = node; //currentParent为栈中的最后一个
}
处理完开始标签后,接着就是文本,那文本就是当前的孩子,就可以直接拿到currentParent.children,将文本放进去。
function chars(text){ //文本直接放到当前指向的节点中
text = text.replace(/\s/g,''); //如果空格超过2就删除2个以上
text && currentParent.children.push({
type:TEXT_TYPE,
text,
parent:currentParent
});
}
如果遇到结束标签,那就直接去除掉最后一个,并且更新currentParent。
function end(tag){
let node = stack.pop(); //弹出最后一个,检验标签是否合法
currentParent = stack[stack.length-1];
}
代码汇总。在complier文件夹里新建parse.js文件,将所有功能包括正则都放到该文件中。
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); //创造一个ast节点
if(!root){ //看一下是否是空树
root = node; //如果为空则当前是树的根节点
}
if(currentParent){
node.parent = currentParent; //只赋予了parent属性
currentParent.children.push(node); //还需要让父亲记住自己
}
stack.push(node);
currentParent = node; //currentParent为栈中的最后一个
}
function chars(text){ //文本直接放到当前指向的节点中
text = text.replace(/\s/g,''); //如果空格超过2就删除2个以上
text && currentParent.children.push({
type:TEXT_TYPE,
text,
parent:currentParent
});
}
function end(tag){
let node = stack.pop(); //弹出最后一个,检验标签是否合法
currentParent = stack[stack.length-1];
}
//截取匹配成功的部分
function advance(n) {
html = html.substring(n);
}
function parseStartTag() {
const start = html.match(startTagOpen);
// console.log(start) ---0: "<div" 1: "div"
if (start) {
const match = {
tagName: start[1], //标签名(分组就是标签名)
attrs: [] //还需要有标签的属性
}
advance(start[0].length);
// console.log(match,html)
//如果不是开始标签的结束,就一直匹配,且每次匹配同时也希望把这个属性保留起来
let attr, end //end就是>
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length);
// console.log(attr) 0:' id="app"', 1:'id', 2:'=', 3:'app',
match.attrs.push({name:attr[1],value:attr[3] || attr[4] || attr[5] ||true})
}
if (end) {
advance(end[0].length)
}
return match;
}
// console.log(html)
return false; //不是开始标签
};
while (html) {
// 如果textEnd 为0,那说明是一个开始标签或者结束标签 hello</div>
// 如果textEnd >0,说明就是文本的结束位置
let textEnd = html.indexOf('<'); //如果indexOf中的索引是0 则说明是个标签,vue3可以不是标签开始,在template里直接写字符串
if (textEnd == 0) {
const startTagMatch = parseStartTag(); //开始标签的匹配结果
if(startTagMatch){ //解析到的开始标签
start(startTagMatch.tagName,startTagMatch.attrs)
continue
}
let endTagMatch = html.match(endTag);
if(endTagMatch){ //结束标签
advance(endTagMatch[0].length);
end(endTagMatch[1]);
// console.log(endTagMatch) 0: "</div>" 1:div
continue;
}
}
if(textEnd > 0){ //有文本
let text = html.substring(0,textEnd); //从0截取到textEnd文本内容
if(text){
chars(text);
advance(text.length);//解析到的文本
}
}
}
return root;
}
3,代码生成实现原理
在complier中心间index,引入parseHTML函数。前面已经将template转为ast语法树了,接下来要做的就是生成render方法。
export function compileToFunction (template){
//1,就是将template转化成ast语法树
let ast = parseHTML(template);
console.log(ast);
console.log(codegen(ast))
//2,生成render方法(render方法执行后的返回结果就是虚拟DOM
// render(){
// return _c('div',{id:'app'},_c('div',{style:{color:'red'}},_v(_s(name) + 'hello'),_c('span',undefined,_v(_s(age)))))
// }
codegen(ast);
}
function codegen(ast){
let children = genChildern(ast.children);
let code = (`_c('${ast.tag}',${ast.attrs.length > 0? genProps(ast.attrs) :'null'
}${ast.children.length ? `,${children}` : ''
})`) //最后的结果,里面放一个div
return code;
}
先匹配属性,写一个genProps方法,这个方法传的参数就是ast语法树里的属性attrs数组,遍历之后拿到每个索引对象的name和value。另外还需要判断attr.name是不是style,因为如果属性是style,里面会有多个属性值。所以这里判断后要进行切割。拼接的时候要在最后加个逗号 , 等到完成最后一个拼接之后再删掉最后一个逗号。
function genProps(attrs){
let str = '' //{name:'a',value:'b'}
for (let i = 0;i<attrs.length;i++){
let attr = attrs[i];
if(attr.name === 'style'){
// {color: 'red', background-color: ' yellow'} => color:red;background:yellow
// console.log(typeof(attr.value))
let obj = {};
attr.value.split(';').forEach(item => { //qs库
let [key,value] = item.split(':');
obj[key] = value;
});
attr.value = obj;
}
str += `${attr.name}:${JSON.stringify(attr.value)},` //a:b,c:d
}
//由于上面那样拼接最后会多出一个,要删掉
return `{${str.slice(0,-1)}}`
}
接着判断孩子,如果有孩子,就把孩子映射成一个一个的元素,同时用逗号, 拼接,这里写一个方法gen。先判断孩子是元素还是文本,这里要用到前面定义好的type。
const ELEMENT_TYPE = 1; //元素类型
const TEXT_TYPE = 3; //文本类型
如果是文本,还要用到正则,去判断是纯文本还是变量。由于变量还有特殊情况,例如变量之后继续跟着文本和变量,{{name}} hello {{age}},用exec匹配的话,这里只能匹配到第一个name,就终止了,所有这里要把正则的lastIndex重置,且写个循环。这样就可以拿到两个变量各自的索引,第一个变量的索引加上第一个变量的长度,就能拿到中间的文本。
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // {{ asdsadsa }} 中间可以换行回车,匹配到的内容就是我们表达式的变量
function gen(node){
// 拿到节点,要先判断是文本还是元素,如果是文本要创建文本,如果是元素就直接生成
if(node.type === 1){
// 元素
return codegen(node);
}else{
// 文本
let text = node.text;
if(!defaultTagRE.test(text)){
//纯文本
return `_v(${JSON.stringify(text)})`
}else{
// {{a}} 变量
let tokens = [];
let match;
let lastIndex = 0;
defaultTagRE.lastIndex = 0; //这里用exec匹配之后需要重置,不然只会匹配一个之后就结束
while(match = defaultTagRE.exec(text)){
let index = match.index; //匹配的位置
if(index > lastIndex){
tokens.push(JSON.stringify(text.slice(lastIndex,index)))
}
// console.log(index)-------0 13
// console.log(match)-------0:"{{name}}" 1: "name" groups: undefined index:0
tokens.push(`_s(${match[1].trim()})`) //去掉空格
lastIndex = index + match[0].length // {{name}} hello {{age}} match[0]------{{name}}
}
// 假设变量后面还有文本
if(lastIndex < text.length){
tokens.push(JSON.stringify(text.slice(lastIndex)))
}
return `_v(${tokens.join('+')})`
console.log(tokens)
}
}
}
function genChildern(children){
return children.map(child => gen(child)).join(',')
}
这样属性和孩子就都匹配完成
4,开始准备执行render函数
由于name和age等变量是要去vm上去取值,所以用with方便取值,模板引擎的实现原理 就是 with + new Function,这样就生成render函数。
export function compileToFunction (template){
//1,就是将template转化成ast语法树
let ast = parseHTML(template);
console.log(ast);
codegen(ast);
console.log(codegen(ast))
//2,生成render方法(render方法执行后的返回结果就是虚拟DOM
// render(){
// return _c('div',{id:'app'},_c('div',{style:{color:'red'}},_v(_s(name) + 'hello'),_c('span',undefined,_v(_s(age)))))
// }
// 模板引擎的实现原理 就是 with + new Function
let code = codegen(ast);
code = `with(this){return ${code}}`;
let render = new Function(code); //根据代码生成render函数
console.log(render.toString());
// function render(
// ) {
// with(this){return _c('div',{id:"app",style:{"color":"red","background-color":" yellow"}},_c('div',{style:{"color":"red"}},_v(_s(name)+"hello"+_s(age))),_c('span',null,_v("world")))}
// }
// render.call(vm);
return render;
}
接下来要调用render才能实现页面渲染,在 init.js 调用新方法mountComponent,传入vm和el,对当前vm的render进行挂载,产生虚拟dom,有了虚拟dom再去渲染el。在src下的index.js 调用initLifeCycle(Vue);新建一个lifeCycle.js写方法mountComponent和initLifeCycle。