1.简述功能
我们实现的一个带有响应式功能的简单Vue框架。
功能:
- 负责接收初始化的参数(options)
- 负责把data中的属性注入到Vue实例,转换成getter/setter
- 负责调用observer监听data属性中的变化
- 负责调用compiler解析指令/差值表达式
Vue类的结构:

前三个是Vue的属性,最后一个是Vue的方法,用于将data设置为setter/getter,并纳入Vue实例中。
项目初始结构:
2.创建Vue
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>手写Vue响应式</title>
</head>
<body>
<div id="app">
<h1>差值表达式</h1>
<h3>{{msg}}</h3>
<h3>{{count}}</h3>
<h1>Vue指令(v-text)</h1>
<div v-text="msg"></div>
<h1>Vue指令(v-model)</h1>
<label for="msg">msg:</label><input type="text" v-model="msg">
<label for="count">count:</label><input type="text" v-model="count">
</div>
<script src="./js/vue.js"></script>
<script>
let vm = new Vue({
el:"#app",
data:{
msg:"Hello 迷你Vue",
count:11
}
})
console.log(vm);
</script>
</body>
</html>
vue.js:
class Vue{
constructor(options){
//1.通过属性保存选项的数据
this.$options=options||{},
this.$data=options.data||{},
this.$el=typeof options.el ==="string" ? document.querySelector(options.el):options.el,
//2.把data中的属性转换成getter和setter,注入到vue实例中
this._proxyData(this.$data)
}
_proxyData(data){
Object.keys(data).forEach((key)=>{
//这里使用箭头函数,可以不改变this的指针,这里的this指向Vue实例,因为上面是使用this._proxyData调用
//确保第一个参数this指向Vue实例
//Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
//下面就是将data属性注入到vue实例中
Object.defineProperty(this,key,{
configurable:true,
enumerable:true,
set(newValue){
if(data[key]===newValue){
return
}else{
data[key]=newValue;
}
},
get(){
return data[key];
}
})
})
}
}
我们看看我们打印出来的vm:

左边的视图我们先不管,因为我们还没有实现v-text、v-model、差值表达式以及编译等功能。打印出来的vm是我们预期的Vue结构。
上面我们之所以将$data中的属性注入Vue实例中,是为了调用方便:
<template></template>
<script>
let this = new Vue();
this.msg;
this.count;
</script>
<style></style>
我们在Vue模板中就是通过this指向Vue实例,然后直接使用this.xxx调用data中的数据的。
3.Observer 实现响应式数据
刚刚我们调用Object.defineProperty()只是将data对象中的属性注入到Vue实例中,是Vue实例中的msg和count属性有了setter和getter。下面我们开始实现$data对象中的属性实现数据响应式。
Observer.js的功能:
- 负责把data选项中的属性转换成响应式数据
- data中的某个属性也是对象,则把该属性也转换成响应式数据
- 数据变化发送通知(结合观察者模式实现)
Observer.js结构:

这两个方法名也是Vue源码中的方法名。
下面看看observer.js的源码(未完全实现上述功能):
class Observer{
constructor(data){
//在新建对象时,就调用this.walk
this.walk(data);
}
//walk()用于遍历data中的所有属性,在这里调用defineReactive
walk(data){
//1.判断data是否是对象
if(!data||typeof data !=='object'){
return
}
Object.keys(data).forEach(key=>{
this.defineReactive(data,key,data[key]);
})
}
//定义响应式数据
defineReactive(obj,key,val){
Object.defineProperty(obj,key,{
enumerable:true,
configurable:true,
set(newValue){
if(newValue===val){
return
}
val=newValue;
},
get(){
return val;
}
})
}
}
修改 index.html ,引入observer.js:
<script src="./js/observer.js"></script>
<script src="./js/vue.js"></script>
因为在vue.js中使用了observer,所以得在vue.js前面引入observer.js。
此时$data中的属性也是响应式的了,有了setter、getter:

