Dive into Vue.js

摘要

Vue.js做为先进的前端MVVM框架,在外卖已经普遍应用在各业务线中。本文阐述了Vue.js做为前端MVVM框架的主要优点,并从Vue.js的三个核心点:Observer, Watcher, Compiler出发,深刻阐述Vue.js的设计与实现原理。javascript

背景

Vue.js 是一个轻量级的前端 MVVM 框架,专一于web视图(View)层的开发 。 自2013年以来,Vue.js 经过其可扩展的数据绑定机制、极低的上手成本、简洁明了的API、高效完善的组件化设计等特性,吸引了愈来愈多的开发者。在github上已经有30,000+ star,且不断在增加;在国内外都有普遍的应用,社区和配套工具也在不断完善,影响力日益扩大,与React 、AngularJS 这种「世界级框架」几乎分庭抗礼。 外卖B端的FE同窗比较早(0.10.x版本)就引入了 Vue.js 进行业务开发,通过一年多的实践,积累了必定的理解。在此基础上,咱们但愿去更深刻地了解 Vue.js , 而不是一直停留在表面。因此「阅读源码」成为了一项课外任务。 我我的从9月份开始阅读Vue的源码,陆陆续续看了2个月,这里是个人源码学习笔记。本篇文章但愿从 Vue.js 1.0版本的设计和实现为主线,阐述本身阅读源码的一些心得体会。html

前端为何须要有MVVM框架?

前端刀耕火种的历史在这里就不赘述,在jquery 等DOM操做库大行其道的年代,主要的开发痛点集中于:前端

当数据更新时,须要开发者主动地使用 DOM API 操做DOM;当DOM发生变化时,须要开发者去主动获取DOM的数据,把数据同步或提交; 一样的数据映射到不一样的视图时,须要新的一套DOM操做,复用性低; 大量的DOM操做使得业务逻辑繁琐冗长,代码的可维护性差。 因而,问题的聚焦点就在于:vue

业务逻辑应该专一在操做数据(Model),更新DOM不但愿有大量的显式操做。 在数据和视图间作到同步(数据绑定和更新监听),不须要人为干预。 一样的数据能够方便地对应多个视图。 此外,还应该作到的一些特性:java

方便地实现视图逻辑(声明式或命令式); 方便地建立和链接、复用组件; 管理状态和路由。 MVVM框架能够很好地解决以上问题。经过 ViewModel 对 View 和 Model 进行桥接,而Model 和 ViewModel 之间的交互是双向的,View 数据的变化会同步到 Model 中,Model 中的数据变化也会当即反应到 View 上,即咱们一般所说的「双向绑定」。这是不须要人为干涉的,因此开发者只须要关注业务逻辑,其余的DOM操做、状态维护等都由MVVM框架来实现。node

Why Vue?

Vue.js的优势主要体如今:jquery

开发者的上手成本很低,开发体验好。 若是使用过 angular 的同窗就知道,里面的API多如牛毛,并且还会要求开发者去熟悉相似 controller , directive ,dependency injection , digest cycle 这些概念; angular2 更是须要提早去了解 Typescript 、RxJS 等基础知识; 要让一个前端小白去搞定 React 的全家桶,ES6 + Babel , 函数式编程 ,JSX , 工程化构建 这些也是必须要过的槛。 Vue.js 就没有这些开发负担,对开发者屏蔽了一系列复杂的概念,API从数量、设计上都十分精简,很接地气地支持js的各类方言,让前端小白能够快速上手 — 固然,对于有必定经验的同窗,也可使用流行的语言、框架、库、工程化工具来作自由合理搭配。git

博采众长,集成各类优秀特性 — Vue.js 里面有像 angular 这样的双向数据绑定,2.0版本也提供了像 React 这样的 JSX,Virtual-DOM ,服务端同构 的特性;同时 vuex ,vue-router ,vue-cli 等配套工具也组成了一个完整的框架生态。github

