手写Vue2
使用rollup搭建开发环境
使用rollup打包第三方库会比webpack更轻量,速度更快
首先安装依赖
npm init -ynpm install rollup rollup-plugin-babel @babel/core @babel/preset-env --save-dev
然后添加 rollup 的配置文件 rollup.config.js
import babel from "rollup-plugin-babel"export default {input:"./src/index.js", // 配置入口文件output:{file:"./desc/vue.js", // 配置打包文件存放位置以及打包后生成的文件名name:"Vue",// 全局挂载一个Vue变量format:"umd", // 兼容esm es6模块sourcemap:true, // 可以调试源代码},plugins:[babel({exclude:"node_modules/**", // 排除node_modules文件夹下的所有文件})]
}
添加 babel 的配置文件 .babelrc
{"presets": ["@babel/preset-env"]
}
修改 package.json
{"name": "vue2","version": "1.0.0","description": "","main": "index.js","scripts": {"dev": "rollup -cw"},"keywords": [],"author": "","license": "ISC","devDependencies": {"@babel/core": "^7.23.2","@babel/preset-env": "^7.23.2","rollup": "^4.3.0","rollup-plugin-babel": "^4.4.0"},"type": "module"
}
记得在 package.json
后面添加 "type": "module"
,否则启动时会提示 import babel from "rollup-plugin-babel"
错误
准备完成后运行启动命令
npm run dev
出现上图表示启动成功,并且正在监听文件变化,文件变化后会自动重新打包
查看打包出来的文件
然后新建一个 index.html
引入打包出来的 vue.js
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Title</title><script src="./vue.js"></script>
</head>
<body>
<script>console.log(Vue)
</script>
</body>
</html>
访问这个文件,并打开控制台查看打印
至此,我们准备工作完成,接下来开始实现Vue核心部分。
初始化数据
修改 src/index.js
import {initMixin} from "./init";function Vue(options){this._init(options)
}initMixin(Vue)export default Vue
添加 init.js
,用于初始化数据操作,并导出 initMixin 方法
import {initStatus} from "./state.js";export function initMixin(Vue){// 给Vue原型添加一个初始化方法Vue.prototype._init = function (options){const vm = thisvm.$options = options// 初始化状态initStatus(vm)}
}
state.js
的写法
export function initStatus(vm){const opt = vm.$optionsif(opt.data){initData(vm)}
}function initData(vm){let data = vm.$options.datadata = typeof data === "function" ? data.call(vm) : dataconsole.log(data)
}
我们打开控制台查看打印的东西
可以发现已经正确的得到data数据
实现对象的响应式
现在我们在 initData 方法中拿到了data,接下来就是对data中的属性进行数据劫持
在 initData 中添加 observe 方法,并传递data对象
function initData(vm){let data = vm.$options.datadata = typeof data === "function" ? data.call(vm) : data// 拿到数据开始进行数据劫持,把数据变成响应式的observe(data)
}
新建 observe/index.js
文件,实现 observe 方法
class Observer{constructor(data) {this.walk(data)}walk(data){// 循环对象中的每一个属性进行劫持Object.keys(data).forEach(key=>{defineReactive(data,key,data[key])})}
}export function defineReactive(target,key,value){// 这里对当前的值进行判断,如果值还是一个对象,则递归继续进行深度劫持observe(value)// Object.defineProperty 接收三个参数,第一个是对象,第二是要劫持的key,第三个是一个对象,里面有get和set方法// 当读取这个key是,会触发get方法,当设置key时,会触发set方法,并接收新值Object.defineProperty(target,key,{get(){return value},set(newValue){if(newValue === value) returnvalue = newValue}})
}export function observe(data){// 对这个对象进行劫持,需要判断一下是否是一个对象,如果不是一个对象不能进行劫持if(typeof data !== "object" || data === null) returnreturn new Observer(data)
}
现在对数据就劫持完成了,但是我们如何获取呢?我们可以吧data方法返回的对象挂载到Vue的实例上即可
还是在 initData 方法内添加代码,并且增加一个 proxy 方法,让我们可以通过 vm.xxx 的方式直接获取data中的属性值
function initData(vm){let data = vm.$options.datadata = typeof data === "function" ? data.call(vm) : data// 拿到数据开始进行数据劫持,把数据变成响应式的observe(data)// 吧data方法返回的对象挂载到Vue的实例上vm._data = data// 目前取值必须通过 vm._data.xxx 的方式来获取值或者设置值// 如果想直接通过 vm.xxx 的方式来设置值,则必须对vm再进行一次代理proxy(vm,"_data")
}function proxy(target,key){for (const dataKey in target[key]) {Object.defineProperty(target, dataKey,{get(){return target[key][dataKey]},set(newValue){target[key][dataKey] = newValue}})}
}
现在来打印一下 vm
通过打印发现,vm 自身上就有了data中定义的属性
并且直接通过 vm 来读取和设置属性值也是可以的
实现数组的响应式
实现思路:
- 首先遍历数组中的内容,吧数组中的数据变成响应式的
- 如果调用的数组中的方法,添加了新的数据,则也要吧新的数据变成响应式的,这里可以劫持7个变异方法来实现
首先在 Observer 类中添加判断,如果data是一个数组,则单独走一个observeArray方法,来实现对数组的响应式处理
import {newArrayProperty} from "./array.js";class Observer{constructor(data) {// 定义一个__ob__,值是this,不可枚举// 给数据加了一个标识,表示这个数据是一个已经被响应式了的Object.defineProperty(data,"__ob__",{// 定义这个属性值是当前的实例value:this,// 定义__ob__不能被遍历,否则会引起死循环// 原因:在walk方法中会递归遍历对象中的每一个属性进行响应式处理,因为__ob__表示的当前对象的实例// 实例本身又包含__ob__,这样就会导致递归无限往里面找,就造成了死循环,// 所以这里要设置成 enumerable:false,不能遍历 __ob__ 这个属性enumerable:false})// 如果data中的某个值定义的是一个数组,则对数组进行劫持,进行响应式处理if(Array.isArray(data)){data.__proto__ = newArrayPropertythis.observeArray(data)}else{this.walk(data)}}walk(data){// 循环对象中的每一个属性进行劫持Object.keys(data).forEach(key=>{defineReactive(data,key,data[key])})}// 对数组中的数据进行响应式处理observeArray(data){data.forEach(item=>observe(item))}
}
这里在 data 中定义了 __ob__
属性,并且值等于当前的 Observer 实例,是为了在 array.js 中拿到 Observe 实例中的 observeArray 方法,来实现对新传递进来的数据进行响应式处理
既然有了这个 __ob__
属性,我们就可以判断一下,如果 data 中有了 __ob__
属性,则表示这个数据已经被响应式了,则不需要进行再次响应式,所以我们可以在 observe 方法中加一个判断
export function observe(data){// 对这个对象进行劫持,需要判断一下是否是一个对象,如果不是一个对象不能进行劫持if(typeof data !== "object" || data === null) return// 如果这个对象已经被代理过了,则直接返回当前示例if(data.__ob__){return data.__ob__}return new Observer(data)
}
然后下面是 array.js 的实现代码
// 获取原始的数组原型链
let oldArrayProperty = Array.prototype
// 复制一份出来,到新的对象中
export let newArrayProperty = Object.create(oldArrayProperty)// 声明数组的变异方法有哪些
let methods = ["push", "pop", "unshift", "shift", "reserve", "sort", "splice"]methods.forEach(method => {// 调用新的方法时,接收传递进来的参数,然后再调用一下原来的newArrayProperty[method] = function (...args) {// 判断如果是下面的几个方法,则要对传递进来的参数继续进行响应式处理let inserted;// 这里的this指向的是函数的调用者,所以这里的this指向的是data,也就是在Observer类中接收到data// 恰好我们给data的__ob__属性设置了值,等于Observe实例,// 利用这点就可以拿到Observe中的observeArray方法,来对新数据进行响应式处理let ob = this.__ob__switch (method) {case "push":case "unshift":case "shift":inserted = args;breakcase "splice":inserted = args.slice(2)breakdefault:break;}if(inserted){// 对传递进来的参数继续进行响应式处理ob.observeArray(inserted)}// 将结果返回return oldArrayProperty[method].call(this, ...args);}
})
现在看一下效果
可以看到我们新 push 的数据也被响应式了
解析HTML模板
我们可以根据option中的el来获取到根标签,然后获取对应的html,拿到html后开始解析
先写一些测试代码,准备一个html页面
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Title</title><script src="./vue.js"></script>
</head>
<body><div id="app"><div style="font-size: 15px;color: blue" data-name = "123">{{name}}</div><div style="color: red">{{age}}</div></div>
</body>
<script>const vm = new Vue({el:"#app",data(){return{name:"szx",age:18,address:{price:100,name:"少林寺"},hobby:['each','write',{a:"tome"}]}}})
</script>
</html>
然后来到 init.js 中的 initMixin 方法,判断一下是否有 el 这个属性,如果有则开始进行模板解析
import {initStatus} from "./state.js";
import {compilerToFunction} from "./compile/index.js";export function initMixin(Vue){// 给Vue原型添加一个初始化方法Vue.prototype._init = function (options){const vm = thisvm.$options = options// 初始化状态initStatus(vm)// 解析模板字符串if(vm.$options.el){vm.$mount(vm.$options.el)}}// 在原型链上添加$mount方法,用户获取页面模板Vue.prototype.$mount = function (el){let template;const vm = thisconst opts = vm.$options// 判断配置中是否已经存在template,如果没有,则根据el获取页面模板if(!opts.render){if(!opts.template && opts.el){// 拿到模板字符串template = document.querySelector(el).outerHTML}if(opts.template){template = opts.template}if(template){// 这里拿到模板开始进行模板编译const render = compilerToFunction(template)}}// 有render函数后再执行后续操作}
}
下面就是 compilerToFunction
的代码
const unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 匹配到的是结束标签的名字 </xxx>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
// 匹配属性的正则,第一个分组是key,value则是分组3/分组4/分组5
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 匹配自闭和的标签
const startTagClose = /^\s*(\/?)>/
// 匹配花括号中的的内容
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/gfunction parseHtml(html){// 前进function advance(n){html = html.substring(n)}function parseStart(){// 匹配开始标签let start = html.match(startTagOpen)if(start){const match = {// 获取到标签名tagName:start[1],attrs:[]}// 截取已经匹配到的内容advance(start[0].length)// 循环匹配剩下的属性,如果不是开始标签的结束位置,则开始匹配属性let attr,end;while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))){match.attrs.push({name:attr[1],value:attr[3] || attr[4] || attr[5] || true})// 匹配到一点后就删除一点advance(attr[0].length)}if(end){advance(end[0].length)}console.log(match)return match}return false}while (html){let textEnd = html.indexOf('<');// 判断做尖括号的位置,如果是0表示这是一个开始标签if(textEnd === 0){// 匹配开始标签const startTagMatch = parseStart()if(startTagMatch){continue}// 匹配结束标签let endTagMatch = html.match(endTag)if(endTagMatch){advance(endTagMatch[0].length)continue}}// 进入到这里说明匹配到了文本:{{ xxx }}if(textEnd > 0){let text = html.substring(0,textEnd)if(text){advance(text.length)}}}console.log(html)return ""
}export function compilerToFunction(template){let ast = parseHtml(template)return ""
}
这段代码在不断的解析html内容,匹配到开始标签,就会标签名称和属性放在match数组中,并且删除一已经匹配到的内容,如果匹配到文本或者结束版本则删除匹配到的内容,最终html变成空,表示解析过程就结束了。
我们通过打印看一下html被解析的过程
可以看到html的内容再不断减少
接下来,我们只需要在这些方法中添加如果匹配到开始标签,就触发一个方法处理开始标签的内容,如果匹配到文本,就处理文本内容,如果匹配到结束标签,就处理结束标签的内容。
在 parseHtml 方法中添加三个方法如下,分别处理开始标签,文本,结束标签
- onStartTag
- onText
- onCloseTag
const unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 匹配到的是结束标签的名字 </xxx>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
// 匹配属性的正则,第一个分组是key,value则是分组3/分组4/分组5
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 匹配自闭和的标签
const startTagClose = /^\s*(\/?)>/
// 匹配花括号中的的内容
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/gfunction parseHtml(html){// 处理开始标签function onStartTag(tag,attrs){console.log(tag,attrs)console.log("开始标签")}// 处理文本标签function onText(text){console.log(text)console.log("文本")}// 处理结束标签function onCloseTag(tag){console.log(tag)console.log("结束标签")}// 前进function advance(n){html = html.substring(n)}function parseStart(){// 匹配开始标签let start = html.match(startTagOpen)if(start){const match = {// 获取到标签名tagName:start[1],attrs:[]}// 截取已经匹配到的内容advance(start[0].length)// 循环匹配剩下的属性,如果不是开始标签的结束位置,则开始匹配属性let attr,end;while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))){match.attrs.push({name:attr[1],value:attr[3] || attr[4] || attr[5] || true})// 匹配到一点后就删除一点advance(attr[0].length)}if(end){advance(end[0].length)}return match}return false}while (html){let textEnd = html.indexOf('<');// 判断做尖括号的位置,如果是0表示这是一个开始标签if(textEnd === 0){// 匹配开始标签const startTagMatch = parseStart()if(startTagMatch){onStartTag(startTagMatch.tagName,startTagMatch.attrs)continue}// 匹配结束标签let endTagMatch = html.match(endTag)if(endTagMatch){onCloseTag(endTagMatch[1])advance(endTagMatch[0].length)continue}}// 进入到这里说明匹配到了文本:{{ xxx }}if(textEnd > 0){let text = html.substring(0,textEnd)if(text){onText(text)advance(text.length)}}}console.log(html)return ""
}export function compilerToFunction(template){let ast = parseHtml(template)return ""
}
并在在相对应的代码中调用者三个方法
查看打印效果
接下来构建语法树
function parseHtml(html){const ELEMENT_TYPE = 1 // 标记这是一个元素const TEXT_TYPE = 3 // 标记这是一个文本const stack = [] // 声明一个栈let currentParent;let root;function createNode(tag,attrs){return{tag,attrs,type:ELEMENT_TYPE,children:[],parent:null}}// 处理开始标签function onStartTag(tag,attrs){let node = createNode(tag,attrs)if(!root){root = node}if(currentParent){node.parent = currentParentcurrentParent.children.push(node)}stack.push(node)currentParent = node}// 处理文本标签function onText(text){text = text.replace(/\s/g,"")text && currentParent.children.push({type:TEXT_TYPE,text,parent:currentParent})}// 处理结束标签function onCloseTag(tag){stack.pop()currentParent = stack[stack.length -1]}// ...省略其他代码return root
}
然后打印一下生成的ast语法树
export function compilerToFunction(template){let ast = parseHtml(template)console.log(ast)return ""
}
代码生成的实现原理
现在我们已经得到了AST语法树,接下来我们就需要根据得到的AST语法树转化成一段cvs字符串
- _c 表示创建元素
- _v 表示处理文本内容
- _s 表示处理花括号包裹的文本
这里提前吧 parseHtml 方法抽离出来放在 parse.js 文件中并导出
然后在 compilerToFunction 方法中添加 codegen 方法,根据 ast 语法树生成字符串代码
import {parseHtml,ELEMENT_TYPE} from "./parse.js";function genProps(attrs){let str = ``attrs.forEach(attr=>{// 遍历行内元素,变成 {id:"app",style:{"color":"red","font-size":"20px"}} 这种效果if(attr.name === "style"){let obj = {}attr.value.split(";").forEach(sItem=>{let [key,value] = sItem.split(":")obj[key] = value.trim()})attr.value = obj}str += `${JSON.stringify(attr.name)}:${JSON.stringify(attr.value)},`})str = `{${str.slice(0,-1)}}`return str
}function genChildren(children){return children.map(child=>gen(child)).join(",")
}// 匹配花括号中的的内容
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;function gen(child) {// 匹配到的是一个元素if (child.type === ELEMENT_TYPE) {return codegen(child)} else {let text = child.text// 匹配到的是一个纯文本,不是用花括号包裹起来的文本时,会走下面的方法,返回一个 _v 函数,用于创建文本节点if (!defaultTagRE.test(text)) {return `_v(${JSON.stringify(text)})`} else {// 如果这个文本元素包含有花括号包裹的数据,就会走else方法let token = []let match;let lastIndex = 0// 设置正则的lastIndex从0开始,也就是从文本的最前面开始进行匹配defaultTagRE.lastIndex = 0// exec方法会匹配到一个花括号数据时就会走一次循环,然后继续往后匹配剩余的花括号// 只到匹配结束,会返回null,然后退出循环while (match = defaultTagRE.exec(text)) {// match.index返回的是当前匹配到的数据的下标let index = match.index// 如果出现匹配到的数据下标大于lastIndex下标,则表示如下情况// hello {{ age }},表示这个花括号前面还有文本,我们需要先把花括号前面的文本放在token中if (index > lastIndex) {token.push(JSON.stringify(text.slice(lastIndex, index)))}// 然后再吧花括号里面的变量放在token中,并且用_s函数接收这个变量token.push(`_s(${match[1].trim()})`)// 更新lastIndexlastIndex = index + match[0].length}// 当exec匹配完成后,出现lastIndex < text.length的情况// 表示最后一个花括号后面还有普通文本,例如:{{ age }} word,则我们需要吧后面的文本也放在token中if (lastIndex < text.length) {token.push(JSON.stringify(text.slice(lastIndex)))}// 最后返回一个 _v() 函数,里面的token使用+拼接返回return `_v(${token.join("+")})`}}
}function codegen(ast) {// _c("div",{xxx:xxx,xxx:xxx})// 第一个参数是需要创建的元素,第二个是对应的属性let code = `_c(${JSON.stringify(ast.tag)},${ast.attrs.length ? genProps(ast.attrs) : "null"},${ast.children.length ? genChildren(ast.children) : "null"})`return code
}export function compilerToFunction(template) {// 1. 解析DOM,转化成AST语法树let ast = parseHtml(template)// 2. 根据AST语法树生成cvs字符串let cvs = codegen(ast)console.log(cvs)return ""
}
效果如下图
生成render函数
现在我们得到了一个字符串,并不是一个函数,下面就是要把这个字符串变成一个render函数
export function compilerToFunction(template) {// 1. 解析DOM,转化成AST语法树let ast = parseHtml(template)// 2. 根据AST语法树生成cvs字符串let code = codegen(ast)// 3.根据字符串生成render方法code = `with(this){return ${code}}`let render = new Function(code)console.log(render.toString())return render
}
这里的 with 方法可以自动从 this 中读取变量值
with (object) {// 在此作用域内可以直接使用 object 的属性和方法// 无需重复引用 object// 例如:// property1 // 相当于 object.property1// method1() // 相当于 object.method1()// ...// 注意:如果 object 中不存在某个属性或方法,会向上级作用域查找// 如果上级作用域也找不到,则会抛出 ReferenceError// 在严格模式下,不允许使用 with 语句
}
简单示例
let testObj = {name:"Tome",age:18
}
with (testObj) {console.log(name + age); // 输出:Tome18
}
现在有了render函数后,将render 返回并添加到 $options 中
在 initMixin 方法中添加
import {initStatus} from "./state.js";
import {compilerToFunction} from "./compile/index.js";
import {mountComponent} from "./lifecycle.js";export function initMixin(Vue){// 给Vue原型添加一个初始化方法Vue.prototype._init = function (options){const vm = thisvm.$options = options// 初始化状态initStatus(vm)// 解析模板字符串if(vm.$options.el){vm.$mount(vm.$options.el)}}// 在原型链上添加$mount方法,用户获取页面模板Vue.prototype.$mount = function (el){let template;const vm = thisconst opts = vm.$options// 判断配置中是否已经存在template,如果没有,则根据el获取页面模板if(!opts.render){if(!opts.template && opts.el){// 拿到模板字符串template = document.querySelector(el).outerHTML}if(opts.template){template = opts.template}if(template){// 这里拿到模板开始进行模板编译opts.render = compilerToFunction(template)}}// 有了render函数,开始对组件进行挂载mountComponent(vm,el)}
}
添加 mountComponent 方法,新建一个文件,单独写个这个方法
lifecycle.js
/*** Vue 核心流程* 1.创造了响应式数据* 2.根据模板转化成ast语法树* 3.将ast语法树转化成render函数* 4.后续每次更新数据都只执行render函数,自动更新页面*/export function initLifeCycle(Vue){Vue.prototype._render = function (){}Vue.prototype._update = function (){}
}// 挂载页面
export function mountComponent(vm,el){vm.$el = el// 1.调用render方法产生虚拟节点vm._update(vm._render())// 2.根据虚拟DOM产生真实DOM// 3.插入到el中去
}
initLifeCycle 方法需要接收一个 Vue,我们可以在 index.js 文件中添加调用
import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";function Vue(options){this._init(options)
}initMixin(Vue)
initLifeCycle(Vue)export default Vue
创建虚拟节点并更新视图
完善 lifecycle.js 文件的代码
/*** Vue 核心流程* 1.创造了响应式数据* 2.根据模板转化成ast语法树* 3.将ast语法树转化成render函数* 4.后续每次更新数据都只执行render函数,自动更新页面*/
import {createElementVNode, createTextVNode} from "./vdom/index.js";export function initLifeCycle(Vue){// _c 的返回值就是 render 函数的返回值Vue.prototype._c = function (){// 创建一个虚拟DOMreturn createElementVNode(this,...arguments)}// _v 的返回值给 _c 使用Vue.prototype._v = function (){return createTextVNode(this,...arguments)}// _s 的返回值给 _v 使用Vue.prototype._s = function (value){return value}// _render函数的返回值会作为参数传递给 _updateVue.prototype._render = function (){return this.$options.render.call(this)}// 更新视图方法Vue.prototype._update = function (vnode){// 获取当前的真实DOMconst elm = document.querySelector(this.$options.el)patch(elm,vnode)}
}function patch(oldVNode,newVNode){// 判断是否是一个真实元素,如果是真实DOM会返回1const isRealEle = oldVNode.nodeType;if(isRealEle){// 获取真实元素const elm = oldVNode// 获取真实元素的父元素const parentElm = elm.parentNode// 创建新的虚拟节点let newRealEl = createEle(newVNode)// 把新的虚拟节点插入到真实元素后面parentElm.insertBefore(newRealEl,elm.nextSibling)// 然后删除之前的DOMparentElm.removeChild(elm)}
}function createEle(vnode){let {tag,data,children,text} = vnodeif (typeof tag === "string"){vnode.el = document.createElement(tag)Object.keys(data).forEach(prop=>{if(prop === "style"){Object.keys(data.style).forEach(sty=>{vnode.el.style[sty] = data.style[sty]})}else{vnode.el.setAttribute(prop,data[prop])}})// 递归处理子元素children.forEach(child=>{vnode.el.appendChild(createEle(child))})}else{// 当时一个文本元素是,tag是一个undefined,所以会走elsevnode.el = document.createTextNode(text)}return vnode.el
}// 挂载页面
export function mountComponent(vm,el){vm.$el = el// 1.调用render方法产生虚拟节点// 2.根据虚拟DOM产生真实DOM// 3.插入到el中去vm._update(vm._render())
}
vnode/index.js
// 创建虚拟节点
export function createElementVNode(vm,tag,prop,...children){if(!prop){prop = {}}let key = prop.keyif(key){delete prop.key}return vnode(vm,tag,prop,key,children,undefined)
}// 创建文本节点
export function createTextVNode(vm,text){return vnode(vm,undefined,undefined,undefined,undefined,text)
}function vnode(vm,tag,data,key,children,text){children = children.filter(Boolean)return {vm,tag,data,key,children,text}
}
此时我们的页面就可以正常显示数据了
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Title</title><script src="./vue.js"></script>
</head>
<body><div id="app" style="color: red;font-size: 20px"><div style="font-size: 15px;color: blue" data-name = "123">你好 {{ name }} hello {{ age }} word</div><span>{{ address.name }}</span></div>
</body>
<script>const vm = new Vue({el:"#app",data(){return{name:"szx",age:18,address:{price:100,name:"少林寺"},hobby:['each','write',{a:"tome"}]}}})
</script>
</html>
实现依赖收集
现在初次渲染已经可以吧页面上绑定的数据渲染成我们定义的数据,但是当我们改变data数据时,页面不会发生更新,这里就要使用观察者模式,实现依赖收集。
读取某个属性时,会调用get方法,在get方法中收集watcher(观察者),然后当更新数据时,会调用set方法,通知当前这个属性绑定的观察者去完成更新视图的操作。
在get方法中收集watcher的同时,watcher也要收集这个属性(dept),要知道我当前的这个watcher下面有几个dept。
一个dept对应多个watcher,因为一个属性可能会在多个视图中使用
一个watcher对应多个dept,因为一个组件中会有多个属性
下面的代码实现逻辑
修改 lifecycle.js
文件中的 mountComponent
方法
// 挂载页面
export function mountComponent(vm,el){vm.$el = el// 1.调用render方法产生虚拟节点// 2.根据虚拟DOM产生真实DOM// 3.插入到el中去const updateComponent = ()=>{vm._update(vm._render())}// 初始化渲染new Watcher(vm,updateComponent)
}
新建 observe/watcher.js
import Dep from "./dep.js";let id = 0class Watcher{constructor(vm,fn) {this.id = id++ // 唯一IDthis.getter = fn // 这里存放多个dep,一个Watcher对应多个depsthis.depts = []this.deptSet = new Set()this.get()}get(){// 在调用getter之前,吧当前的Watcher实例放在Dep全局上Dep.target = this// 调用getter就相当于调用的render函数,调用了render函数就会触发每个属性的get方法this.getter()// 调用完getter之后,把Dep.target置为nullDep.target = null}// watcher也要知道我自己下面有几个dept,所以这里要收集一下addDep(dep){// 判断dep的id不能重复if(!this.deptSet.has(dep.id)){this.depts.push(dep)this.deptSet.add(dep.id)dep.addSubs(this)}}// 更新视图update(){this.get()}
}export default Watcher
对应的 observe/dep.js
let id = 0
class Dep{constructor() {this.id = id++this.subs = [] // 存放当前这个属性对应的多个watcher}depend(){// Dep.target 就是当前的 watcher// 让watcher记住当前的depDep.target.addDep(this)}// 当在watcher函数中添加好dep后会调用dep的addSubs方法,在dep中再保存一下watcheraddSubs(watcher){this.subs.push(watcher)}// 更新属性时更新这个属性对应的dep上的notify方法,会遍历这个dep对应的所有的watcher进行更新视图notify(){this.subs.forEach(watcher => watcher.update())}
}
export default Dep
dep 就是被观察者,watcher 就是观察者,在属性的 get 和 set 方法中进行依赖收集和更新通知
修改 observe/index.js
的 defineReactive
方法
export function defineReactive(target,key,value){// 这里对当前的值进行判断,如果值还是一个对象,则递归继续进行深度劫持observe(value)// Object.defineProperty 接收三个参数,第一个是对象,第二是要劫持的key,第三个是一个对象,里面有get和set方法// 当读取这个key是,会触发get方法,当设置key时,会触发set方法,并接收新值let dep = new Dep();Object.defineProperty(target,key,{get(){// 进行依赖收集,收集这个属性的观察者(watcher)if(Dep.target){dep.depend()}return value},set(newValue){if(newValue === value) returnvalue = newValue// 通知观察者更新视图dep.notify()}})
}
测试视图更新
const vm = new Vue({el:"#app",data(){return{name:"szx",age:18,address:{price:100,name:"少林寺"},hobby:['each','write',{a:"tome"}]}}
})function addAge(){vm.name = "李四"vm.age = 20
}
我们发现,点击更新按钮后视图确实发生了更新。但是控制台打印了两次更新。这是因为我们在 addAge 方法中对两个属性进行了更改,所以触发了两次更新。下面我们来解决这个问题,让他只触发一次更新
实现异步更新
修改 Watch 中的 update 方法,同时新增一个 run 方法,专门用于更新视图操作
import Dep from "./dep.js";let id = 0class Watcher{constructor(vm,fn) {this.id = id++ // 唯一IDthis.getter = fn // 这里存放多个dep,一个Watcher对应多个depsthis.depts = []this.deptSet = new Set()this.get()}get(){// 在调用getter之前,吧当前的Watcher实例放在Dep全局上Dep.target = this// 调用getter就相当于调用的render函数,调用了render函数就会触发每个属性的get方法this.getter()// 调用完getter之后,把Dep.target置为nullDep.target = null}// watcher也要知道我自己下面有几个dept,所以这里要收集一下addDep(dep){// 判断dep的id不能重复if(!this.deptSet.has(dep.id)){this.depts.push(dep)this.deptSet.add(dep.id)dep.addSubs(this)}}// 更新视图update(){// 实现异步更新,将多个watcher放在一个队列中,然后写一个异步任务实现异步更新queueWatcher(this)}run(){console.log('更新视图')this.getter()}
}let queue = []
let watchObj = {}
let padding = false
function queueWatcher(watcher){if(!watchObj[watcher.id]){watchObj[watcher.id] = truequeue.push(watcher)// 执行多次进行一个防抖if(!padding){// 等待同步任务执行完再执行异步更新nextTick(flushSchedulerQueue,0)padding = true}}
}function flushSchedulerQueue(){let flushQueue = queue.slice(0)queue = []watchObj = {}padding = falseflushQueue.forEach(cb=>cb.run())
}let callbacks = []
let waiting = false
export function nextTick(cb){callbacks.push(cb)if(!waiting){// setTimeout(()=>{// // 依次执行回调// flushCallback()// })// 使用 Promise.resolve进行异步更新Promise.resolve().then(flushCallback)waiting = true}
}function flushCallback(){let cbs = callbacks.slice(0)callbacks = []waiting = falsecbs.forEach(cb=>cb())
}export default Watcher
在 src/index.js
中挂载全局的 $nexitTick 方法
import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";
import {nextTick} from "./observe/watcher.js";function Vue(options){this._init(options)
}Vue.prototype.$nextTick = nextTickinitMixin(Vue)
initLifeCycle(Vue)export default Vue
页面使用
function addAge(){vm.name = "李四"vm.age = 20vm.$nextTick(()=>{console.log(document.querySelector("#name").innerText)})
}
点击更新按钮执行 addAge 方法,可以在控制台看到只触发了一个更新视图,并且获取的页面也是更新后的
实现mixin核心功能
mixin的核心是合并对象,将Vue.mixin中的对象和在Vue中定义的属性进行合并,然后再初始化状态前后调用不同的Hook即可
首先在 index.js 中添加方法调用
index.js
文件增加 initGlobalApi,传入 Vue
import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";
import {nextTick} from "./observe/watcher.js";
+ import {initGlobalApi} from "./globalApi.js";function Vue(options){this._init(options)
}Vue.prototype.$nextTick = nextTickinitMixin(Vue)
initLifeCycle(Vue)
+ initGlobalApi(Vue)export default Vue
globalApi.js
内容如下
import {mergeOptions} from "./utils.js";export function initGlobalApi(Vue) {// 添加一个静态方法 mixinVue.options = {}Vue.mixin = function (mixin) {this.options = mergeOptions(this.options, mixin)return this}
}
utils.js
中实现 mergeOptions 方法
// 定义一些策略,例如 created,beforeCreated 等,不需要写大量的if判断
const strats = {}
const LIFECYCLE = ["beforeCreated","created"
]LIFECYCLE.forEach(key => {strats[key] = function (p, c) {if (c) {if (p) {return p.concat(c)} else {return [c]}} else {return p}}
})
// 合并属性的方法
export function mergeOptions(parent, child) {const options = {}// 先获取父亲的值for (const key in parent) {mergeField(key)}for (const key in child) {// 如果父亲里面没有这个子属性,在进行合并子的/*** 示例:父亲:{a:1} 儿子:{a:2}* 儿子中也有父亲的属性a,所以不会走儿子中的合并方法,但是在取值的时候,优先取的是儿子身上的属性值* 所以合并到一个对象中时,儿子会覆盖父亲*/if (!parent.hasOwnProperty(key)) {mergeField(key)}}function mergeField(key) {if (strats[key]) {// {created:fn} {}// 合并声明周期上的方法,例如:beforeCreated,createdoptions[key] = strats[key](parent[key], child[key])} else {// 先拿到儿子的值options[key] = child[key] || parent[key]}}return options
}
然后在 init.js 中进行属性合并和Hook调用
import {initStatus} from "./state.js";
import {compilerToFunction} from "./compile/index.js";
import {mountComponent} from "./lifecycle.js";
+import {mergeOptions} from "./utils.js";export function initMixin(Vue){// 给Vue原型添加一个初始化方法Vue.prototype._init = function (options){const vm = this
+ // this.constructor就是当前的大Vue,获取的是Vue上的静态属性
+ // this.constructor.options 拿到的就是mixin合并后的数据
+ // 然后再把用户写的options和mixin中的进行再次合并
+ vm.$options = mergeOptions(this.constructor.options,options)
+ // 初始化之前调用beforeCreated
+ callHook(vm,"beforeCreated")
+ // 初始化状态
+ initStatus(vm)
+ // 初始化之后调用created
+ callHook(vm,"created")// 解析模板字符串if(vm.$options.el){vm.$mount(vm.$options.el)}}// 在原型链上添加$mount方法,用户获取页面模板Vue.prototype.$mount = function (el){let template;const vm = thisel = document.querySelector(el)const opts = vm.$options// 判断配置中是否已经存在template,如果没有,则根据el获取页面模板if(!opts.render){if(!opts.template && opts.el){// 拿到模板字符串template = el.outerHTML}if(opts.template){template = opts.template}if(template){// 这里拿到模板开始进行模板编译opts.render = compilerToFunction(template)}}// 有了render函数,开始对组件进行挂载mountComponent(vm,el)}
}+function callHook(vm,hook){
+ // 拿到用户传入的钩子函数
+ const handlers = vm.$options[hook]
+ if(handlers){
+ // 遍历钩子函数,执行钩子函数
+ for(let i=0;i<handlers.length;i++){
+ handlers[i].call(vm)
+ }
+ }
+}
测试 Vue.mixin
// Vue内部会把minix进行合并,如果有两个created会合并成一个created数组,里面有两个方法,然后依次执行
Vue.mixin({beforeCreated() {console.log("beforeCreated")},created() {console.log(this.name,"--mixin")},
})const vm = new Vue({el: "#app",data() {return {name: "szx",age: 18,address: {price: 100,name: "少林寺"},hobby: ['each', 'write', {a: "tome"}]}},created() {console.log(this.name,"--vue")}
})
查看控制台打印
实现数组更新
我们之前给每一个属性都加了一个dep,实现依赖收集,但是如果这个属性值是一个对象类型的话,当我们不改变这个属性的引用地址,只是改变对象属性值,比如给数组push一个数据,不会改变原来的引用地址。这样的话页面就无法实现更新。
我们可以判断一下,当属性值是一个对象类型的时候,给这个对象本身也添加一个dep,当读取这个属性值的时候,进行一下依赖收集,如果是一个数组的话,当调用完push等方法时,在我们重写的方法哪里再执行一个更新就可以了。
下面是代码实现
修改 observe/index.js
import {newArrayProperty} from "./array.js";
import Dep from "./dep.js";class Observer{constructor(data) {// 给对象类型的数据加一个 Dep 实例
+ this.dep = new Dep()// 定义一个__ob__,值是this,不可枚举// 给数据加了一个标识,表示这个数据是一个已经被响应式了的Object.defineProperty(data,"__ob__",{// 定义这个属性值是当前的实例value:this,// 定义__ob__不能被遍历,否则会引起死循环// 原因:在walk方法中会递归遍历对象中的每一个属性进行响应式处理,因为__ob__表示的当前对象的实例// 实例本身又包含__ob__,这样就会导致递归无限往里面找,就造成了死循环,// 所以这里要设置成 enumerable:false,不能遍历 __ob__ 这个属性enumerable:false})// 如果data中的某个值定义的是一个数组,则对数组进行劫持,进行响应式处理if(Array.isArray(data)){data.__proto__ = newArrayPropertythis.observeArray(data)}else{this.walk(data)}}walk(data){// 循环对象中的每一个属性进行劫持Object.keys(data).forEach(key=>{defineReactive(data,key,data[key])})}// 对数组进行响应式处理observeArray(data){data.forEach(item=>observe(item))}
}+function dependArr(array){
+ array.forEach(item=>{
+ // 数组中的普通类型的值不会有__ob__
+ item.__ob__ && item.__ob__.dep.depend()
+ if(Array.isArray(item)){
+ dependArr(item)
+ }
+ })
+}export function defineReactive(target,key,value){// 这里对当前的值进行判断,如果值还是一个对象,则递归继续进行深度劫持
+ const childOb = observe(value)// Object.defineProperty 接收三个参数,第一个是对象,第二是要劫持的key,第三个是一个对象,里面有get和set方法// 当读取这个key是,会触发get方法,当设置key时,会触发set方法,并接收新值let dep = new Dep();Object.defineProperty(target,key,{get(){// 进行依赖收集,收集这个属性的观察者(watcher)if(Dep.target){dep.depend()
+ if(childOb){
+ childOb.dep.depend()
+ if(Array.isArray(value)){
+ dependArr(value)
+ }
+ }}return value},set(newValue){if(newValue === value) returnvalue = newValue// 通知观察者更新视图dep.notify()}})
}export function observe(data){// 对这个对象进行劫持,需要判断一下是否是一个对象,如果不是一个对象不能进行劫持if(typeof data !== "object" || data === null) return// 如果这个对象已经被代理过了,则直接返回当前示例if(data.__ob__){return data.__ob__}return new Observer(data)
}
实现效果
const vm = new Vue({el: "#app",data() {return {name: "szx",age: 18,address: {price: 100,name: "少林寺"},hobby: ["爬山","玩游戏"]}},created() {console.log(this.name,"--vue")}
})function addAge() {vm.hobby.push("吃")
}
点击后页面会自动更新,并且控制台打印了一次更新视图
实现计算属性
首先添加computed计算属性
const vm = new Vue({el: "#app",data() {return {name: "szx",age: 18,address: {price: 100,name: "少林寺"},hobby: ["爬山","玩游戏"]}},created() {console.log(this.name,"--vue")},computed:{fullname(){console.log("调用计算属性")return this.name + this.age}}
})
找到 state.js 文件,添加如下代码,添加针对 computed 属性的处理逻辑
import {observe} from "./observe/index.js";export function initStatus(vm){// vm是Vue实例const opt = vm.$options// 处理data属性if(opt.data){initData(vm)}// 处理computed计算属性if(opt.computed){initComputed(vm)}
}//...省略原有代码function initComputed(vm){let computed = vm.$options.computed// 遍历计算属性中的每一个方法,将方法名作为一个keyObject.keys(computed).forEach(key=>{// 劫持每一个属性,将key作为一个新的属性,方法的返回值作为这个属性值添加到vm上defineComputed(vm,key,computed)})
}function defineComputed(target,key,computed){let getter = typeof computed[key] === "function" ? computed[key] : computed[key].getlet setter = computed[key].set || (()=>{})Object.defineProperty(target,key,{get:getter,set:setter})
}
现在我们就可以在页面上使用
<span>{{fullname}} {{fullname}} {{fullname}}
</span>
但是会发现执行了三次计算属性的方法,在真正的vue中,计算属性是带有缓存的。我们可以定义一个标识,当执行完一次计算属性方法后,把这个标识改掉,下次再次调用计算属性时,从缓存获取
修改 initComputed 方法
function initComputed(vm){let computed = vm.$options.computedconst computedWatchers = vm._computedWatchers = {}// 遍历计算属性中的每一个方法,将方法名作为一个keyObject.keys(computed).forEach(key=>{let getter = typeof computed[key] === "function" ? computed[key] : computed[key].get// 给每个计算属性绑定一个watcher,并且标记状态是lazy// 然后再watcher中判断这个状态,决定是否立即执行一次和是否返回缓存的数据computedWatchers[key] = new Watcher(vm,getter,{lazy:true})// 劫持每一个属性,将key作为一个新的属性,方法的返回值作为这个属性值添加到vm上defineComputed(vm,key,computed)})
}function defineComputed(target,key,computed){let setter = computed[key].set || (()=>{})Object.defineProperty(target,key,{get:createComputedGetter(key),set:setter})
}// 收集计算属性watcher
function createComputedGetter(key){return function (){let watcher = this._computedWatchers[key]// 这里的dirty默认是trueif(watcher.dirty){// 调用完watcher上的evaluate方法后,会吧这个dirty改成false// 同时吧计算属性的方法返回值赋值给当前watcher的value属性上watcher.evaluate()}// 这样在多次调用计算属性的get方法时,只会触发一次真正的get方法return watcher.value}
}
修改 Watcher.js
import Dep from "./dep.js";let id = 0class Watcher{constructor(vm,fn,options = {}) {this.id = id++ // 唯一IDthis.vm = vmthis.getter = fn // 这里存放多个dep,一个Watcher对应多个depsthis.depts = []this.deptSet = new Set()this.lazy = options.lazy// 用作计算属性的缓存,标记是否需要重新计算this.dirty = this.lazy// 由于watcher会默认执行一次get,会渲染一次页面,但是计算属性不需要一上来就执行渲染// 所以这里判断,如果dirty为true,则不执行get,只有当dirty为false的时候才执行get,渲染页面this.dirty ? undefined : this.get()}evaluate(){// 在这个方法中调用get方法,会去执行计算属性的方法// 这时get中的this指向的计算属性watcher,同时会这这个计算属性watch加入栈中this.value = this.get()this.dirty = false}get(){// 在调用getter之前,吧当前的Watcher实例放在Dep全局上// Dep.target = thispushWatcher(this)// 调用getter就相当于调用的render函数,调用了render函数就会触发每个属性的get方法let value = this.getter.call(this.vm)// 调用完getter之后,把Dep.target置为null// Dep.target = nullpopWatcher()// 把计算属性的值赋值给valuereturn value}// ... 省略其他代码
}// ... 省略其他代码let stack = []
function pushWatcher(watcher){stack.push(watcher)Dep.target = watcher
}
function popWatcher(){stack.pop()Dep.target = stack[stack.length-1]
}export default Watcher
上面我们给每一个计算属性绑定了一个计算watcher,并且添加了一个lazy标记,然后再watcher中吧dirty的值默认等于这个标记,同时添加一个evaluate方法,专门处理计算属性的返回值
现在我们页面上使用三次计算属性,但是只会执行一次
现在当我们更改依赖的属性时,页面不会发生变化
这是为什么呢?这是因为目前计算属性中依赖的属性中的dep绑定是的计算Watcher,并不是渲染Watcher,当我们改变了计算属性依赖值时,通知的只是计算属性Watcher,所以不会引起页面的渲染。这就需要同时去触发渲染Watcher。
在 createComputedGetter
方法中增加一个判断,判断Dep.target是否还有值,有值就表示计算属性的watcher出栈后,还有一个渲染watcher,调用watcher中的depend方法,获取计算属性watcher所有的属性,也就是每一个dep,遍历这些dep,去同时吧渲染watcher添加到这些计算属性所依赖的dep的订阅者中,这样当这些依赖的值发生变化时,就会通知到渲染watcher,从而去更新页面。
// 收集计算属性watcher
function createComputedGetter(key){return function (){let watcher = this._computedWatchers[key]// 这里的dirty默认是trueif(watcher.dirty){// 调用完watcher上的evaluate方法后,会吧这个dirty改成false// 同时吧计算属性的方法返回值赋值给当前watcher的value属性上watcher.evaluate()}// 判断Dep.target是否还有值,有值就表示计算属性的watcher出栈后,还有一个渲染watcherif(Dep.target){// 调用watcher中的depend方法,获取计算属性watcher所有的属性,也就是每一个dep// 遍历这些dep,去同时吧渲染watcher添加到这些计算属性所依赖的dep的订阅者中// 这样当这些依赖的值发生变化时,就会通知到渲染watcher,从而去更新页面watcher.depend()}// 这样在多次调用计算属性的get方法时,只会触发一次真正的get方法return watcher.value}
}
然后再 Watcher 中添加 depend 方法
import Dep from "./dep.js";let id = 0class Watcher{constructor(vm,fn,options = {}) {this.id = id++ // 唯一IDthis.vm = vmthis.getter = fn // 这里存放多个dep,一个Watcher对应多个depsthis.depts = []this.deptSet = new Set()this.lazy = options.lazy// 用作计算属性的缓存,标记是否需要重新计算this.dirty = this.lazy// 由于watcher会默认执行一次get,会渲染一次页面,但是计算属性不需要一上来就执行渲染// 所以这里判断,如果dirty为true,则不执行get,只有当dirty为false的时候才执行get,渲染页面this.dirty ? undefined : this.get()}evaluate(){// 在这个方法中调用get方法,会去执行计算属性的方法// 这时get中的this指向的计算属性watcher,同时会这这个计算属性watch加入栈中this.value = this.get()this.dirty = false}depend(){let i = this.depts.lengthwhile (i--){this.depts[i].depend()}}// ...省略其他代码
}// ...省略其他代码export default Watcher
现在当修改了计算属性所依赖的属性值时,会更新视图。然后重新调用一次计算属性
实现watch监听
watch可以理解为一个自定义的观察者watcher,当观察的属性发生变化时,执行对应的回调即可
首先新增一个全局 $watch
src/index.js
import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";
import Watcher, {nextTick} from "./observe/watcher.js";
import {initGlobalApi} from "./globalApi.js";function Vue(options) {this._init(options)
}Vue.prototype.$nextTick = nextTickinitMixin(Vue)
initLifeCycle(Vue)
initGlobalApi(Vue)+ Vue.prototype.$watch = function (expOrFn, cb) {
+ new Watcher(this, expOrFn, {user:true},cb)
+ }
export default Vue
然后再初始化状态,增加一个初始化watch的方法
src/state.js
import {observe} from "./observe/index.js";
import Watcher from "./observe/watcher.js";
import Dep from "./observe/dep.js";export function initStatus(vm){// vm是Vue实例const opt = vm.$options// 处理data属性if(opt.data){initData(vm)}// 处理computed计算属性if(opt.computed){initComputed(vm)}// 处理watch方法if(opt.watch){initWatch(vm)}
}// ....省略其他代码function initWatch(vm){// 从vm中获取用户定义的watch对象let watch = vm.$options.watch// 遍历这个对象获取每一个属性名和属性值for (const watchKey in watch) {// 属性值let handle = watch[watchKey]// 属性值可能是一个数组/**age:[(newVal,oldVal)=>{console.log(newVal,oldVal)},(newVal,oldVal)=>{console.log(newVal,oldVal)},]*/if(Array.isArray(handle)){for (let handleElement of handle) {createWatcher(vm,watchKey,handleElement)}}else{// 如果不是数组可能是一个字符串或者是一个回调// 这里先不考虑是字符串的情况createWatcher(vm,watchKey,handle)}}}function createWatcher(vm,keyOrFn,handle){vm.$watch(keyOrFn,handle)
}
然后修改Watcher类,当所监听的值发生变化时触发回调
src/observe/watcher.js
import Dep from "./dep.js";let id = 0class Watcher{constructor(vm,keyOrFn,options = {},cb) {this.id = id++ // 唯一IDthis.vm = vm
+ // 如果是一个字符串吗,则包装成一个方法
+ if(typeof keyOrFn === 'string'){
+ this.getter = function (){
+ return vm[keyOrFn]
+ }
+ }else{
+ this.getter = keyOrFn // 这里存放多个dep,一个Watcher对应多个deps
+ }this.depts = []this.deptSet = new Set()this.lazy = options.lazy// 用作计算属性的缓存,标记是否需要重新计算this.dirty = this.lazy// 由于watcher会默认执行一次get,会渲染一次页面,但是计算属性不需要一上来就执行渲染// 所以这里判断,如果dirty为true,则不执行get,只有当dirty为false的时候才执行get,渲染页面
+ this.value = this.dirty ? undefined : this.get()
+ // 区分是否为用户自定义watcher
+ this.user = options.user
+ // 拿到watcher的回调
+ this.cb = cb}// ....省略其他代码run(){console.log('更新视图')
+ let oldVal = this.value
+ let newVal = this.getter()
+ // 判断是否是用户自定义的watcher
+ if(this.user){
+ this.cb.call(this.vm,newVal,oldVal)
+ }}
}// ....省略其他代码export default Watcher
实现基本的diff算法
首先吧 src/index.js
中的 $nextTick
和 $watch
放在 src/state.js
文件中,并封装在 initStateMixin 方法内,并且导出
src/state.js
import Watcher, {nextTick} from "./observe/watcher.js";// ....省略其他代码export function initStateMixin(Vue){Vue.prototype.$nextTick = nextTickVue.prototype.$watch = function (expOrFn, cb) {new Watcher(this, expOrFn, {user:true},cb)}
}
在 src/index.js
导出并使用,并且下面添加了diff的测试代码
import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";
import {initGlobalApi} from "./globalApi.js";
import {initStateMixin} from "./state.js";
import {compilerToFunction} from "./compile/index.js";
import {createEle, patch} from "./vdom/patch.js";function Vue(options) {this._init(options)
}initMixin(Vue)
initLifeCycle(Vue)
initGlobalApi(Vue)
initStateMixin(Vue)//----------测试diff算法---------------
let render1 = compilerToFunction("<div style='color: red'></div>")
let vm1 = new Vue({data:{name:"张三"}})
let prevVNode = render1.call(vm1)
let el = createEle(prevVNode)
document.body.appendChild(el)let render2 = compilerToFunction(`<div style='background-color: blue;color: white'><h2>{{name}}</h2><h3>{{name}}</h3>
</div>`)
let vm2 = new Vue({data:{name:"李四"}})
let newVNode = render2.call(vm2)setTimeout(()=>{console.log(prevVNode)console.log(newVNode)patch(prevVNode,newVNode)
},1000)export default Vue
上面代码生成了两个虚拟节点,然后倒计时1秒后进行更新
在 src/vdom/patch.js
中对节点进行比较
下面的代码在patchVNode完成新节点和旧节点的对比
import {isSameVNode} from "./index.js";export function patch(oldVNode,newVNode){// 判断是否是一个真实元素,如果是真实DOM会返回1const isRealEle = oldVNode.nodeType;// 初次渲染if(isRealEle){// 获取真实元素const elm = oldVNode// 获取真实元素的父元素const parentElm = elm.parentNode// 创建新的虚拟节点let newRealEl = createEle(newVNode)// 把新的虚拟节点插入到真实元素后面parentElm.insertBefore(newRealEl,elm.nextSibling)// 然后删除之前的DOMparentElm.removeChild(elm)}else{// 对比新旧节点patchVNode(oldVNode,newVNode)}
}// 根据虚拟dom渲染真实的dom
export function createEle(vnode){let {tag,data,children,text} = vnodeif (typeof tag === "string"){vnode.el = document.createElement(tag)// 处理节点的属性patchProps(vnode.el,{},data)// 递归处理子元素children.forEach(child=>{child && vnode.el.appendChild(createEle(child))})}else{vnode.el = document.createTextNode(text)}return vnode.el
}// 给节点添加属性
function patchProps(el,oldProps = {},props){let oldPropsStyle = oldProps.stylelet newPropsStyle = props.style// 判断如果新节点上没有旧节点的样式,则应该吧原来的样式清空掉for (const key in oldPropsStyle) {if(!newPropsStyle[key]){el.style[key] = ""}}// 判断旧节点的属性在新节点是否存在for (const key in oldProps) {if(!props[key]){el.removeAttribute(key)}}for (const key in props) {if(key === "style"){Object.keys(props.style).forEach(sty=>{el.style[sty] = props.style[sty]})}else{el.setAttribute(key,props[key])}}
}// 对比新旧节点
function patchVNode(oldVNode,newVNode){// 进行diff算法,对比新老节点// 对比两个节点的tag和key是否一样,如果不一样,则直接吧老的替换掉,换成新的节点if(!isSameVNode(oldVNode,newVNode)){let el = createEle(newVNode)oldVNode.el.parentNode.replaceChild(el,oldVNode.el)return el}// 一样的情况对DOM元素进行复用let el = newVNode.el = oldVNode.el // 如果一样,则还需要判断一下文本的情况if(!oldVNode.tag){if(oldVNode.text !== newVNode.text){el.textContent = newVNode.text}}// 比较新节点和旧节点的属性是否一致patchProps(el,oldVNode.data,newVNode.data)// 然后比较新旧节点的儿子节点let oldVNodeChildren = oldVNode.children || []let newVNodeChildren = newVNode.children || []if(oldVNodeChildren.length > 0 && newVNodeChildren.length > 0){// 进行完整的diff算法console.log("进行完整的diff算法")}else if(newVNodeChildren.length > 0){// 这里表示老节点没有儿子,但是新节点有,需要遍历新节点的每一个儿子,放在新节点中mountChildren(el,newVNodeChildren)}else if(oldVNodeChildren.length > 0){// 这里表示新节点没有儿子,但是老节点有,需要吧老节点的儿子删除掉unMountChildren(el,oldVNodeChildren)}return el
}function mountChildren(el,children){for (const child of children) {el.appendChild(createEle(child))}
}function unMountChildren(el,children){// 直接删除老节点的子元素el.innerHTML = ""
}
实现完整的diff算法
这里我们来完成当旧节点和新节点都有子元素时,进行互相对比。
在Vue2中使用了双指针来进行子元素之间的对比,一个指针指向第一个节点,一个指针指向最后一个节点,比较一次后,首指针往后移动一位,当首指针大于尾指针时,比较结束
patch.js
import {isSameVNode} from "./index.js";export function patch(oldVNode,newVNode){// 判断是否是一个真实元素,如果是真实DOM会返回1const isRealEle = oldVNode.nodeType;// 初次渲染if(isRealEle){// 获取真实元素const elm = oldVNode// 获取真实元素的父元素const parentElm = elm.parentNode// 创建新的虚拟节点let newRealEl = createEle(newVNode)// 把新的虚拟节点插入到真实元素后面parentElm.insertBefore(newRealEl,elm.nextSibling)// 然后删除之前的DOMparentElm.removeChild(elm)}else{patchVNode(oldVNode,newVNode)}
}export function createEle(vnode){let {tag,data,children,text} = vnodeif (typeof tag === "string"){vnode.el = document.createElement(tag)// 处理节点的属性patchProps(vnode.el,{},data)// 递归处理子元素children.forEach(child=>{child && vnode.el.appendChild(createEle(child))})}else{vnode.el = document.createTextNode(text)}return vnode.el
}function patchProps(el,oldProps = {},props = {}){let oldPropsStyle = oldProps.stylelet newPropsStyle = props.style// 判断如果新节点上没有旧节点的样式,则应该吧原来的样式清空掉for (const key in oldPropsStyle) {if(!newPropsStyle[key]){el.style[key] = ""}}// 判断旧节点的属性在新节点是否存在for (const key in oldProps) {if(!props[key]){el.removeAttribute(key)}}for (const key in props) {if(key === "style"){Object.keys(props.style).forEach(sty=>{el.style[sty] = props.style[sty]})}else{el.setAttribute(key,props[key])}}
}function patchVNode(oldVNode,newVNode){// 进行diff算法,对比新老节点// 对比两个节点的tag和key是否一样,如果不一样,则直接吧老的替换掉,换成新的节点if(!isSameVNode(oldVNode,newVNode)){console.log(oldVNode,'oldVNode')console.log(newVNode,'newVNode')let el = createEle(newVNode)oldVNode.el.parentNode.replaceChild(el,oldVNode.el)return el}// 一样的情况对DOM元素进行复用let el = newVNode.el = oldVNode.el// 如果一样,则还需要判断一下文本的情况if(!oldVNode.tag){if(oldVNode.el.text !== newVNode.text){oldVNode.el.text = newVNode.text}}// 比较新节点和旧节点的属性是否一致patchProps(el,oldVNode.data,newVNode.data)// 然后比较新旧节点的儿子节点let oldVNodeChildren = oldVNode.children || []let newVNodeChildren = newVNode.children || []if(oldVNodeChildren.length > 0 && newVNodeChildren.length > 0){// 声明两个指针,分别指向头节点和尾节点// 然后进行对比,当旧node的头节点和新node的头节点相同时,则进行头指针往后移动// 当头指针大于尾指针时停止循环let oldStartIndex = 0let oldEndIndex = oldVNodeChildren.length - 1let oldStartNode = oldVNodeChildren[0]let oldEndNode = oldVNodeChildren[oldEndIndex]let newStartIndex = 0let newEndIndex = newVNodeChildren.length - 1let newStartNode = newVNodeChildren[0]let newEndNode = newVNodeChildren[newEndIndex]// 添加一个映射表let nodeMap = {}oldVNodeChildren.forEach((child,index)=>{nodeMap[child.key] = index})while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex){if(!oldStartNode){oldStartNode = oldVNodeChildren[++oldStartIndex]}else if(!oldEndNode){oldEndNode = oldVNodeChildren[--oldEndIndex]}// 1.进行头头对比else if(isSameVNode(oldStartNode,newStartNode)){// 递归更新子节点patchVNode(oldStartNode,newStartNode)oldStartNode = oldVNodeChildren[++oldStartIndex]newStartNode = newVNodeChildren[++newStartIndex]}// 2.进行尾尾对比else if(isSameVNode(oldEndNode,newEndNode)){// 递归更新子节点patchVNode(oldEndNode,newEndNode)oldEndNode = oldVNodeChildren[--oldEndIndex]newEndNode = newVNodeChildren[--newEndIndex]}// 3.进行尾头else if(isSameVNode(oldEndNode,newStartNode)){patchVNode(oldEndNode,newStartNode)// 如果旧节点的尾节点和新节点的头节点相同,则吧把旧节点的尾节点放在头节点之前// 然后把旧的尾指针往前移动,新节点的头指针往后移动el.insertBefore(oldEndNode.el,oldStartNode.el)oldEndNode = oldVNodeChildren[--oldEndIndex]newStartNode = newVNodeChildren[++newStartIndex]}// 4.进行头尾对比else if(isSameVNode(oldStartNode,newEndNode)){patchVNode(oldStartNode,newEndNode)// 如果旧节点的头和新节点的尾相同,则吧旧节点的头节点放在尾节点后// 然后把旧的头指针往后移动,新节点的尾指针往前移动el.insertBefore(oldStartNode.el,oldEndNode.el.nextSibling)oldStartNode = oldVNodeChildren[++oldStartIndex]newEndNode = newVNodeChildren[--newEndIndex]}else{// 5.进行乱序查找let oldNodeIndex = nodeMap[newStartNode.key]if(oldNodeIndex !== undefined){let moveNode = oldVNodeChildren[oldNodeIndex]el.insertBefore(moveNode.el,oldStartNode.el)oldVNodeChildren[oldNodeIndex] = undefinedpatchVNode(moveNode,newStartNode)}else{el.insertBefore(createEle(newStartNode),oldStartNode.el)}newStartNode = newVNodeChildren[++newStartIndex]}}// 循环结束后,如果新节点的头指针小于等于新节点的尾指针// 说明新节点是有多出来的内容,则要把新节点多出来的push到现有节点的后面if(newStartIndex <= newEndIndex){console.log("1")for (let i = newStartIndex; i <= newEndIndex; i++) {// 如果尾指针的下一个节点有值,说明是新节点的前面有多出来的节点// 需要吧新的节点插入到前面去let anchor = newVNodeChildren[newEndIndex + 1] ? newVNodeChildren[newEndIndex + 1].el : null// 吧新节点插入到anchor的前面el.insertBefore(createEle(newVNodeChildren[i]),anchor)}}// 如果旧节点的头指针小于等于旧节点的尾指针,则说明旧的有多的节点,需要删除掉if(oldStartIndex <= oldEndIndex){for (let i = oldStartIndex; i <= oldEndIndex; i++) {let chilEl = oldVNodeChildren[i]chilEl && el.removeChild(chilEl.el)}}}else if(newVNodeChildren.length > 0){// 这里表示老节点没有儿子,但是新节点有,需要遍历新节点的每一个儿子,放在新节点中mountChildren(el,newVNodeChildren)}else if(oldVNodeChildren.length > 0){// 这里表示新节点没有儿子,但是老节点有,需要吧老节点的儿子删除掉unMountChildren(el,oldVNodeChildren)}return el
}function mountChildren(el,children){for (const child of children) {el.appendChild(createEle(child))}
}function unMountChildren(el,children){// 直接删除老节点的子元素el.innerHTML = ""
}
测试一下,手动的编写两个虚拟节点进行比对
//----------测试diff算法---------------
let render1 = compilerToFunction(`<div style='color: red'>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
</div>`)
let vm1 = new Vue({data:{name:"张三"}})
let prevVNode = render1.call(vm1)
let el = createEle(prevVNode)
document.body.appendChild(el)let render2 = compilerToFunction(`<div style='background-color: blue;color: white'>
<li key="f">f</li>
<li key="e">e</li>
<li key="c">c</li>
<li key="n">n</li>
<li key="a">a</li>
<li key="m">m</li>
<li key="j">j</li>
</div>`)
let vm2 = new Vue({data:{name:"李四"}})
let newVNode = render2.call(vm2)setTimeout(()=>{console.log(prevVNode)console.log(newVNode)patch(prevVNode,newVNode)
},1000)
倒计时一秒后会自动变成新的
但是我们肯定不能使用这种方式来实现页面的更新和diff,需要在修改完数据后,在update中进行新旧节点的diff
修改 lifecycle.js
文件中的 update 方法
// 更新视图方法
Vue.prototype._update = function (vnode){const vm = thisconst el = vm.$elconst preVNode = vm._vnodevm._vnode = vnodeif(preVNode){// 第二个渲染,用两个虚拟节点进行diffvm.$el = patch(preVNode,vnode)}else{// 第一次渲染页面,用虚拟节点直接覆盖真实DOMvm.$el = patch(el,vnode)}
}
查看效果
通过动画我们可以看到每次更新时只有里面的文字变化,其他元素并不会重新渲染
自定义组件实现原理
vue中可以声明自定义组件和全局组件,当自定义组件和全局组件重名时,会优先使用自定义组件。
在源码中,主要靠 Vue.extend 方法来实现
例如如下写法:
Vue.component("my-button",{template:"<button>全局的组件</button>"
})let Sub = Vue.extend({template:"<button>子组件 <my-button></my-button></button>",components:{"my-button":{template:"<button>子组件自己声明的button</button>"}}
})new Sub().$mount("#app")
页面展示的效果
我们来实现这个源码
在 globalApi.js
文件中添加方法
import {mergeOptions} from "./utils.js";export function initGlobalApi(Vue) {// 添加一个静态方法 mixinVue.options = {// 添加一个属性,记录Vue实例_base:Vue}Vue.mixin = function (mixin) {this.options = mergeOptions(this.options, mixin)return this}Vue.extend = function (options){function Sub(options = {}){this._init(options)}Sub.prototype = Object.create(Vue.prototype)Sub.prototype.constructor = SubSub.options = mergeOptions(Vue.options,options)return Sub}Vue.options.components = {}Vue.component = function (id,options){options = typeof options === "function" ? options : Vue.extend(options)Vue.options.components[id] = options}
}
在 utils.js
文件中添加组件合并策略,实现先找自身声明的组件,找不到再去原型链上找全局声明的组件
// 定义一些策略,例如 created,beforeCreated 等,不需要写大量的if判断
const strats = {}
const LIFECYCLE = ["beforeCreated","created"
]LIFECYCLE.forEach(key => {strats[key] = function (p, c) {if (c) {if (p) {return p.concat(c)} else {return [c]}} else {return p}}
})// 添加组件合并策略
strats.components = function (parentVal, childVal){const res = Object.create(parentVal)if(childVal){for (const key in childVal) {res[key] = childVal[key]}}return res
}// 合并属性的方法
export function mergeOptions(parent, child) {const options = {}// 先获取父亲的值for (const key in parent) {mergeField(key)}for (const key in child) {// 如果父亲里面没有这个子属性,在进行合并子的/*** 示例:父亲:{a:1} 儿子:{a:2}* 儿子中也有父亲的属性a,所以不会走儿子中的合并方法,但是在取值的时候,优先取的是儿子身上的属性值* 所以合并到一个对象中时,儿子会覆盖父亲*/if (!parent.hasOwnProperty(key)) {mergeField(key)}}function mergeField(key) {if (strats[key]) {// {created:fn} {}// 合并声明周期上的方法,例如:beforeCreated,createdoptions[key] = strats[key](parent[key], child[key])} else {// 先拿到儿子的值options[key] = child[key] || parent[key]}}return options
}
这一步实现了组件按照原型链查找,通过打断点可以看到
接着修改 src/vdom/index.js
文件,增加创建自定义组件的虚拟节点
// 判断是否是原生标签
let isReservedTag = (tag) => {return ["a", "div", "span", "button", "ul", "li", "h1", "h2", "h3", "h4", "h5", "h6", "p", "input", "img"].includes(tag)
}// 创建虚拟节点
export function createElementVNode(vm, tag, prop, ...children) {if (!prop) {prop = {}}let key = prop.keyif (key) {delete prop.key}if (isReservedTag(tag)) {return vnode(vm, tag, prop, key, children, undefined)} else {// 创建组件的虚拟节点return createTemplateVNode(vm, tag, prop, key, children)}
}function createTemplateVNode(vm, tag, data, key, children) {let Core = vm.$options.components[tag]// 这里有两种情况,如果是自己组件本身定义的一个子组件,则拿到的直接是一个对象,里面有一个template// 否则会往原型链上找,找到的是通过 Vue.component 定义的组件,拿到的是一个Sub构造函数if (typeof Core === "object") {// 需要将对象变成Sub构造函数Core = vm.$options._base.extend(Core)}data.hook = {init() {}}return vnode(vm, tag, data, key, children = [], undefined, Core)
}// 创建文本节点
export function createTextVNode(vm, text) {return vnode(vm, undefined, undefined, undefined, undefined, text)
}function vnode(vm, tag, data, key, children = [], text, componentsOptions) {children = children.filter(Boolean)return {vm,tag,data,key,children,text,componentsOptions}
}// 判断两个节点是否一致
export function isSameVNode(oldVNode, newVNode) {// 对比两个节点的tag和key是否都一样,如果都一样,就认为这两个节点是一样的return oldVNode.tag === newVNode.tag && oldVNode.key === newVNode.key
}
实现组件渲染功能
上面我们根据tag判断是否是一个组件,并且添加了一个 createTemplateVNode 方法,返回组件的虚拟节点vnode。
然后需要在 src/vdom/patch.js
文件的 createEle 生成真实节点的方法中添加判断,是否是虚拟节点
function createComponent(vnode){let i = vnode.dataif((i=i.hook) && (i=i.init)){i(vnode)}if(vnode.componentsInstance){return true}
}export function createEle(vnode){let {tag,data,children,text} = vnodeif (typeof tag === "string"){// 判断是否是组件if(createComponent(vnode)){return vnode.componentsInstance.$el}vnode.el = document.createElement(tag)// 处理节点的属性patchProps(vnode.el,{},data)// 递归处理子元素children.forEach(child=>{child && vnode.el.appendChild(createEle(child))})}else{vnode.el = document.createTextNode(text)}return vnode.el
}
在 createComponent 方法中就会去调用在上面 createTemplateVNode 方法中定义的 init 方法,并把当前的 vnode 传递过去
这时需要在init方法中接收这个vnode,并去new 这个 vnode 的 componentsOptions 中的 Core,这里的Core也就是 Vue.extend
修改 src/vdom/index.js
中的 createTemplateVNode 方法
function createTemplateVNode(vm, tag, data, key, children) {// 从全局中的component中获取对应的组件,应为之前已经合并过了,所以这里可以直接获取let Core = vm.$options.components[tag]// 这里有两种情况,如果是自己组件本身定义的一个子组件,则拿到的直接是一个对象,里面有一个template// 否则会往原型链上找,找到的是通过 Vue.component 定义的组件,拿到的是一个Sub构造函数if (typeof Core === "object") {// 需要将对象变成Sub构造函数Core = vm.$options._base.extend(Core)}data.hook = {init(vnode) {// 从返回的vnode上获取componentsOptions中的Corelet instance = vnode.componentsInstance = new vnode.componentsOptions.Coreinstance.$mount()}}return vnode(vm, tag, data, key, children = [], undefined, {Core})
}
new 完 Core 后返回的实例同时赋值给当前vnode的componentsInstance上和局部变量instance
然后使用 instance 再去调用 $mount 方法,会触发 patch 方法,但是这里并没有传递参数,所以就需要在 patch 方法中添加一个判断,如果没有旧节点,直接创建新的节点并返回
export function patch(oldVNode,newVNode){
+ if(!oldVNode){
+ return createEle(newVNode)
+ }// 判断是否是一个真实元素,如果是真实DOM会返回1const isRealEle = oldVNode.nodeType;// 初次渲染if(isRealEle){// 获取真实元素const elm = oldVNode// 获取真实元素的父元素const parentElm = elm.parentNode// 创建新的虚拟节点let newRealEl = createEle(newVNode)// 把新的虚拟节点插入到真实元素后面parentElm.insertBefore(newRealEl,elm.nextSibling)// 然后删除之前的DOMparentElm.removeChild(elm)// 返回渲染后的虚拟节点return newRealEl}else{return patchVNode(oldVNode,newVNode)}
}
这是用我们自己的vue.js来看一下实现的效果
<body><div id="app"><ul><li>{{age}}</li><li>{{name}}</li></ul><button onclick="updateAge()">更新</button></div>
</body><script src="./vue.js"></script>
Vue.component("my-button",{template:"<button>全局的组件</button>"
})let Sub = Vue.extend({template:"<button>子组件 <my-button></my-button></button>",components:{"my-button":{template:"<button>子组件自己声明的button</button>"}}
})new Sub().$mount("#app")
总结:
- 创建子类构造函数的时候,会将全局的组件和自己身上定义的组件进行合并。(组件的合并,会优先查找自己身上的,找不到再去找全局的)
- 组件的渲染:开始渲染的时候组件会编译组件的模板(template 属性对应的 html)变成render函数,然后调用 render函数
- createrElementVnode 会根据 tag 类型判断是否是自定义组件,如果是组件会创造出组件对应的虚拟节点(给组件增加一个初始化钩子,增加componentOptions选项 { Core })
- 然后再创建组件的真实节点时。需要 new Core,然后使用返回的实例在去调用 $mount() 方法就可以完成组件的挂载