上面我们之所以说功能未完成,是因为还没考虑下面两种情况:
- 如果data的属性也是一个对象,那么这个属性内的属性还不是响应式的。
- 如果data中某属性初始化是字符串,但后面改为了对象,这时这对象就不是响应式的了。
完善observer.js:
class Observer{
constructor(data){
//在新建对象时,就调用this.walk
this.walk(data);
}
//walk()用于遍历data中的所有属性,在这里调用defineReactive
walk(data){
//1.判断data是否是对象
if(!data||typeof data !=='object'){
return
}
Object.keys(data).forEach(key=>{
this.defineReactive(data,key,data[key]);
})
}
//定义响应式数据
defineReactive(obj,key,val){
//假如key的属性值也是对象,则再调用walk
//这里之所以没有判断val是不是对象,是因为walk()内部会判断val是不是对象
//如果不是对象会直接return
this.walk(val);
let that=this;
Object.defineProperty(obj,key,{
enumerable:true,
configurable:true,
set(newValue){
if(newValue===val){
return
}
val=newValue;
//之所以使用that,是因为这里地set()是一个新的作用域,this不是指向observer实例
that.walk(val);
},
get(){
return val;
}
})
}
}
上面更改的地方是在defineReactive中多调用了两次walk,这解决了上面提出来的两个问题。
4.Compiler
上面我们html中的标签内容不能正常显示,是因为我们没有实现编译功能。在这里我们没有使用VNode,直接渲染真实的DOM。
功能:
- 负责编译模板,解析指令/差值表达式
- 负责页面的首次渲染
- 当数据变化后重新渲染视图
结构:

在编写compiler.js时,先在Vue.js中引入Compiler实例:
constructor(options){
//1.通过属性保存选项的数据
this.$options=options||{};
this.$data=options.data||{};
this.$el=typeof options.el ==="string" ? document.querySelector(options.el):options.el;
//2.把data中的属性转换成getter和setter,注入到vue实例中
this._proxyData(this.$data);
//3.调用observer对象,监听数据的变化
new Observer(this.$data);
//4.编译模板,this就是vm,即Vue实例
new Compiler(this);
}
同时在index.html中引入compiler.js文件:
//注意三者关系和顺序
<script src="./js/compiler.js"></script>
<script src="./js/observer.js"></script>
<script src="./js/vue.js"></script>
compiler.js:
class Compiler{
//vm即是vue实例
constructor(vm){
this.el=vm.$el;
this.vm=vm;
this.compiler(this.el)
}
//编译模板,处理文本节点和元素节点
compiler(el){
let childNodes=el.childNodes;
Array.from(childNodes).forEach((node)=>{
if(this.isTextNode(node)){
//如果是文本节点则编译文本
this.compilerText(node);
}else if(this.isElementNode(node)){
//如果是元素节点,则编译元素
this.compilerElement(node);
}
//还需判断node还有没有子节点,元素节点内的文本节点也是相当于子节点
if(node.childNodes&&node.childNodes.length){
//递归处理子节点
this.compiler(node);
}
})
}
//编译元素节点。处理指令
compilerElement(node){
//通过node的attributes可以获取元素内的属性
//遍历节点的所有属性,判断是否是指令
Array.from(node.attributes).forEach((attr)=>{
//获取属性名
let attrName=attr.name;
if(this.isDirective(attrName)){
//对v-model和v-text分别进行处理
//从下标为2处,开始截取字符串,attrName为model或text
attrName=attrName.substr(2);
//获取指令绑定的属性名
let key=attr.value;
//将updateFunc指向textUpdater或modelUpdater函数
//使用了这种方法就不用if进行判断是调用哪个处理函数了
let updateFunc=this[attrName+'Updater'];
//之所以用“&&”,是因为我们这里只处理了v-text、v-model指令
//如果出现其它指令,那么updateFunc则为undefined,那么再执行updateFunc()就会出错,应该排除这种情况
//调用textUpdater或modelUpdater函数
updateFunc&&updateFunc(node,this.vm[key]);
}
})
}
//处理v-text指令
textUpdater(node,value){
node.textContent=value;
}
//处理v-model指令
modelUpdater(node,value){
node.value=value;
}
//编译文本节点,处理差值表达式
compilerText(node){
//正则表达式匹配双花括号
let reg=/\{\{(.+?)\}\}/;
let value=node.textContent;
if(reg.test(value)){
//获取正则表达式里面的内容,即data属性
let key=RegExp.$1.trim();
//将属性对应的值插入DOM中
node.textContent=value.replace(reg,this.vm[key]);
}
}
//判断元素属性是否是指令
isDirective(attrName){
//如果是v-开头则是指令,返回true
return attrName.startsWith("v-");
}
//判断节点是否是文本节点
isTextNode(node){
//根据节点的nodeType来判断是什么节点
//nodeType=3则是文本节点
return node.nodeType===3;
}
//判断节点是否是元素节点
isElementNode(node){
//nodeType=1则是元素节点
return node.nodeType===1;
}
}