性能优秀。 Vue.js 在1.x版本的时候性能已经明显优于同期基于 dirty check (条件性全量脏检查) 的 angular 1.x ;整体上来讲,Vue.js 1.x版本 的性能与React 的性能相近,并且 Vue.js 不须要像React 那样去手动声明shouldComponentUpdate 来优化状态变动时的从新渲染的性能。Vue2.0版本使用了Virtual DOM + Dependency Tracking 方案,性能获得进一步优化。固然,不分场景的性能比较属于耍流氓。 这个benchmark 对比了主流前端框架的性能,能够看出 Vue.js 的性能在大部分场景下都属于业界顶尖。web

Vue.js的绑定设计思路

根据上面篇幅的描述,MVVM框架工做的重中之重是创建 View 和 Model 之间的关系。也就是「绑定」。官方文档的附图说明了这一点:

从上图,只能获得一些基(cu)础(qian)的认识:

Model是一个 POJO 对象,即简单javascript对象。 View 经过 DOM Listener 和 Model 创建绑定关系。 Model 经过 Directives(指令),如 {{a}} , v-text="a" 与 View 创建绑定关系。 而实际上要作的工做仍是不少的:

让 Model 中的数据作到 Reactive ,即在状态变动(数据变化)时,系统能作出响应。 Directives(指令) 混杂在一个html片断(fragment,或者你能够理解就是Vue实例中的 template )中,须要正确解析指令和表达式(expression),不一样的指令须要对应不一样的DOM更新方式,最易理解的例子就是 v-if 和 v-show ; Model 的更新触发指令的视图更新须要有必定的机制来保证; 在 DOM Listener 这块,须要抹平不一样浏览器的差别。 Vue.js 在实现「绑定」方面,为全部的指令(directives)都约定了 bind 和 update 方法,即:

解析完指令后,应该如何绑定数据 数据更新时,怎样更新DOM Vue.js 的解决方案中,提出了几个核心的概念:

Observer : 数据观察者,对全部 Model 数据进行 defineReactive,即便全部 Model 数据在数据变动时,能够通知数据订阅者。 Watcher : 订阅者,订阅并收到全部 Model 变化的通知,执行对应的指令(表达式)绑定函数 Dep : 消息订阅器,用于收集 Watcher , 数据变化时,通知订阅者进行更新。 Compiler : 模板解析器,可对模板中的指令、表达式、属性(props)进行解析,为视图绑定相应的更新函数。 可见这里面的核心思想是你们(特别FE同窗)都很熟悉的「观察者模式」。整体的设计思路以下:

回到刚才说的 bind 与 update,咱们看看上述概念是如何工做的:

在初始化视图,即绑定阶段,Observer 获取 new Vue() 中的data数据,经过Object.defineProperty 赋予 getter 和 setter; 对于数组 形式的数据,经过劫持某些方法让数组在变更时也能获得通知。另外,Compiler 对DOM节点指令进行扫描和解析,并订阅Watcher 来更新视图。Watcher 在 消息订阅器 Dep 中进行管理。 在更新阶段,当数据变动时,会触发 setter 函数,当即会触发相应的通知, 开始遍历全部订阅者,调用其指令的update方法,进行视图更新。 OK,下面咱们继续深刻看三个「核心点」,即 Observer, Compiler, Watcher 的实现原理。

数据观察者 Observer 的实现原理

前面提到,Observer 的核心是对 Model(data) 中的数据进行 defineReactive。 这里的实现以下:

Vue.js 在初始化data时,会先将data中的全部属性代理到Vue实例下(方便使用 this 关键字来访问),而后即调用 Observer 来进行数据观察。 Observer会先将全部 data 中的属性进行总体观察,定义一个属性__ob__ ,进行Object.defineProperty,即为 data 自己添加观察器。 一样,对于data中的每一个属性也会使用 ob 为每一个属性自己添加观察器。 同理,当定义了相似以下的属性值为POJO对象时,会去递归地 Object.defineProperty ;

