mv*设计思想

前言

Vue是一个非常出名的MVVM框架,其设计理念主要核心是view-model。现在Vue3使用的是Proxy,而过去则是Object.defineProperty。这篇转文主要参考的是后者相关资料以及设计理念,从实现一个简单的Vue着手,很有阅读价值。


MVVM双向绑定的简单流程

首先是思路上的明确。Vue中采用的是Object.defineProperty()中的settergetter来对每个属性进行劫持,同时结合订阅者-发布者模式的方式,实现数据模版的自动更新。

简单的流程可以这样来理解:

一、MVVM编译前新建一个Observer对象来拦截data里面的每个数据,大概有以下几个步骤:

  1. 使用闭包,让每个属性有一个各自的Dep对象来存放自己的依赖队列,里面是一系列的Watcher
  2. getter中把Watcher添加到队列上
  3. setter中让Dep对象触发update,即依次执行里面的Watcher
  4. 视图更新的逻辑就放在Watcher

这样,我们在每次更新数据的时候就是触发setter从而实现视图更新。

但这里有两个关键问题需要解决:

  1. 编译是在Observer之后的,如何实现在getter中把Watcher添加到队列上?
  2. 一个属性就只有一个getter,但却可以有多个Watcher依赖,如何确保准确地添加Watcher到队列上?

二、新建一个Compiler对象进行模版编译,大概有以下几个步骤:

  1. 对每个元素节点的指令进行扫描和解析,根据指令调用相应的handler函数进行处理
  2. 对每个属性依赖新建Watcher对象进行监听

三、在MVVM实例初始化中整合流程一和流程二

一个简单的MVVM实现流程大概就是这些步骤,下面大概介绍下具体实现思路:

Observer实现

对于第一个问题,解决也不难,我们在Watcher的初始化中对要监听的数据进行访问,自然就会触发到getter

巧妙的地方在第二点,如何确保当前的Watcher能够被正确添加?在需要把自身添加到队列时,我们可以在Dep全局对象中设置Dep.target为自身,在getter中则进行判断是否有Dep.target这个属性才决定是否进行添加队列操作,看一下别人实现的一个简单版的代码,留意 get方法里面的一段:

var uid = 0; //避免重复添加

function Watcher(vm, expOrFn, cb){
  this.uid = uid++;
  this.$vm = vm;
  this.expOrFn = expOrFn;
  this.value = null;
  this.cb = cb;
  this.update();//初始化时执行一遍
}
Watcher.prototype = {
  get: function(){
    Dep.target = this;   //把自身添加到target上
    var value = computeExpression(this.$vm, this.expOrFn);  //这里会触发到getter
    Dep.target = null;   //执行完记得设为null
    return value;
  },
  ....//省略
}

//精髓在于with与eval的应用,用with指定scope作用域,然后用eval执行表达式
//其实用eval会有安全问题,而且性能上不太好,更好的解决办法是使用New Function()来动态构建函数,表达式置于函数体内
function computeExpression(scope, exp){
  try{
    with(scope){
      return eval(exp);
    }
  } catch(e){
    console.error('ERROR', e);
  }
}

然后是getter里面,留意到判断到有Dep.target属性才添加到依赖队列中:

Observer.prototype = {
    ....//省略
    defineReactive: function(data, key, val){
        var dep = new Dep();
        var self = this;
        self.observe(val); //如果是对象则递归遍历

        Object.defineProperty(data, key, {
            enumerable: true, //可枚举遍历
            configureable: false, //不可再次配置
            get: function(){
                Dep.target && dep.addSub(Dep.target);
                return val;
            },
            set: function(newVal){
                if(val === newVal){ return; }
                val = newVal;
                self.observe(newVal);  //对新值进行遍历
                dep.notify();  //执行更新
            }
        })
    }
}

Dep里面的代码比较简单,无非就是维护一个存放Watcher的对象:

function Dep(){
  this.subs = {};
}
Dep.prototype = {
  addSub: function(sub){
    //防止重复添加Watcher
    if(!this.subs[sub.uid]){
      this.subs[sub.uid] = sub;
    }
  },
  notify: function(){
    for(var uid in this.subs){
      this.subs[uid].update();
    }
  }
}

要知道js是单线程的,所以可以确保每次只有一个Watcher在调用,也就确保了它能准备地添加到它所监听变量的依赖队列上。这个做法可谓是十分巧妙,不得不佩服尤大大的厉害。

经过这样完整的一个结构,我们就已经可以简单地实现拦截变量和通知变化的功能了。

Compiler实现

要实现这个需要对原生的一些DOM属性和节点操作办法比较熟悉,下面以一个最简单的文本节点解析为例。

文本节点里面的表达式一般是 {{ a + 'b' }} + 某些文字 这样,对于这种字符串,我们就要转换为scope.a + 'b' + '某些文字'这样的表达式来执行,可以回顾一下上面的computeExpression函数,下面继续看一下别人的简单实现

//先看下parseTextExp函数,其实就是正则匹配加字符串拼接的过程
function parseTextExp(text) {
    //匹配{{ }}里面的内容
    var regText = /\{\{(.+?)\}\}/g;
    //存放其余的片段,类似'某些文字'这些
    var pieces = text.split(regText);
    var matches = text.match(regText);
    var tokens = [];
    pieces.forEach(function (piece) {
        if (matches && matches.indexOf('{{' + piece + '}}') > -1) {    // 注意排除无{{}}的情况
            tokens.push(piece);
        } else if (piece) {
            tokens.push('`' + piece + '`');
        }
    });
    //最后返回类似 scope.a + 'b' + '某些文字' 这样的字符串表达式
    return tokens.join('+');
}