现在模板上的数据都可以正常显示出来了,但是还没有实现MVVM。即更新数据,页面还不会更新。
5.Dep(发布者)
我们实现了图中的大部分代码:

现在剩下的是Dep和Watcher没有实现。
dep.js:
class Dep{
constructor(){
//记录所有订阅者
this.subs=[];
}
addSub(sub){
//sub必须有update才是合格的订阅者
if(sub&&sub.update){
this.subs.push(sub);
}
}
notify(){
//通知,即执行每个sub的update()
this.subs.forEach(sub=>{
sub.update();
})
}
}
我们该怎么使用Dep类呢?这个类的主要功能是收集订阅者和发布时发布通知,我们可以在observer.js中的set()中为每个响应式数据设置一个Dep对象,在数据发生改变时,通知各个订阅者;在get()收集订阅者。
更改observer.js代码:加入Observer
class Observer{
constructor(data){
//在新建对象时,就调用this.walk
this.walk(data);
}
//walk()用于遍历data中的所有属性,在这里调用defineReactive
walk(data){
//1.判断data是否是对象
if(!data||typeof data !=='object'){
return
}
Object.keys(data).forEach(key=>{
this.defineReactive(data,key,data[key]);
})
}
//定义响应式数据
defineReactive(obj,key,val){
//假如key的属性值也是对象,则再调用walk
//这里之所以没有判断val是不是对象,是因为walk()内部会判断val是不是对象
//如果不是对象会直接return
this.walk(val);
let that=this;
//为对象的每个属性创建依赖,并发送通知
let dep=new Dep();
Object.defineProperty(obj,key,{
enumerable:true,
configurable:true,
set(newValue){
if(newValue===val){
return
}
val=newValue;
//之所以使用that,是因为这里地set()是一个新的作用域,this不是指向observer实例
that.walk(val);
//set说明数据发生变化,通知订阅者
dep.notify();
},
get(){
//收集依赖,只有当存在target时才添加依赖
//target存储的就是观察者对象
//先不用管Dep.target是如何而来,它是在Watcher类中添加的
Dep.target&&dep.addSub(Dep.target);
return val;
}
})
}
}
此时页面初始化每个引用data中的属性时,还是无法将watcher添加到this.subs中的,因为现在的Dep.target是未定义的。
6.Watcher