{
    data: {
        a: {
            b: "c"
        }
    },
    d: [1, 2, 3]
}
复制代码

那么,当定义的属性值为数组时,在数组自己经过方法变化时,也须要监听数组的改变。 经过javascript操做数组的变化无外乎如下几种方式:

经过 push , pop 等数组原生方法进行改变; 经过length属性进行改变,如 arr.length = 0; 经过角标赋值, 如 arr[0] = 1 。 对于数组原生的方法,咱们须要在使用这些方法时同时触发事件,让系统知道这个数组改变了。那么,咱们一般会想到去「劫持」数组自己的方法。可是显然,咱们不能直接去覆写 Array.prototype , 这样的全局覆写显然会对其余不须要响应式数据的数组操做产生影响。 Vue.js 的思路在于,当监测到一个data属性值是Array时,去覆写这个属性值数组的 proto 属性,即只覆写响应式数据的原型变量。核心实现以下:

function Observer(value) {
    this.value = value
    this.dep = new Dep()
    _.define(value, '__ob__', this)
    // 若是判断当前值是Array, 进行劫持操做
    if (_.isArray(value)) {
        var augment = _.hasProto
            ? protoAugment
            : copyAugment
        // 在这里,arrayMethods是进行了劫持操做后的数组原型
        // augment的做用便是覆写原型方法
        augment(value, arrayMethods, arrayKeys)
        this.observeArray(value)
    } else {
        // 递归地defineProperty
        this.walk(value)
    }
}
复制代码

对于直接改变数组length来修改数组、角标赋值,显然不能直接劫持。这时一种实现方式是把原生的 Array 作上层包装,变成一个Array like Object, 再在这里面进行defineProperty, 这样能够搞定length和角标赋值。可是这样作的弊端是,每次在使用数组时都须要显式去调用这个对象,如:

var a = new ObservableArray([1, 2, 3, 4]);
复制代码

这样显然增长了开发者上手成本,并且改变length能够经过splice来实现;因此 Vue.js 并无实现此功能,是一种正确的取舍。 对于角标赋值,仍是有必定的使用场景,因此 Vue.js 扩展了 $set 和 $remove 方法来实现。 这两种方法实质仍是在使用可被劫持的 splice,而被劫持的方法能够触发视图更新。

example1.items[0] = { childMsg: 'Changed!'} // 不能触发视图更新
example1.items.$set(0, { childMsg: 'Changed!'}) // 能够触发视图更新
复制代码
  • 监测Object对象的改变,有一个提案期的 Object.observe() 方法,但如今已经被浏览器标准废弃,各浏览器均再也不支持。一样有一个非标准的监视方法Object.watch() 被Firefox 支持,但不具有通用性。
  • 监测数组对象的改变,一样有一个提案性的Array.observe()。它不只能监视数组方法,还能够监视角标赋值等变化。可是这个提案也已经被废弃。
  • 理论上,使用 ES6 的 Proxy 对象也能够进行 get 和 set 的拦截, 但浏览器支持状况并很差,实用性不高。
  • 因此,当前的条件下 Object.defineProperty 还是最好的选择 — 固然,在IE8及更低版本浏览器盛行的年代,基于此特性的MVVM框架就很难大规模被普及。

解析器Compiler的实现

Compiler 的做用主要是解析传入的元素 el 或模板 template ,建立对应的DOMFragment,提取指令(directive)并执行指令相关方法,并对每一个指令生成Watcher。 主要的入口点是挂载在Vue.prototype下的 _compile 方法(实际内容在instance/lifecycle.js, Vue1.x的不一样版本位置略有不一样)。 整个 _compile 方法的流程以下:

首先执行 transclude() , 实际是在处理template标签或 options.template 字符串,将其解析为DOMFragment, 拿到 el 对象。 其次执行_initElement(el), 将拿到的el对象进行实例挂载。 接着是CompileRoot(el, options) ,解析当前根实例DOM上的属性(attrs); 而后执行Compile(el, options),解析template,返回一个link Funtion(compositeLinkFn)。 最后执行这个compositeLinkFn,建立 Compile(el, options) 的具体流程以下:

首先,compile过程的基础函数是compileNode, 在检测到当前节点有子节点的时候,递归地调用compileNode即对DOM树进行了遍历解析。 接着对节点进行判断,使用comileElement 或 compileTextNode 进行解析。 咱们看到最终compile的结果return了一个compositeLinkFn, 这个函数的做用是把指令实例化,将指令与新建元素创建链接,并将元素替换到DOM树中。 compositeLinkFn会先执行经过comileElement 或 compileTextNode 产出的Linkfn 来建立指令对象。 在指令对象初始化时,不但调用了指令的bind, 还定义了 this._update 方法,并建立了 Watcher,把 this._update 方法(实际对应指令的更新方法)做为 Watcher 的回调函数。 这里把 Directive 和 Watcher 作了关联,当 Watcher 观察到指令表达式值变化时,会调用 Directive 实例的 _update 方法,最终去更新 DOM 节点。 以compileTextNode为例,写一段伪代码表示这个过程:

// compile结束后返回此函数
function compositeLinkFn(arguments) {
    linkAndCapture()
    // 返回解绑指令函数,这里不深究。
    return makeUnlinkFn(arguments)
}
function linkAndCapture(arguments) {
    // 建立指令对象
    linkFn()
    // 遍历 directives 调用 dirs[i]._bind 方法对单个directive作一些绑定操做
    // 这里会去实例化单个指令,执行指令的bind()函数,并建立Watcher
    dirs[i]._bind()
}
// 解析TextNode节点,返回了linkFn
function compileTextNode(node) {
    // 对节点数据进行解析,生成tokens
    var tokens = textParser.parse(node.data)
    createFragment()
    // 建立token的描述,做为后续生成指令的依据
    setTokenDescriptor()
    /**
   do other things
   **/
    return linkFn(tokens, ...);
}
// linkFn遍历token,遍历执行_bindDir, 传入token描述
function linkFn() {
    tokens.forEach(function (token) {
        if (token.html) replaceHtml();
        vm._bindDir(token.discriptor)
    })
}
// 根据token描述建立指令新实例
Vue.prototype._bindDir = function (descriptor) {
    this._directives.push(new Directive(descriptor))
}
复制代码

至此,compiler 的工做就结束了。

Watcher订阅监听的实现

Watcher的职责

在上述compiler的实现中,最后一步用于建立Watcher:

// 为每一个directive指令建立一个watcher dirs[i]._bind() Directive.prototype._bind = function () { ... // 建立Watcher部分 var watcher = this._watcher = new Watcher( this.vm, this.expression, this._update, // callback { filters: this.filters, twoWay: this.twoWay, deep: this.deep, preProcess: preProcess, postProcess: postProcess, scope: this._scope } ) } 接收的参数是vm实例、expression表达式、 callback回调函数和相应的Watcher配置, 其中包含了上下文信息: this._scope。 每一个指令都会有一个watcher, 实时去监控表达式的值,若是发生变化,则通知指令执行 _update 函数去更新对应的DOM。那么咱们能够想到,watcher主要作的工做是:

  • 解析表达式,如 v-show="a.b.c > 1" ; 表达式须要转换成函数来求值;
  • 自动收集依赖。

watcher的实现

这部分工做的实现以下:

使用状态机进行路径解析

这里的parse Expression使用了路径状态机(state machine)进行路径的高效解析。 详细代码见 parsers/path.js 部分。 这里所谓的「路径」就是指一个对象的属性访问路径:

a = {
    b: {
        c: 'd'
    }
}
复制代码