function Compiler(el, vm){
    this.$el = el;
    this.$vm = vm;
    if (this.$el) {
        //转换为节点片段,提高执行效率,同时用于去除一些注释节点,空文本节点等
        this.$fragment = nodeToFragment(this.$el);
        this.compiler(this.$fragment);
        this.$el.appendChild(this.$fragment);
    }
}
Compiler.prototype = {
    //分两类,元素节点和文本节点,同时进行递归
    compiler: function(node, scope){
        var childs = [].slice.call(node.childNodes);
        childs.forEach(function(child){
            if (child.nodeType === 1) {
                this.compileElementNode(child, scope);
            }else if(child.nodeType === 3){
                this.compileTextNode(child, scope);
            }
        }.bind(this))
    },
    compileTextNode: function(textNode, scope){
        var text = textNode.textContent.trim();
        if(!text) return;

        //将文本中的{{a + 'bbb'}} asdsd 转换成 scope.a + 'bbb' + asdsd 的形式
        var exp = parseTextExp(text);
        scope = scope || this.$vm;

        this.textHandler(textNode, exp, scope);
    },
    textHandler: function(textNode, exp, scope){
        //增加一个Watcher依赖
        new Watcher(scope, exp, function(newVal){
            textNode.textContent = !newVal ? '' : newVal ;
        })
    },
    ....//省略
}

先转换成fragment,然后对于文本节点,直接解析并转换里面的内容,然后增加一个Watcher依赖。我们再看一下Watcher里面的代码

function Watcher(vm, expOrFn, cb){
  this.uid = uid++;
  this.$vm = vm;
  this.expOrFn = expOrFn;
  this.value = null;
  this.cb = cb;
  this.update(); //初始化时就先执行一次cb函数
}
Watcher.prototype = {
  get: function(){
    Dep.target = this;
    var value = computeExpression(this.$vm, this.expOrFn);
    Dep.target = null;
    return value;
  },
  update: function(){
    //此处会调用getter,将Watcher添加到dep里面
    var newVal = this.get();
    if(newVal !== this.value){
      this.cb.call(this.$vm, newVal, this.value);
      this.value = newVal;
    }
  }
}

可以看到,在Watcher初始化时便自动添加到依赖队列中,同时也得到了经过首次解析后的表达式的值,并存放在this.value中,而且还执行了回调,触发了视图更新。

这是最简单的文本节点的解析,对于像v-forv-ifv-model等其他较为复杂的指令都有其相应的处理办法,并且有些指令的实现是十分有趣的,详情可以阅读文章最后的Reference,我就不复制粘贴了。

MVVM实例化

有了前两部分的实现,这里的实例化就显得简单很多了,继续看代码:

function MVVM(options){
  this.$options = options;
  //先提取根节点
  this.$el = typeof options.el === 'string'
    ? document.querySelector(options.el)
  : options.el || document.body;

  var data = this._data = this.$options.data;

  //Observer所有数据
  var ob = new Observer(this._data);
  if(!ob) return;

  //对data里面的数据代理到实例上
  Object.keys(data).forEach(function(key){
    this._proxy(key);
  }.bind(this))

  //模版编译
  new Compiler(this.$el, this);
}

MVVM.prototype = {
  _proxy: function(key){
    var self = this;
    Object.defineProperty(self, key, {
      configureable: false,
      enumerable: true,
      get: function(){
        return self._data[key];
      },
      set: function(val){
        self._data[key] = val;
      }
    })
  },
  $watch: function(expOrFn, cb){
    new Watcher(this, expOrFn, cb);
  }
}

正如前面所说,这里只需要把ObserverCompiler整合一下就可以了。需要注意的是这里实现了一个代理,因为它的数据是挂载在vm._data上的,假如我们要改变数据的值,则要用vm._data.a = xxx 这样的方式来改变,这样显示是不符合我们期望的,我们希望可以直接用vm.a = xxx 这样的方式来改变数据的值。

所以我们增加了一个_proxy函数,其实主要还是用Object.defineProperty()这个方法来拦截类似vm.a这样的属性,使它变成返回和设置vm._data.a上的值。至此,一个简单版的MVVM变完全实现了。

总结

首先需要说明的是上面的代码基本上都是各种博客或者源码里面的,我这里主要是分析其实现思路。当然我自己也照着这些代码仿造了一个,但其实代码内容大同小异,就没必要贴上来了。

写这篇文章的目的主要是让自己明白一个流行的轮子是大概是基于怎样的思路造出来的,旨在提升一下撸码水平。如有不妥的地方大家一起探讨。

关于Vue2.0的源码其实还有非常多值得学习的地方,例如virtual dom及其diff算法实现,各种正则的巧妙运用,transition过渡指令集的实现等。可惜本人水平有限,还不能完全参透其原理,看以后有没机会解读。最后,感谢大家的阅读!

本文转载自Github_Bless-L

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,723评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,003评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,512评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,825评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,874评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,841评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,812评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,582评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,033评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,309评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,450评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,158评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,789评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,409评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,609评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,440评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,357评论 2 352