功能:
- 当数据变化,触发依赖,dep会通知所有watcher实例更新视图
- watcher自身实例化的时候往dep对象中添加自己
结构:
在index.html引入js文件:
//它们有依赖,注意顺序不可变
<script src="./js/dep.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compiler.js"></script>
<script src="./js/observer.js"></script>
<script src="./js/vue.js"></script>
Watcher类:
class Watcher{
constructor(vm,key,cb){
this.vm=vm;
this.key=key;
this.cb=cb;
//下面三行代码实现了将watcher添加到了this.subs中
//把watcher对象记录到Dep类的静态属性target属性
//当触发observer.js中的get()时,get()会调用addSub(Dep.target)
Dep.target=this;
//下面的vm[key]就触发了get(),这时watcher就被加入了this.subs中
this.oldValue=vm[key];
//当上面的vm[key]触发get()后,为了不重复添加设置为null
//因为此时为null,“&&”就不会再执行addSub()
Dep.target=null;
}
//当数据发生变化时,更新视图
update(){
let newValue=this.vm[this.key];
if(this.oldValue===newValue){
return;
}
//如果不相同,调用回调函数
this.cb(newValue);
}
}
现在我们创建好了Watcher,但是我们应该在哪里使用Watcher呢?Watcher主要是监听到数据变化就更新视图,而更新视图就是对真实DOM操作。而我们操作DOM都放在了compiler.js中,所以应该在compiler.js中创建watcher对象。
而我们compiler.js只是在下面三个方法中更新视图,这三种方法只是在页面初次渲染被调用,我们应该让它在数据更新时也能被调用:
textUpdater(node,value){
node.textContent=value;
}
//处理v-model指令
modelUpdater(node,value){
node.value=value;
}
//编译文本节点,处理差值表达式
compilerText(node){
//正则表达式匹配双花括号
let reg=/\{\{(.+?)\}\}/;
let value=node.textContent;
if(reg.test(value)){
//获取正则表达式里面的内容,即data属性
let key=RegExp.$1.trim();
//将属性对应的值插入DOM中
node.textContent=value.replace(reg,this.vm[key]);
}
}
修改四个函数:
//编译元素节点。处理指令
compilerElement(node){
//通过node的attributes可以获取元素内的属性
//遍历节点的所有属性,判断是否是指令
Array.from(node.attributes).forEach((attr)=>{
//获取属性名
let attrName=attr.name;
if(this.isDirective(attrName)){
//对v-model和v-text分别进行处理
//从下标为2处,开始截取字符串,attrName为model或text
attrName=attrName.substr(2);
//获取指令绑定的属性名
let key=attr.value;
//将updateFunc指向textUpdater或modelUpdater函数
//使用了这种方法就不用if进行判断是调用哪个处理函数了
let updateFunc=this[attrName+'Updater'];
//之所以用“&&”,是因为我们这里只处理了v-text、v-model指令
//如果出现其它指令,那么updateFunc则为undefined,那么再执行updateFunc()就会出错,应该排除这种情况
//调用textUpdater或modelUpdater函数
updateFunc&&updateFunc.bind(this,node,this.vm[key],key)();
}
})
}
//处理v-text指令
textUpdater(node,value,key){
node.textContent=value;
//创建watcher对象,当数据改变时更新视图
new Watcher(this.vm,key,(newValue)=>{
node.textContent=newValue;
})
}
//处理v-model指令
modelUpdater(node,value,key){
node.value=value;
//创建watcher对象,当数据改变时更新视图
new Watcher(this.vm,key,(newValue)=>{
node.value=newValue;
})
}
//编译文本节点,处理差值表达式
compilerText(node){
//正则表达式匹配双花括号
let reg=/\{\{(.+?)\}\}/;
let value=node.textContent;
if(reg.test(value)){
//获取正则表达式里面的内容,即data属性
let key=RegExp.$1.trim();
//将属性对应的值插入DOM中
node.textContent=value.replace(reg,this.vm[key]);
//创建watcher对象,当数据改变时更新视图
new Watcher(this.vm,key,(newValue)=>{
node.textContent=newValue;
})
}
}
现在就实现了数据更新,视图也更新了:

由于新建Watcher需要三个参数,而vm要使用this.vm需要使用bind或call来绑定this指针指向Compiler实例,而key在compilerElement()方法中有,则在updateFunc中使用参数传递过来即可。
注意:可能有人会疑问,在编译中使用new Watcher()会不会给一个属性重复添加watcher。答案是不会,因为编译只会执行一次,而执行编译后,所有绑定到html的数据都是响应式的,在get()也不会重复执行dep.addSubs(),因为在Watcher内部中的静态属性Dep.target=null,确保了不会再执行dep.addSubs()。
7.双向绑定
下面我们要实现视图的输入框数据变化,我们的数据也随着变化,这就是双向数据绑定。我们要怎样实现呢?
我们需要知道表单数据的变化,那么我们就需要监听表单的input事件:
修改modelUpdater():
//处理v-model指令
modelUpdater(node,value,key){
node.value=value;
//创建watcher对象,当数据改变时更新视图
new Watcher(this.vm,key,(newValue)=>{
node.value=newValue;
})
//实现视图变化,数据也变化
node.addEventListener("input",()=>{
//从节点node中获取输入值,赋值给数据
this.vm[key]=node.value;
})
}

到这里,简单的实现了Vue响应式。