老掉牙的文章了,不过为了加深上一篇对观察者模式的理解,所以来自己实现一个简单的vue双向绑定。
目标
给一个input做个双向绑定的功能
<div id="ele">
<input v-model="test"/>
{{test}}
</div>
<script src="./src/observer.js"></script>
<script src="./src/watcher.js"></script>
<script src="./src/compile.js"></script>
<script src="./src/mvvm.js"></script>
<script>
const vm = new MVVM({
el:'ele',
data(){
return {
test:''
}
}
})
</script>
思路
input => 数据 : 给input加个事件,变化的时候改变数据即可。
数据 => input :通过defineProperty设置get和set属性来劫持数据,触发视图的更新。
开始
1、给对象所有的键值都用defineProperty设置get,set。
function observe(data){
if(typeof data !== "object"){ //如果不是对象
return;
}
Object.keys(data).forEach(key => { //遍历对象键值
defineReactive(data,key,data[key]);
});
}
function defineReactive(data,key,val){
observe(val);
Object.defineProperty(data,key,{
enumerable: true,
configurable: true,
get(){
return val;
},
set(newval){
val = newval;
}
})
}
这样,一旦修改数据都能在set函数中监听到。
2、实现MVVM构造函数。
在调用MVVM构造函数的时候,需要把data里面所有的键值都绑定上get和set。然后编译模板。
class MVVM{
constructor(options){
this._options = options;
let data = this._data = options.data();
observe(data); //给数据的所有键值加上get set
let dom = document.getElementById(options.el);
new Compile(dom ,this); //编译模板了
}
}
3、实现模板编译
模版编译就是遍历节点,寻找具有v-model属性的元素节点,以及{{}}这种格式的文本节点(简化了,vue有很多指令都需要进行判断)。
class Compile{
constructor(el,vm){
this._el = el;
this._compileElement(el);
}
_compileElement(el){ //遍历节点
let childs = el.childNodes;
Array.from(childs).forEach(node => {
if (node.childNodes && node.childNodes.length) {
this._compileElement(node);
}else{
this._compile(node);
}
})
}
_compile(node){
if(node.nodeType == 3){ //文本节点
let reg = /\{\{(.*)\}\}/;
let text = node.textContent;
if(reg.test(text)){
//如果这个元素是{{}}这种格式
}
}else if(node.nodeType == 1){ //元素节点
let nodeAttr = node.attributes;
Array.from(nodeAttr).forEach(attr => {
if(attr == "v-model"){
//如果这个元素有v-model属性,那么得做点事情了
}
})
}
}
}
现在停下来思考,如果查到了某个元素的属性有v-model,我们该做什么。
一个数据变化,所有它关联的dom元素都需要更新。咦,这不就是观察者模式做的事吗!观察者会被添加到目标中,目标一通知,所有的观察者都会更新。所以,查到元素有v-model后(或者{{}}),就需要创建一个观察者,添加到目标(数据)中。
4、实现观察者
观察者需要实现一个update方法。
class Watcher{
constructor(vm,exp,cb){ //初始化的时候把对象和键值传进来
this._cb = cb;
this._vm = vm;
this._exp = exp; //保存键值
this._value = vm[exp]; //隐藏开关,这句代码会发生什么?
}
update(){
let value = this._vm[_exp];
if(value != this._value){
this._value = value;
this._cb.call(this.vm,value);
}
}
}
vm[exp] 就会触发get,这点很重要。
5、实现目标
观察者是被添加到目标上的,所以得写个目标的构造函数
class Dep{ //目标
constructor(){
this.subs = [];
}
add(watcher){
this.subs.push(watcher)
}
notify(){
this.subs.forEach(sub => {
sub.update();
})
}
}
6、准备就绪,将一切串联起来
(1)每次给key值添加get,set的时候都要创建一个Dep(目标)。
(2)每次模板编译的时候,遇到v-model或者{{}}就创建一个观察者添加到Dep。同时将data里的值赋给node。input还需要绑定一个input事件,输入时改变对象里的值。
(3)准备一个全局变量,利用key值的get属性添加watcher。
完成版:
watcher:
var uId = 0;
class Watcher{
constructor(vm,exp,cb){ //初始化的时候把对象和键值传进来
this._cb = cb;
this._vm = vm;
this._exp = exp; //保存键值
this._uid = uId;
uId++; //每个观察者配个ID,防止重复添加
Target = this;
this._value = vm[exp]; //看到没,这里触发getter了
Target = null; //用完就删
}
update(){
let value = this._vm[this._exp];
if(value != this._value){
this._value = value;
this._cb.call(this.vm,value);
}
}
}
obeserve:
function defineReactive(data,key,val){
observe(val);
let dep = new Dep();
Object.defineProperty(data,key,{
enumerable: true,
configurable: true,
get(){
Target && dep.add(Target); //添加观察者了
return val;
},
set(newval){
val = newval;
dep.notify(); //通知所有观察者去更新
}
})
}
watcher:
_compile(node){
if(node.nodeType == 3){ //文本节点
let reg = /\{\{(.*)\}\}/;
let text = node.textContent;
if(reg.test(text)){
//如果这个元素是{{}}这种格式
let key = RegExp.$1;
node.textContent = this._vm[key];
new Watcher(this._vm,key,val=>{
node.textContent = val;
})
}
}else if(node.nodeType == 1){ //元素节点
let nodeAttr = node.attributes;
Array.from(nodeAttr).forEach(attr => {
if(attr.nodeName == "v-model"){
node.value = this._vm[attr.nodeValue]; //初始化赋值
//如果这个元素有v-model属性,那么得做点事情了
node.addEventListener('input',()=>{
this._vm[attr.nodeValue] = node.value;
})
new Watcher(this._vm,attr.nodeValue,val =>{
node.value = val;
})
}
})
}
}
一个很简单双向绑定,很多指令我都没去解析,看看理解下观察者模式就好了。附上Github