主要内容
Vue响应式原理支持,对象属性劫持实现对数组的方法劫持模板编译原理,将模板转化成ast语法树代码生成,实现虚拟DOM通过虚拟DOM生成真实DOM环境准备:
npm install rollup//将高级语法转换为低级语法npm install rollup rollup-plugin-babel @babel/core @babel/preset-env --save-devnpm i @rollup/plugin-node-resolve
package.json
{ "scripts": { "dev": "rollup -cw" }, "devDependencies": { "@babel/core": "^7.18.6", "@babel/preset-env": "^7.18.6", "rollup": "^2.75.7", "rollup-plugin-babel": "^4.4.0" }}
.babelrc
{ "presets": [ "@babel/preset-env" ]}
rollup.config.js
//rollup默认可以导出一个对象,作为打包的配置文件import babel from 'rollup-plugin-babel'export default { input: './src/index.js', //入口 output: { file: './dist/vue.js', //出口 name: 'Vue', format: 'umd', //esm es6模块, commonjs模块 iife自执行函数 umd统一模块规范 sourcemap: true, //希望可以调试源代码 }, plugins: [ babel({ exclude: 'node_modules/**' //排除node_modules所有文件 }) ]}
打包命令:npm rundev
初始化数据
创建Vue实例
import { initMixin } from "./init";function Vue(options){ // debugger this._init(options)}//给Vue实例添加初始化方法,将用户选项挂载到实例上,并开始初始化状态Vue.prototype._init = function(options){ //用于初始化操作 // $表示Vue自带的属性 const vm = this; vm.$options = options;//将用户的选项挂载到实例上 //初始化状态 if(vm.$options.data){// data = typeof data === 'function' ? data.call(vm) : data } }
劫持对象观测
遍历对象data中的每一个元素进行属性劫持,保证数据访问或者更新时能拦截,如果key存储的是对象则进行递归监测
class Observer{ constructor(data){ //Object.defineProperty只能劫持已经存在的属性,后续增加的无法监听(vue2会为此单独写一个例如 $set $delete的api) Object.defineProperty(data, '__ob__', { value: this, //将Observer类实例赋值给data的__ob__属性,如果数据上有这个属性则说明这个属性被观测过了 enumerable: false //将obj变成不可枚举,解决如果data初始是对象,在调用walker方法进行观测时,内部的observe方法会对__ob__属性所代表的data对象无限调用 }) if(Array.isArray(data)){ this.observeArray(data);//监测数组对象中的变化 }else{ this.walker(data) } } walker(data){ //循环对象,对属性依次进行劫持 //重新定义属性,因为要重新构建所以性能很低 Object.keys(data).forEach(key => defineReactive(data, key, data[key])) } observeArray(data){//观测数组 data.forEach(item => observe(item)) }}export function defineReactive(target, key, value){//闭包 属性劫持 // debugger observe(value) //对多层对象递归进行属性劫持 Object.defineProperty(target, key, { get(){//取值的时候会执行get return value }, set(newV){ if(newV === value) return observe(newV) value = newV //Q_lys:这里有一点不是很明白,defineReactive中的参数value应该只在defineReactive的函数内部有效,为什么这里直接更改value会反向更改data中的值 // console.log('set data:', target) } })}export function observe(data){ // 对这个对象进行劫持 if(typeof data !== 'object' || data == null){ return; //只对对象进行劫持 } if(data.__ob__ instanceof Observer){//说明这个对象被代理过了 return data.__ob__ } // 如果一个对象被劫持过了,那就不需要再被劫持了(要判断一个对象是否被劫持过,可以增添一个实例,用实例来判断是否被劫持过) return new Observer(data)}
劫持数组观测
对Array的原型方法进行重写并返回,这里用到了一个很巧妙地方法,在Observer类中给data增添了一个__ob__属性,该属性存储的是Obsever实例对象,便于在array.js文件中能直接获取observe
方法进行数组观测
array.js
let oldArrayProto = Array.prototype;//获取数组的原型export let newArrayProto = Object.create(oldArrayProto)let methods = [//所有会修改原数组的方法 'push', 'pop', 'shift', 'reverse', 'sort', 'splice']methods.forEach(method => { newArrayProto[method] = function(...args){//重写数组方法 //这里指的arr const result = oldArrayProto[method].call(this, ...args)//内部调用原来的方法 函数劫持 切片编程 //对新增的数据进行劫持 let inserted; let ob = this.__ob__;//拿到Observer实例 switch(method){ case 'push': case 'unshift': inserted = args; break; case 'splice': //Array.splice(idx, 删除的个数, 新增内容) inserted = args.slice(2) default: break } if(inserted){//有新增的内容对新增数组进行观测 ob.observeArray(inserted) } return result }})
解析模板参数
el: ‘#app’ //将数据解析到el元素上
模板引擎 性能很差 正则匹配替换 vue1.0没有引入虚拟DOM的改变采用寻DOM,数据变化后比较虚拟DOM的差异,最后更新需要更新的地方核心就是需要将模板变成js语法,最后通过js语法生成虚拟DOM先变成语法树,再重新组装成新的语法,将temolate语法转换成render语法
注意: 有现成的包解析html,htmlparser2
正则表达式图形可视化网站
startTagOpen: ^<((?:[a-zA-Z_][\-\.0-9_a-zA-Z]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Z]*)>
匹配标签名,例如:<div,标签名不能以数字开头,还能匹配带命名空间的标签:<div:xxx>
endTag: /^<\/((?:[a-zA-Z_][\-\.0-9_a-zA-Z]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Z]*)[^>]*>/
属性匹配:/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>
]+)))?/`
第一个分组就是属性的key,value就是分组3/分组4/分组5
转换抽象语法树AST
对html进行解析时,不断解析到开始标签<div
、结束标签>
、属性style="color:red"
、文本内容{{name}}
(普通文本hello
),解析一段便删除一段,直到删完
html
<div id="app"> <div style="color:red;font-size:14px">{{name}} hello</div> <span>{{age}}</span></div>
解析开始标签及中间的属性和结束标签
function parseStartTag(){ const start = html.match(startTagOpen); // console.log(start) if(start){ const match = { tagName: start[1],//标签名 attrs: [] } advance(start[0].length); // 如果不是开始标签的结束,就一直匹配属性 let attr, end; 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] || true}) } if(end){//删掉结束标签> advance(end[0].length) } // console.log('match:',match) return match } return false; //不是开始标签 }
解析流程: 通过判断html中<
的位置,如果位置为0,可能是开始标签<div style="color:red">
或者是结束标签(开始标签和文本内容都截取完后,只剩下</div>
),而如果位置大于0,则说明是文本内容(hello</div>
)。如果开始标签解析出来有内容,则说明接下来要解析文本内容,跳过下面的结束标签解析判断,提升代码性能。同理如果是结束标签解析完毕,则不会再进行解析文本内容也直接continue.
while(html){ // <div>hello</div> // textEnd为0说明是一个开始标签或者结束标签 // 如果textEnd>0说明是文本的结束位置 let textEnd = html.indexOf('<'); //如果indexOf中的索引是0则说明是个标签 if(textEnd==0){ const startTagMathch = parseStartTag(); if(startTagMathch){//解析到的开始标签 start(startTagMathch.tagName, startTagMathch.attrs) continue } let endTagMatch = html.match(endTag); if(endTagMatch){ advance(endTagMatch[0].length); end(endTagMatch[1]) continue; } // break } if(textEnd>0){ let text = html.substring(0, textEnd);//文本内容 if(text){ chars(text) advance(text.length) } } }
AST语法树:利用栈形结构创造树,将开始标签入栈,并创建AST节点,并创建父亲儿子指向,遇到结束标签则出栈。文本直接放到当前指向的节点中
//最终需要转换成一颗抽象语法树 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, attr){//开始标签内容 let node = createASTElement(tag, attr);//创造一个ast节点 if(!root){ root = node;//如果root为空,则该节点为树的根节点 } if(currentParent){ node.parent = currentParent currentParent.children.push(node) } stack.push(node); currentParent = node;//currentParent为栈中最后一个 } function chars(text){//文本内容 text = text.replace(/\s/g, '') text && currentParent.children.push({ type: TEXT_TYPE, text, parent: currentParent }) } function end(tag){//可以校验标签是否合法 stack.pop(); currentParent = stack[stack.length-1] }
上面的HTML结构就会解析成如下的抽象语法树
代码生成
代码生成,将抽象语法树转换成render方法
_c(tag, attrs, children, text)
:这个函数是要创建一个tag的元素,并且它的属性是attrs,儿子是children,文本内容是text_s(变量)
:这个函数的作用是将要插值表达式中的变量{{name}}
转成字符串_v(text)
:这个函数的作用是要创建文本的 import { parseHTMl } from "./parse";function genProps(attrs){ let str = ''// for(let i=0;i<attrs.length;i++){ let attr = attrs[i]; let obj = {} if(attr.name === 'style'){ // color:red;background:red => {color:'red'} attr.value.split(';').forEach(el=>{//qs库 let [key, value] = el.split(':') obj[key] = value }) attr.value = obj } str += `${attr.name}:${JSON.stringify(attr.value)},` } return `{${str.slice(0,-1)}}`}const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g //{{any}}匹配到的内容就是表达式的变量function gen(node){ if(node.type === 1){ return codegen(node);//如果孩子是元素则调用codegen生成 }else{ let text = node.text if(!defaultTagRE.test(text)){//普通文本 return `_v(${JSON.stringify(text)})` }else{//文本内容是带有{{}} let tokens = [] let match; defaultTagRE.lastIndex = 0; let lastIndex = 0; while(match = defaultTagRE.exec(text)){ let index = match.index;//匹配的位置 {{name}} hello {{age}} hello if(index>lastIndex){//将中间hello匹配到 tokens.push(JSON.stringify(text.slice(lastIndex, index))) } tokens.push(`_s(${match[1].trim()})`) lastIndex = index + match[0].length } if(lastIndex < text.length){//将最后的hello匹配到 tokens.push(JSON.stringify(text.slice(lastIndex))) } return `_v(${tokens.join('+')})` } }}function genChildren(children){ return children.map(child => gen(child)).join(',')} function codegen(ast){ // debugger let children = genChildren(ast.children) let code = (`_c('${ast.tag}',${ast.attrs.length>0 ? genProps(ast.attrs) : 'null' }${ast.children.length ? `,${children}`:'' })`) return code}export function compileToFunction(template){ // console.log('template:',template) // 1.就是将template转换成ast语法树 let ast = parseHTMl(template); console.log('ast:', ast) //2.生成render方法,render方法执行后的返回结果就是虚拟DOM // _c('div', {id:"app"},_c('div', {style:{"color":"red","font-size":"14px"}},_v(_s(name)+"hello")),_c('span', null,_v(_s(age)))) let code = codegen(ast) // 模板引擎的实现原理 就是with+new Function code = `with(this){return ${code}}`; //对象属性直接变成with作用域下的变量 let render = new Function(code); // console.log('render:', render) return render}
实现虚拟DOM生成真实DOM
_c函数的具体实现:
// _c('div', attrs, ...childs) Vue.prototype._c = function(){ return createElementVNode(this, ...arguments) } export function createElementVNode(vm, tag, data={}, ...children){ if(!data){ data = {} } let key = data.key; if(key){ delete data.key } return vnode(vm,tag, key, data, children)}
_s函数的具体实现:
Vue.prototype._s = function(value){ if(typeof value !== 'object') return value return JSON.stringify(value) }
_v函数的具体实现:
//_v(text) Vue.prototype._v = function(){ return createTextVNode(this, ...arguments) } export function createTextVNode(vm, text){ return vnode(vm, undefined, undefined, undefined, undefined, text)}
vnode:
function vnode(vm, tag, key, data, children, text){//这里的data就是ast中的属性 return { vm,tag,key,data,children, text }}
通过上面的操作可以将render函数产生虚拟节点,下面将根据生成的虚拟节点创造真实DOM,
patch函数: oldVNode如果是初渲染状态就是el挂载的节点,如果是更新状态就是待更新节点,vnode虚拟节点。根据虚拟节点生成真实节点,将原来的oldVNode删除,并在下面插入真实节点function patch(oldVNode, vnode){ const isRealElement = oldVNode.nodeType; if(isRealElement){//初渲染流程 const elm = oldVNode //获取真实元素 const parentElm = elm.parentNode; //获取父元素 let newElm = createElm(vnode) console.log("newElm:", newElm) parentElm.insertBefore(newElm, elm.nextSibling);//先在老节点下面插入新节点 parentElm.removeChild(elm);//删除老节点 }else{ //diff算法 }}
createElm(vnode):根据虚拟节点创造真实节点,并在虚拟节点上挂载真实节点,方便后续更新 function createElm(vnode){ let {tag, data, children, text} = vnode; if(typeof tag === 'string'){//标签 vnode.el = document.createElement(tag)//虚拟节点上挂载了真实节点 patchProps(vnode.el, data); children.forEach(child => { vnode.el.appendChild(createElm(child)) }); }else{ vnode.el = document.createTextNode(text) } return vnode.el}function patchProps(el, props){ for(let key in props){ if(key === 'style'){ for(let styleName in props.style){ el.style[styleName] = props.style[styleName]; } }else{ el.setAttribute(key, props[key]); } }}