在这里, ‘d’的访问路径便是 a.b.c, 解析后为['a', 'b', 'c']。 如一个表达式 a[0].b.c, 解析后为 ['a', '0', 'b', 'c']。 表达式a[b][c]则解析为 ['a', '*b', '*c']。 解析的目的是进行compileGetter, 即 getter 函数; 解析为数组缘由是,能够方便地还原new Function()构造中正确的字符串。

exports.compileGetter = function (path) {
    var body = 'return o' + path.map(formatAccessor).join('')
    return new Function('o', body)
}
function formatAccessor(key) {
    if (identRE.test(key)) { // identifier
        return '.' + key
    } else if (+key === key >>> 0) { // bracket index
        return '[' + key + ']'
    } else if (key.charAt(0) === '*') {
        return '[o' + formatAccessor(key.slice(1)) + ']'
    } else { // bracket string
        return '["' + key.replace(/"/g, '\\"') + '"]' } } 复制代码

如一段表达式:

<p>{{list[0].text}}</p>
解析后path为["list", "0", "text"], getter函数的生成结果为:
复制代码
(function (o/**/) {
    return o.list[0].text
})
复制代码

把正确的上下文传入此函数便可正确取值。 Vue.js 仅在路径字符串中带有 [ 符号时才会使用状态机进行匹配;其余状况下认为它是一个simplePath, 如a.b.c,直接使用上述的formatAccessor转换便可。 状态机的工做原理以下:

里面的逻辑比较复杂,能够简单地描述为:

  • Vue.js 里面维护了一套状态机机制,每解析一个字符,均匹配对应的状态;
  • 如当前的字符索引是0,那么就会有一个「当前状态」的模式(mode),这个模式只容许下一个字符属于特定的状态模式。举例,如 ][ 这样的表达式显然不合理, "]"字符所在的状态,决定了下个字符不能为 "[" 这样的
  • 字符,不然就会退出解析;
  • 接下来的索引去根据「当前状态」的模式看本身是否属于一个合理的状态;
  • 若是属于一个合理的状态,先设置当前状态的模式为当前字符匹配的状态模式;
  • 再调用相关的方法(action)来处理。例如,list[0]这个表达式,在处理"l", "i", "s", "t" 时,只是在执行 append action , 生成"list" ; 直到遇到 "[" , 执行 push action , 把"list"字符串推入结果中。

Vue.js 的状态机设计能够看勾三股四总结的这张图。

缓存系统

想象一个场景:当存在着大量的路径(path)须要解析时,极可能会有大量重复的状况。如上面所述,状态机解析是一个比较繁琐的过程。那么就须要一个缓存系统,一旦路径表达式命中缓存,便可直接获取,而不用再次解析。 缓存系统的设计应该考虑如下几点:

缓存的数据应是有限的。不然容易数据过多内存溢出。 设定数据存储条数应结合实际状况,经过测试给出。 缓存数据达到上限时,若是继续有数据存入,应该有相应的策略去清除现有缓存。 Vue.js 在缓存系统上直接使用了js-lru项目。这是一个LRU(Least Recently Used)算法的实现。核心思路以下:

基础数据结构为js实现的一个双向链表。 cache对象有头尾,即一个head(最少被使用的项)和tail(最近被使用的项)。 缓存中的每一项均有newer和older的指针。 缓存中找数据使用object key进行查找。 具体实现以下图:

由图理解很是简单:

  • 获取缓存项B,把B插入变为tail, D和B创建 newer,older 关系,A和C创建newer, older关系;
  • 设置缓存项E,把E插入变为tail, D和E创建 newer,older 关系;
  • 达到缓存上限时,删除缓存项A(head),把B变成head。

缓存系统的其余实现,能够参考wikipedia上的Cache replacement policies。 依赖收集 (Dependency Collection) 让咱们回到Watcher的构造函数:

function Watcher(vm, expOrFn, cb, options) {
    //...
    // 解析表达式,获得getter和setter函数
    var res = parseExpression(arguments)
    this.getter = res.get
    this.setter = res.get
    // 设定Dep.target为当前Watcher实例
    Dep.target = this
    // 调用getter函数
    try {
        value = this.getter.call(scope, scope)
    } catch (e) {
        //...
    }
}
复制代码

这里面又有什么玄机呢?回顾一下 Observer 的 defineReactive :

function defineReactive(obj, key, val) {
    var dep = new Dep()
    var childOb = Observer.create(val)
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function metaGetter() {
            // 若是Dep.target存在,则进行依赖收集
            if (Dep.target) {
                dep.depend()
                if (childOb) {
                    childOb.dep.depend()
                }
            }
            return val
        },
        set: function metaSetter(newVal) {
            if (newVal === val) return
            val = newVal
            childOb = Observer.create(newVal)
            dep.notify()
        }
    })
}
复制代码

可见, Watcher 把 Dep.target 设置成当前Watcher实例, 并主动调用了 getter,那么此时必然会进入 dep.depend() 函数。 dep.depend() 实际执行了 Watcher.addDep() :

Watcher.prototype.addDep = function (dep) {
    var id = dep.id
    if (!this.newDeps[id]) {
        this.newDeps[id] = dep
        if (!this.deps[id]) {
            this.deps[id] = dep
            dep.addSub(this)
        }
    }
}
复制代码

能够看出,Watcher 把 dep 设置为当前实例的依赖,同时 dep 设置(添加)当前 Watcher为一个订阅者。至此完成了依赖收集。 从上面 defineReactive 中的 setter 函数也可知道,当数据改变时,Dep 进行通知 (dep.notify()), 遍历全部的订阅者(Watcher), 将其推入异步队列,使用订阅者的update方法,批量地更新DOM。 至此 Watcher 的工做就完成了。

  • 实际上,Watcher的依赖收集机制也是实现 computed properties ( 计算属性)的基础;核心都是劫持 getter , 触发通知,收集依赖。
  • Vue.js 初期对于计算属性,强制要求开发者设定 getter 方法,后期直接在 computed 属性中搞定,对开发者很友好。
  • 推荐看一下这篇文章:数据的关联计算。

Vue.js的其余黑魔法

因为篇幅所限,本文讨论的内容主要在Observer, Compiler, Watcher 这些核心模块上;但实际上,Vue.js 源码(或历史源码)中还有大量的其余优秀实现,如:

  • batcher 异步队列
  • v-for 中的DOM diff算法
  • exp parser 曾经借鉴了 artTemplate 模板引擎
  • template parser 借鉴了 jquery
  • transition过渡系统设计

等等。其余的代码解耦、工程化、测试用例等也是很是好的学习例子。 此外,若是是对 Vue.js 的源码演进过程比较熟悉的同窗,就会发现 Vue.js 的核心思想是(借用尤大本身的表述):

“把高大上的思想变得平易近人”

从框架概念、开发体验、api设计、全家桶设计等多个方面,Vue.js 都不断地往友好和简洁方向努力,这也是如今这么火爆的缘由吧。

如何阅读开源项目的源码?

最后,想把一些读源码的体验和各位同窗分享:

  • 不要一上来就看最新版本的实现,由于很难看懂。反而去看最初的实现,容易了解做者的核心理念。
  • 带着问题和测试用例去看源码。
  • 多使用调试工具,跑各类测试流程,光看就能看懂…除非你是人肉编译机。
  • 找一个(或多个)小伙伴和你一块儿看源码,多交流,效果比一我的好不少。
  • 锲而不舍,若是不保持阅读持续性,很容易遗忘和失去学习兴趣。
  • 持续性地总结,好记性不如烂笔头,真正从你本身总结出来的,才是被你吸取的知识。

以上,共勉。

相关文章
相关标签/搜索