《深入浅出vue.js》

1,077 阅读13分钟

前言

所有技术解决方案的终极目标都是解决问题,都是先有问题,然后有解决方案,解决方案并不完美,也可能有很多种。

vue.js也是如此,它解决了什么问题?如何解决?解决问题的同时都做了哪些权衡和取舍?

本书达到的目的

  • Vue.js的响应式原理,理解为什么修改数据视图会自动更新
  • 虚拟DOM的概念和原理
  • 模板编译原理,理解vue.js的模板是如何生效的
  • Vue.js整体架构设计与项目结构
  • 深入理解Vue.js的生命周期,不同的生命周期钩子之间有什么区别,不同的生命周期之间Vue.js部到底发生了什么
  • Vue.js提供的各种API的内部实现原理
  • 指令的实现原理
  • 过滤器的实现原理
  • 使用vue.js开发项目的最佳实践

第一章 Vue.js 简史

Vue的一个理念:渐进式框架

所谓渐进式框架,就是把框架分层。然后往外就是组件机制,在这个基础上再加入路由机制,再加入状态管理,最外层是构建工具。就是根据我们不同的需求一步步去添加这些应用层级。

第二章 Object的变化侦测

带着目的去阅读书籍才会更能理解吧,在开头的第一个目的就是:Vue.js的响应式原理,理解为什么修改数据视图会自动更新

2.1 什么是变化侦测

响应式系统赋予框架重新渲染的能力,其重要组成部分是变化侦测。简单来说,变化侦测的作用就是侦测数据的变化。当数据变化时,会通知试图进行相应的更新 Object和Array的变化侦测采用不同的处理方式,我们先来看Object的变化侦测

状态不断发生变化,就需要不停地重新渲染,那我们如何确定状态发生了什么变化呢?“推”(push)和“拉”(pull)

从Vue.js2.0开始,它引入了虚拟DOM,将粒度调整为中等粒度,即一个状态绑定的依赖不再是具体的DOM节点,而是一个组件。这样状态变化后,会通知到组件,组件内部再使用虚拟DOM进行对比。

2.2 如何追踪变化

问题:在js中如何侦测一个对象的变化?

两种方式:Object.defineProperty和ES6的Proxy

本书还是会以Object.defineProperty讲解,Proxy是vue3尤大大会使用Proxy代替Object.defineProperty

    function defineReactive(data,key,val){
      Object.defineProperty(data,key,{
        enumerable:true,
        configurable:true,
        get:function(){
          return val
        },
        set:function(newVal){
          if(val === newVal){
            return
          }
          val = newVal
        }
      })
    }

封装好之后,每当从data的key中读取数据时,get函数被触发,每当往data的key中设置数据时,set函数被触发。

2.3 如何收集依赖

只是把Object.defineProperty进行封装,并没有实际用处,真正有用的是收集依赖

问题:如何收集依赖?

思考:我们之所以要观察数据,其目的是当数据的属性发生了变化时,可以通知那些曾经使用了该数据的地方

<template>    
  <h1>{{ name }}</h1>  
</template>

该模板中使用了数据name,所以当它发生改变时,要向使用了它的地方发送通知

对于上面的问题,我的回答是,先收集依赖,即把用到数据name的地方收集起来,然后属性发生变化时,把之前收集好的依赖循环触发一遍就好了。

总结就是:在getter中收集依赖,在setter中触发依赖

2.4 依赖在哪里收集

  <script>
    function defineReactive(data, key, val) {
      let dep = [] //新增
      Object.defineProperty(data,key,{
        enumerable:true,
        configurable:true,
        get:function(){
          dep.push(window,target)
          return val
        },
        set:function(newVal){
          if(val === newVal){
            return
          }
          for(let i = 0;i<dep.length;i++){
            dep[i](newVal,val)
          }
          val = newVal
        }
      })
    }
  </script>

这样写有点耦合,我们把依赖收集的代码封装成一个Dep类,它专门帮助我们管理依赖。使用这个类,我们可以收集依赖,删除依赖或者向依赖发送通知等

export default class Dep {
      constructor(){
        this.subs = []
      }
      addSub(sub){
        this.subs.push(sub)
      }
      removeSub(sub){
        remove(this.subs,sub)
      }
      depend(){
        if(window.target){
          this.addSub(window.target)
        }
      }
      notify(){
        // 请注意,该方法并不会修改数组,而是返回一个子数组。如果想删除数组中的一段元素,应该使用方法 Array.splice()。
        const subs = this.subs.slice()
        for(let i = 0,l = subs.length;i<l;i++ ){
          subs[i].update()
        }
      }
      function remove(arr,item) {
        if(arr.length){
          const index = arr.indexOf(item)
          if(index>-1){
            return arr.splice(index,1)
          }
        }
      }
    }

    // 再改造一下defineReactive
    function defineReactive(data,key,val){
      let dep = new Dep() //修改
      Object.defineProperty(data,key,{
        enumerable:true,
        configurable:true,
        get:function(){
          dep:depend() //修改
          return val
        },
        set:function(newVal){
          if(val === newVal){
            return
          }
          val = newVal
          dep.notify() //新增
        }
      })
    }

2.5依赖是谁

数据有很多地方用到,而且类型不一样。但是我们收集依赖是封装好的实例进来的,通知也只是会通知他一个。接着再由它通知其他地方,我们把这个抽象的东西叫watcher吧

2.6什么是Watcher

Watcher是一个中介的角色,数据发生变化时通知它,然后它再通知其他地方

vm.$watch('a.b.c',function(newVal,oldVal){
	//做点什么
})

这段代码表示当data.a.b.c属性发生变化时,触发第二个参数中的函数

    export default class Watcher{
      constructor(vm,expOrfn,cb){
        this.vm = vm
        this.getter = parsePath(expOrfn)
        this.cb = cb
        this.value = this.get()
      }
      get(){
        window.target = this
        let target = this.getter.call(this.vm,this.vm)
        window.target = undefined
        return value
      }
      update(){
        const oldValue = this.value
        this.value = this.get()
        this.cb.call(this.vm,this.value,oldValue)
      }
    }

2.7 递归侦测所有key

现在,其实已经可以实现变化侦测的功能了,但是前面介绍的代码只能侦测数据中的某一个属性,我们希望把数据中的属性(包括所有子属性)都侦测到,所以要封装一个Observer类。这个类的作用是将数据内的所有属性(包括子属性)都转换成getter/setter 的形式,然后去追踪它们的变化:

  /**
   * Observer 类会附加到每一个侦测的Object上,
   * 一旦被附加上,Observer会将object的所有属性转换为getter/setter的形式
   * 来收集属性的依赖,并且当属性发生变化时会通知这些依赖
   * */  
  export class Observer{
    constructor(value){
      this.value = value
      if(!Array.isArray(value)){
        this.walk(value)
      }
    }
    /**
     * walk会将每一个属性都转换成getter/setter 的形式来侦测变化
     * 这个方法只有数据类型为Object时被调用
     * */ 
    walk(obj){
      const keys = Object.keys(obj)
      for(let i = 0;i<keys.length;i++){
        defineReactive(obj,keys[i],obj[keys[i]])
      }
    }
  }

2.8 关于Object的问题

前面介绍了Object类型数据的变化侦测原理,了解了数据的变化是通过getter/setter来追踪的。也正是由于这种追踪方式,有些语法即便是数据发生了变化,vue.js也追踪不到 比如,向Object添加属性:

    var vm = new vue({
      el:'#el',
      template:'#demo-template',
      methods: {
        action(){
          this.obj.name = 'berwin'
        }
      },
      data:{
        obj:{
          
        }
      }
    })

再比如,从obj中删除一个属性

    var vm = new vue({
      el:'#el',
      template:'#demo-template',
      methods: {
        action(){
          delete this.obj.name 
        }
      },
      data:{
        obj:{
          name:'berwin'
        }
      }
    })

Vue.js通过Object.defineProperty来将对象的key转换成getter/setter的形式来追踪变化,但getter/seeter只能追踪一个数据是否被修改,无法追踪新增属性和删除属性,为了解决这个问题,Vue.js提供了两个API--vm.setvm.set与vm.delete

总结

变化侦测就是侦测数据的变化。当数据变化时,要能侦测到并能发出通知

  • Data 通过Observer 转换成了getter/setter 的形式来追踪变化
  • 当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到依赖中。
  • 当数据发生了变化时,会触发setter,从而向Dep中的依赖(Watcher)发送通知
  • Watcher接收到通知后,会向外界发送通知,变化通知到外界后可能会触发视图更新,也有可能触发用户的某个回调函数等

第三章 Array的变化侦测

上一章介绍了Object的侦测方式,本章介绍Array的侦测方式 为什么他们的侦测方式不同呢???

3.1 如何追踪变化

我们可以用一个拦截器覆盖Array.prototype。之后,每当使用Array原型上的方法操作数组时,其实执行的都是拦截器中提供的方法,比如push方法。然后在拦截器中使用原生Array的原型方法去操作数组。这样通过拦截器我们就可以追踪到Array的变化。

3.2 拦截器

经过整理,我们发现Array原型可以改变数组自身内容的方法有7个,分别是push,pop,shift,unshift,splice,sort,和reverse

3.3使用拦截器覆盖Array原型

有了拦截器,想让它生效,直接覆盖Array.prototype.但是这样直接覆盖会污染全局的Array,我们可以在observer中进行覆盖

    export class Observer{
      constructor(value){
        this.value = value
        if(Array.isArray(value)){
          value.__proto = arrayMethods
        }else{
          this.walk(value)
        }
      }
    }

3.5如何收集依赖

而Array 的依赖和Object一样,也在defineReactive中收集

  function defineReactive(data,key,val){
    if(typeof val === 'object') new Observer(val)
    let dep = new Dep()
    Object.defineProperty(data,key,{
      enumerable:true,
      configurable:true,
      get:function(){
        dep.depend()
        // 在这里收集Array依赖
        return val
      },
      set:function(newVal){
        if(val === newVal){
          return
        }
        dep.notify()
        val = newVal
      }
    })
  }

上面的代码新增了一段注释,接下要在这个位置收集依赖。 所以,Array在getter中收集依赖,在拦截器中触发依赖

3.6 依赖列表存在哪儿

Vue.js 把Array的依赖存放在Observe中。 是因为在getter中可以访问Observer实例,同时在Array拦截器中也可以访问到Observer实例

3.12 关于Array的问题

对Array的变化侦测是通过拦截原型的方式实现的。正式因为这种方式,其实有些数组操作Vue.js是拦截不到的,例如:

this.list[0] = 2

即修改数组中第一个元素的值时,无法侦测到数组的变化,所以也不触发re-render或watch等。

this.list.length = 0 

这个清空数组操作也无法侦测到数组的变化

在ES6提供的Proxy来实现这部分的能功能,解决这个问题。vue3的出现,尤大大已经使用了Proxy去重构了这部分的功能了

总结

Array追踪变化的方式和Object不一样。因为它是通过方法来改变内容的,所以我们创建拦截器去覆盖数组原型的方式追踪变化。

第四章 变化侦测相关的API实现原理

4.1 vm.$watch

  • 4.1.1 用法
vm.$watch(exOrFn,callback,[option])

用法:用于观察一个表达式或computed函数在Vue.js实例上的变化。回调函数调用时,会从参数得到新数据(new value)和旧数据(old value)。表达式只接受以点分隔的路径,例如a.b.c。如果是一个比较复杂的表达式,可以用函数代替表达式

vm.$watch('a.b.c',function(newVal,oldVal){// todo}

vm.$watch 返回一个取消观察函数,用来停止触发回调:

var unwatch = vm.$watch('a',(newVal,oldVal) => {})
// 之后取消观察
unwatch()

最后要介绍一下[options]的两个选项deep和immediate。

  • deep 为了发现对象内部值的变化,deep:true
vm.$watch('someObject',callback,{
	deep:true
})
vm.someObject.nestedValue = 123
// 回调函数将被触发

这里需要注意的是,监听数组的变动不需要这么做

  • immediate 将立即以表达式的当前值触发回调 immediate
vm.$watch('a',callback,{
	immediate:true
})
//立即以'a'的当前值触发回调
  • 4.1.2 watch 的内部原理
    Vue.prototype.$watch = function(expOrFn,cb,options){
      const vm = this
      options = options || {}
      const watcher = new Watcher(vm,expOrFn,cb,options)
      if(options.immediate){
        cb.call(vm,watcher.value)
      }
      return function unwatchFn(){
        watch.teardown()
      }
    }

这里有个细节需要注意一下,expOrFn是支持函数的

    export default class Watcher {
      constructor(vm,expOrFn,cb){
        this.vm = vm
        // expOrFn 参数支持函数
        if(typeof expOrFn === 'function'){
          this.getter = expOrFn
        }else{
          this.getter = parsePath(expOrFn)
        }
        this.cb = cb 
        this.value = this.get()
      }
      ......
    }

当expOrFn是函数时,Watcher会同时观察expOrFn函数中读取到的所有Vue.js实例上的响应式数据。当任意一个发生变化时Watcher都会得到通知。 最后,返回一个函数unwatchFn。顾名思义,它的作用是取消观察数据。当用户执行这个函数时,实际上执行了watcher.teardown()来取消观察数据,本质是把watcher实例从当前正在观察的状态依赖列表中移除

4.2 vm.$set

vm.$set(target,key,value)

在object上设置一个属性,如果这个object是响应式的。Vue.js会保证属性被创建后也是响应式的,并且触发视图更新。这个方法主要用来避开Vue.js不能侦测属性被添加的限制
注意: target 不能是Vue.js实例或者Vue.js的根数据对象

  • vm.$set是如何实现的:
import { set } from '../observer/index'
Vue.prototype.$set = set

这里我们在Vue.js的原型上设置set属性。其实我们使用的所有以vm.set属性。其实我们使用的所有以vm.开头的方法都是在Vue.js的原型上设置。vm.$set的具体实现其实是在Observe中抛出的set方法
所以我们先创建一个方法:

export function set(target,key,val){
//todo
}

vm.$delete

vm.$delete(target,key)

总结

我们先介绍了vm.watch的内部实现及其相关参数的实现原理,包括deep,immediateunwatch.随后介绍了vm.watch的内部实现及其相关参数的实现原理,包括deep,immediate和unwatch.随后介绍了vm.set和$delete 内部实现原理

第五章 虚拟DOM简介

本书的第二个目的就是:虚拟DOM的概念和原理

5.1 什么是虚拟DOM

虚拟DOM的解决方式是通过状态生成一个虚拟节点树,然后使用虚拟节点树进行渲染。在渲染之前,会使用新生成的虚拟节点树和上一次生成的虚拟节点树进行对比,只渲染不同的部分
虚拟节点树其实是由组件树建立起来的整个虚拟节点(Virtual Node,也经常简写为vnode)树。

5.2 为什么要引入虚拟DOM

Vue.js 2.0开始选择了一个中等粒度的解决方案,就是引入了虚拟DOM。组件级别是一个watcher实例,就是说即便一个组件内有10个节点使用了某个状态,但其实也只有一个watcher在观察这个状态的变化。所以当这个状态发生变化时,只能通知到组件,然后组件内部通过虚拟DOM去进行对比与渲染。

Vue.js 中的虚拟DOM

它做了两件事

  • 提供与真实DOM节点所对应的虚拟节点vnode.
  • 将虚拟节点vnode和就虚拟节点oldVnode进行比对,然后更新视图 vnode 是JavaScript中一个很普通的对象,这个对象的属性上保存了生成DOM节点所需要的一些数据

总结

虚拟DOM是将状态映射成视图的众多解决方案中的一种,它的运作原理是使用状态生成虚拟节点,然后使用虚拟节点渲染视图
可以虚拟节点缓存,然后使用新创建的虚拟节点和上一次渲染时缓存的虚拟节点进行对比,
Vue.js中通过模板来描述状态与视图之间的映射关系,所以它会先将模板编译渲染函数,然后执行渲染函数生成虚拟节点,最后使用虚拟节点更新视图

第六章 VNode

6.1 什么是VNode

在Vue.js中存在一个VNode类,使用它可以实例化不同类型的vnode实例,而不同类型的vnode实例各自表示不同类型的DOM元素
例如,DOM元素有元素节点,本节点和注释节点等,vnode实例也会对应着有元素节点

 export default class VNode{
      constructor(tag,data,children,text,elm,context,componentOptions,asyncFactory){
        this.tag = tag
        this.data = data
        this.children = children
        this.text = text
        this.ns = undefined
        this.context = context
        this.functionalContext = undefined
        this.functionalOptions = undefined
        this.functionalScopeId = undefined
        this.key = data && data.key
        this.componentOptions = componentOptions
        this.componentInstance = undefined
        this.parent = undefined
        this.raw = false
        this.isStatic = false
        this.isRootInsert = true
        this.isComment = false
        this.isOnce = false
        this.asyncFactory = asyncFactory
        this.asyncMeta = undefined
        this.isAsyncPlaceholder = false
      }
      get child(){
        return this.componentOptions
      }
    }

从上面的代码看成,vnode只是一个名字,本质上其实是JavaScript中的一个普通的对象
简单地说,vnode可以理解成节点描述对象,它描述了应该怎样去创建真实的DOM节点
渲染视图的过程是创建vnode,然后再使用vnode去生成真实的DOM元素,最后插入到页面渲染视图

6.3VNode 的类型

由于创建注释节点的过程非常简单,所以直接通过代码介绍它有哪些属性:

export const createEmptyVNode = text => {
    const node = new VNode()
    node.text = text
    node.isComment = true
    return node
}

例如,一个真实的注释节点:

<!-- 注释节点 -->

所对应的vnode是下面的样子:

{
  text:"注释节点",
  isComment:true
}

虚拟DOM最核心的部分是path,它可以将vnode渲染成真实DOM

当oldVnode和vnode不一样的时候,以vnode为准来渲染视图,进行增删改

总结

通过patch 可以对比新旧两个虚拟DOM,从而只针对发生了变化的节点进行更新视图的操作。本章详细介绍了如何对比新旧两个节点以及更新视图的过程。

第七章 patch

在本章开始,我们主要讨论了在什么情况下创建新节点,将新节点插入到什么位置。还讨论了什么情况下删除节点,删除哪个节点,以及在什么情况修改节点,修改哪个节点

随后,我们介绍了一个元素是怎样从视图中删除

接下来,我们又介绍了一个元素是怎样从视图中删除的

然后,详细介绍了更新节点的详细过程

最后,详细讨论了更新子节点的过程,其中包括创建新增的子节点,删除废弃的子节点,更新发生变化的子节点以及移动位置发生了变化的子节点等

这是第三篇 模板编译原理

在Vue.js内部,模板编译是一项比较重要的技术。我们平时使用Vue.js进行开发时,会经常使用模板。模板赋予我们很多强大的能力,例如可以在模板中访问变量。

但在Vue.js中创建HTML并不是只有模板这一中途径,我们既可以手动写渲染函数来创建HTML,也可以在Vue.js中使用JSX来创建HTML

渲染函数是创建HTML最原始的方法。模板最终会通过编译转换成渲染函数,渲染函数执行后,会得到一份vnode用于虚拟DOM渲染。

第八章 模板编译

在一篇中,我们详细介绍了虚拟DOM,大部分知识都是关于虚拟DOM拿到vnode后所做的事。而模板编译介绍的是如何让虚拟DOM拿到vndoe.

将模板编译成渲染函数

  • 先将模板解析成AST(抽象语法树)
  • 然后使用AST生成渲染函数 由于静态节点不需要总是渲染,所以生成AST之后会在遍历一遍AST,给所有静态节点做一个标记。

模板编译三个部分内容:

  • 将模板解析为AST
  • 遍历AST标记静态节点
  • 使用AST生成渲染函数 三个模块来分别实现各自的功能:
  • 解析器
  • 优化器
  • 代码生成器

深入浅出vue.js 第九章 解析器

解析器的作用

解析器要实现的功能是将模板解析成AST

例如:

<div>
	<p>{{ name }}</p>
</div>

它转换成AST后

    {
      tag:'div'
      type:1,
      staticRoot:false,
      static:false,
      plain:true,
      parent:undefined,
      attrsList:[],
      attrsMap:{},
      children:[
        {
          tag:"p",
          type:1,
          staticRoot:false,
          static:false,
          plain:true,
          parent:{tag:'div',...},
          attrsList:[],
          attrsMap:{},
          children:[{
            type:2,
            text:"{{name}}",
            static:false,
            expression:"_s(name)"
          }]
        }
      ]
    }

其实AST并不是什么神奇的东西,它只是用JavaScript中的对象来描述一个节点,一个对象表示一个节点,对象中的属性用来保存节点所需要的各种数据。当很多个独立的节点通过parent属性和children属性连在一起时,就变成了一个数,而这样一个用对象描述的节点树其实就是AST

总结

解析器的作用就是通过模板得到AST(抽象语法树)

生成AST的过程需要借助HTML解析器,当HTML解析器触发不同的钩子函数时,我们以构建出不同的节点,

随后,我们通过栈来得到当前正在构建的节点的父节点,然后将构建出的节点添加到父节点的下面,最终,当HTML解析器运行完毕后,我们就可以得到一个完整的带DOM层级关系的AST。

第十章 优化器

如果元素节点使用了指令v-pre,那么直接断定它是一个静态节点

优化器的作用是AST中找出静态子树并打上标记,这样两个好处

  • 每次重新渲染时,不需要为静态子树创建新节点
  • 在虚拟DOM中打补丁的过程可以跳过

第十一章 代码生成器

代码生成器是模板编译的最后一步,它的作用是将AST转换成渲染函数中的内容,这个内容可以称为代码字符串。

本章中,我们介绍了代码生成器的作用及其内部原理,了解了代码生成器其实就是字符串拼接的过程。通过递归AST来生成字符串,最先生成根节点,然后在子节点字符串生成后,将其拼接在根节点参数中,子节点的子节点拼接在子节点的参数中,这样一层一层的拼接,直到最后拼接成完整的字符串

最后,字符串拼接好后,会将字符串拼接在with中返回给调用者

第十二章 架构设计与项目结构

第四篇 整体流程

前几篇介绍的是Vue.js在实现一些功能时所要用到的技术,其内容偏底层。

本篇中,我们会介绍Vue.js开发项目时时常用到的API,模板中的各种指令,组件里经常使用的生命周期钩子以及使用事件进行父子组件间的通信,此外,还会定义一些Vue.js插件和过滤器

     // 需要编译器
     new Vue({
       template:'<div>{{ hi }}</div>'
     })
    //  不需要编译器
    new Vue({
      render(h) {
        return h('div', this.hi)
      },
    })

当使用vue-loader或vueify的时候,*.vue文件内部的模板会在构建时编译成JavaSctit.

第十三章 实例方法与全局API的实现原理

上一章介绍了Vue.js内部的整体结构,知道了它会向构造函数添加一些属性和方法。本章中,我们将详细介绍它的实例方法和全局API的实现原理

在Vue.js内部,有这样一段代码

    import {initMixin} from './init'
    import {stateMixin} from './state'
    import {renderMixin} from './render'
    import {eventsMixin} from './events'
    import {lifecycleMixin} from './liftcycle'
    import {warn} from '../util/index'
    function Vue (options){
      if(process.env.NODE_ENV !== 'production' && !(this instanceof Vue)){
        warn('Vue is a constructor and should be called with the `new` keyword)
      }
      this._init(options)
    }
    initMixin(Vue)
    stateMixin(Vue)
    renderMixin(Vue)
    eventsMixin(Vue)
    lifecycleMixin(Vue)
    export default(Vue)

其中定义了Vue构造函数,然后分别调用了initMixin,stateMixin,eventMixin,lifecycleMixin和renderMixin这五个函数,并将Vue构造函数当做参数传给了5个函数。

这5个函数的作用是向Vue的原型中挂载方法。

13.1 数据相关的实例方法

与数据相关的实例方法有3个,分别是vm.watch,vm.watch,vm.set,vm.$delete,它们是在stateMixin中挂载到Vue的原型上的,

import {set,del} from '../observer/index'
export function stateMixin(vue){
  Vue.prototype.$set = set
  Vue.prototype.$delete = del
  Vue.Prototype.$watch = function (expOrFn,cb,options){}
}

当stateMixin被调用时,会向Vue构造函数的prototype属性挂载这个3个实例方法

13.2 事件相关的实例方法

与事件相关的实例方法:vm.on,vm.on,vm.once,vm.offvm.off和vm.emit。当eventMixin被调用时,会添加这个4个实例方法

13.2.1 vm.$on

vm.$on(event,callback)

这个和v-on 好像没关系,别搞混了。

用法 监听当前实例上的自定义事件,事件可以由vm.$emit触发。回调函数会接收所有传入事件所触发的函数的额外参数

vm.$on('test',function(msg){
  console.log(msg)
})
vm.$emit('test','hi')
// => 'hi'

vm._events 是一个对象,用来存储事件。在代码中,我们使用事件名(event)从vm_events中取出事件列表,如果列表不存在,则使用空数组初始化,然后再将回调函数添加到事件列表中

这样事件就注册好了,vm._events哪来的?事实上,在执行new Vue()时,Vue会执行this.init 方法进行一系列初始化操作,其中Vue.js的实例上创建了一个_events属性,用来存储事件

vm.events = Object.create(null)

13.2.2 vm.$off

vm.$off([event,callback])

// 移除所有事件监听器
if(!arguments.length){
    vm._events = Object.create(null)
    return vm
}

this.offvm.off 和vm.off 是同一个方法,vm是this的别名

13.2.3 vm.$once

vm.$once(event,callback) 用法:监听一个自定义事件,但是只触发一次,在第一次触发之后移除监听器

13.2.4 vm.$emit

vm.$emit(event,[...args]) 触发当前实例上的事件。附加参数都会传递给监听器回调

所有事件监听器回调函数都会存储在vm.events中,所以触发事件的实现思路是使用事件名从vm.events中取出对应的事件监听器回调函数列表。然后依次执行列表中的监听器回调并将参数传递给监听器回调。

13.3 生命周期相关的实例方法

与生命周期相关的实例方法有4个,分别是vm.mount,vm.mount,vm.forceUpdate,vm.nextTickvm.nextTick和vm.destroy

vm.forceUpdatevm.forceUpdate和vm.destroy是lifecyleMixin挂载的

vm.$nextTick 方法是从renderMixin挂载的

vm.$mount 是跨平台代码中挂载到Vue构造函数的prototype属性上的

vm.$forceUpdate

作用就是迫使Vue.js实例重新渲染

Vue.prototype.$forceUpdate = function(){
    const vm = this
    if(vm.watcher){
       vm._watcher.update()
    }
}

vm.watcher 就是Vue.js实例的watcher,每当组件内依赖的数据发生变化时,都会自动触发Vue.js实例中_watcher 的update方法

vm.$forceUpdate是手动通知Vue.js实例重新渲染

vm.$destroy

Vue.js 实例的$children属性存储了所有子组件

vm.$nextTick

nextTick 接收一个回调函数作为参数,它的作用是将回调延迟到下次DOM更新周期之后执行。

它与全局方法Vue.nextTick一样,不同的是回调的this自动绑定到调用它的实例上。如果没有提供回调且在支持promise的环境中,则返回一个Promise

new Vue({
  methods:{
    example:function(){
      this.message = 'changed'
      // DOM还没更新
      this.$nextTick(function(){
      //DOM 闲杂更新了
      //this 绑定到当前实例
        this.doSomethingElse()
      })
    }
   }
 })

有个问题: 下次DOM更新周期之后执行,是什么时候呢? 当状态发生变化时,watcher触发渲染的操作不是同步的,而是异步的。

1. 为什么Vue.js使用异步更新队列

react的setData()也是这个原理吧

Vue.js中有个队列,每当需要渲染时,会将watcher推送到这个队列中,在下一次事件循环中再让watcher触发渲染流程

如果在同一轮事件循环中有两个数据发生了变化,那么组件的watcher会收到两份通知,从而进行两次渲染。事实上,并不需要渲染两次,虚拟DOM会对整个组件进行渲染,所以只需要等所有状态修改完毕后,一次性将整个组件的DOM渲染到最新即可

要解决这个问题,收到通知watcher实例添加到队列中缓存起来,并判断队列是否已经存在相同的watcher。然后在下一次事件循环中,会让队列watcher触发渲染。这样保证了即便在同一事件循环中有两个状态发生改变,watcher最后也执行一次渲染流程

2.什么是事件循环

我们都知道JavaScript是一门单线程且非阻塞的脚本语言。

微任务:

  • Promise.then
  • MutationObserver
  • Object.observe
  • process.nextTick 宏任务
  • setTimeout
  • setInterval
  • setImmediate
  • MessageChannel
  • requesAnimationFrame
  • I/O
  • UI 交互事件

3. 什么是执行栈

当我们执行了一个方法时,JavaScript会生成一个与这个方法对应的执行环境(context),又叫执行上下文。这个执行环境中有这个方法的私有作用域,上层作用域的指向,方法的参数,私有作用域中定义的变量以及this对象。这个执行环境会被添加到一个栈中,这个栈就是执行栈

new Vue({
  methods: {
    example:function(){
      // 先使用nextTick 注册回调
      this.$nextTick(function(){
        //DOM 没有更新
      })
      // 然后修改数据
      this.message = 'changed'
    }
  },
})

new Vue({
  methods: {
    example:function(){
      // 先使用setTimeout向宏任务中注册回调
    setTimeout(_ => {
      // DOM 现在更新了
    },0)
      // 然后修改数据向微任务中注册回调 ---- watcher的更新是微任务,异步更新的
      this.message = 'changed'
    }
  },
})

微任务优先级太高,也可能出现问题。也可强制使用宏任务的方法

vm.$mount

vm.$mount([elementOrSelector])

如果Vue.js实例在实例化时没有收到el选项,则它处于“未挂载”状态,没有关联的DOM元素。我们可以使用vm.$mount手动挂载一个未挂载的实例。

13.4 全局API的实现原理

现在我们已经了解了Vue.js实例方法的内部原理,接下来将介绍全局API的内部原理

13.4.1 Vue.extend

Vue.extend(options) 用法: 使用基础Vue构造器创建一个"子类",其参数是一个包含”组件选项“的对象。data选项是特例,在Vue.extend()中,它必须是函数:

var Profile = Vue.extend({
  template:'<p>{{firstName}}{{lastName}} aka {{alias}}',
  data:function(){
    return {
      firstName:'Walter',
      lastName:'Whilte',
      alias:'Heisenberg'
    }
  }
})
// 创建Profile实例,并挂载到一个元素上
new Profile().$mount('#mount-point')

全局API和实例方法不同,后者是在Vue的原型上挂载方法,也就是Vue.prototype上挂载方法,而前者是直接在Vue上挂载方法。

Vue.extend = function(extendOptions){
// 做点什么
}

总体来说,其实就是创建了一个Sub函数并继承了父级。如果直接使用Vue.extend,则Sub继承于Vue构造函数

13.4.2 Vue.nextTick

Vue.nextTick([callback,context])

vm.msg = 'Hello'
// DOM还没有更新
Vue.nextTick(function(){
 // DOM 更新了
})

// 作为一个Promise 使用
Vue.nextTick().then(function(){ // DOM 更新了})

13.4.3 Vue.set

Vue.set(target,key,value) Vue.set和vm.$set 的实现原理相同

是Vue和组件的区别吗 为啥要设置两个呢?

13.4.4 Vue.delete

Vue.delete(target,key) 上同

13.4.5 Vue.directive

注册或获取全局指令 Vue.directive(id,[definition])

Vue.directive('my-directive',{
  bind:function(){},
  inserted:function(){},
  update:function(){},
  componentUpdated:function(){},
  unbind:function(){}
})
// 注册(指令函数)
Vue.directive('my-directive',function(){
  // 这里将会被bind和update调用
})
// getter 方法,返回已注册的指令
var myDirective = Vue.directive('my-directive')

虽然代码复用和抽象的主要形式是组件,但是有些情况下,仍需对普通DOM元素进行底层操作,这时就会用到自定义指令

13.4.6 Vue.filter

注册或获取全局过滤器 Vue.filter(id,[definition])

Vue.filter('my-filter',function(value){
 // 返回处理后的值
})

// getter 方法,返回已注册的过滤器
var myFilter = Vue.filter('my-filter')

过滤器可以用在两个地方:双花括号插值和v-bind表达式

13.4.7 Vue.component

Vue.component(id,[definition]) 注册或获取全局组件。注册组件时,还会自动使用给定的id设置组件的名称。

// 注册组件,传入一个扩展过的构造器
Vue.component('my-component',Vue.extend({ }))
// 注册组件,传入一个选项对象(自动调用Vue.extend)
Vue.component('my-component',{})
// 获取注册的组件(始终返回构造器)
var MyComponent = Vue.component('my-component')

13.4.8 Vue.use

Vue.use(pulgin) 安装Vue.js插件。如果插件是一个对象,必须提供install方法。如果插件是一个函数,它会被作为install方法。调用install方法时,会将Vue作用参数出啊如。install方法被同一插件多次调用时,插件也只会被安装一次

13.4.9 Vue.mixin

Vue.mixin(mixin) 全局注册一个混入(mixin),影响注册之后的每个Vue.js实例.插件作者可以使用混入向组件注入自定义行为(例如:监听生命周期钩子)。

// 自定义的选项myOption注入一个处理器
Vue.mixin({
  created:function(){
    var myOption = this.$options.myOption
    if(myOption){
      console.log(myOption)
    }
  }
})

new Vue({
  myOption:'hello!'
})
// =>'hello!'

Vue.mixin方法注册后,会影响之后创建的没个Vue.js实例,因为该方法会更改Vue.options属性

其实原理并不复杂,只是将用户传入的对象与Vue.js自身的options属性合并在一起,


import { mergeOptions} from '../util/index'
export function ininMixin(Vue){
  Vue.mixin = function(mixin){
    this.options = mergeOptions(this.options,mixin)
    return this
  })
}

因为mixin方法修改了Vue.options属性,而之后创建的没个实例都会用到该属性,所以会影响创建的没个实例。但也正是因为有影响,所以mixin在某些场景下才堪称神器

13.4.10 Vue.compile Vue.compile(template) 编译模板字符串返回包含渲染函数的对象。只在完整版中才有效

var res = Vue.compile('<div><span>{{msg}}</span></div>')

new Vue({
  data:{
    msg:'hello'
  },
  render:res.render
})

总结

本章中,我们详细介绍了Vue.js的实例方法和全局API的实现原理。它们的区别在于:实例方法是Vue.prototype上的方法,而全局API是Vue.js上的方法。

实例方法又分为数据,事件和生命周期这三个类型。

在介绍实例方法以及全局API的实现原理的同时,我们还介绍了扩展知识,例如在介绍vm.$nextTick时我们介绍了JavaScript事件循环机制,以及微任务和宏任务之间的区别等

第十四章 生命周期

每个Vue.js实例在创建时都要经过一系列初始化,例如设置数据监听,编译模板,将实例挂载到DOM并在数据变化时更新DOM等。同时,也会运行一些叫作生命周期钩子的函数,这给了我们在不同阶段添加自定义代码的机会

14.1 生命周期图示

  • 初始化阶段
  • 模板编译阶段
  • 挂载阶段
  • 卸载阶段

14.1.1 初始化阶段

这个阶段的主要目的是在Vue.js实例上初始化一些属性,事件以及响应式数据,如props,methods,data,computed,watch,provide和inject

14.2 从源码角度了解生命周期

new Vue()被调用时发生了什么

想要了解new Vue()被调用时发生了什么,我们需要知道在Vue构造函数中实现了哪些逻辑。

this._init(options)

_init方法的内部原理

如果用户在实例化Vue.js 时传递了el选项,则自动开启模板编译阶段与挂载阶段

如果没有传递el选项,则不进入下一个生命周期流程

用户需要执行vm.$mount 方法,手动开启模板编译阶段与挂载阶段

if(vm.$options.el){
  vm.$mount(vm.$options.el)
}

new Vue() this._init()
初始化 vm.options=>initLifecycle(vm)=>initEvents(vm)=>initRender(vm)beforeCreateinitInjections(vm)=>initState(vm)=>initProvide(vm)createdel选项吗?vm.options=>initLifecycle(vm)=>initEvents(vm)=>initRender(vm)------beforeCreate---- initInjections(vm)=>initState(vm)=>initProvide(vm)----created----有el选项吗?vm.mount(vm.$onptions.el)

callHook 函数的内部原理

可以在Vue.js的构造函数中通过options参数得到用户设置的生命周期钩子

14.4初始化实例属性

以$开头的属性是提供给用户的外部属性,以_开头的属性是提供给内部使用的内部属性

$children=>$parent=>$root

14.5初始化事件

初始化事件是指将父组件在模板中使用v-on注册的事件添加到子组件的事件系统(Vus.js事件系统中)。我们都知道,在Vue.js中,父组件可以在使用子组件的地方v-on来监听子组件触发的事件。

<div id="counter-event-example">
  <p>{{total}}</p>
  <button-counter v-on:increment = "incrementTotal"></button-counter>
  <button-counter v-on:increment = "incrementTotal"></button-counter>
</div>
Vue.component('button-counter',{
  remplate:'<button v-on:click = "incrementCounter">{{counter}}</button>',
  data:function(){
    return {
      counter:0
    }
  },
  methods: {
    incrementCounter:function(){
      this.counter += 1
      this.$emit('increment')
    }
  },
})
new Vue({
  el:'#counter-event-example',
  data:{
    total:0
  },
  methods:{
    incrememtTotal:function(){
      this.total += 1
    }
  }
})

简单来说如果v-on写在组件标签上,那么这个事件会注册到子组件Vue.js事件系统中;如果是写在平台标签上,例如div,那么事件会被注册到浏览器事件中

14.6 初始化inject

inject和provide 通常是成对出现的 说明 provide和inject 主要为高阶插件/组件库提供用例,并不推荐直接用于程序代码中 说明 可用的注入内容指的是祖先组件通过provide注入了内容,子孙组件可以通过inject获取祖先组件注入的内容

var Provider = {
  provide:{
    foo:'bar'
  }
}

var Child = {
  inject:['foo'],
  created(){
    console.log(this.foo) // => "bar"
  }
}

14.7 初始化状态

当我们只用一些状态,例如props,methods,data,computed和watch。在Vue.js 内部,这些状态在使用之前需要进行初始化。

通过本节的学习,我们将理解什么是props,为什么methods中的方法可以通过this访问,data在Vue.js内部是什么样的,computed是如何工作的,以及watch的原理等。

export function initState(vm){
  vm.watchers = []
  const opts = vm.$options
  if(opts.props)initProps(vm,opts.propsprops)
  if(opts.methods)initMethods(vm,opts.methods)
  if(opts.data){
    initData(vm)
  }else{
    observe(vm._data = {},true /* asRootData*/)
  }
  if(opts.computed)initComputed(vm,opts.computed)
  if(opts.watch && opts.watch !== nativeWatch){
    initWatch(vm,opts.watch)
  }
}

14.7.1 初始化props

如果某个节点是组件节点,这会将模板解析时从标签属性上解析出的数据当作参数传递给子组件,其中就是包含props数据

<child user-name = "berwin"></child>

//正确
{
props:['username']
}

// 错误
{
props:['user-name]
}

14.7.2 初始化methods

初始化methods时,只需要循环选项中的methods对象,并将每个属性一次挂载到vm上即可。

vm[key] = methods[key] == null ? noop : bind(methods=[key],vm)

如果存在,则该方法通过bind改写它的this后,再赋值到vmp[key]中

这样,我们就可以通过vm.x访问到methods中的x方法了

14.7.3 初始化data

data 内部究竟是怎样的呢? data中的数据最终会保存到vm._data中。然后在vm上设置一个代理。在通过Observe函数将data转换成响应式数据。于是,data就完成了初始化。

最后仔细想一想,那为什么不是this.data.属性去访问呢? 为什么直接就是this.属性就可以访问了呢?

通过这样的方式将vm._data 中的方法代理到vm上。 proxy(代理)

不对呀,我还是不明白内部是如何加载的,为啥直接this.属性就可以访问了。直接解构了吗?

14.7.4 初始化computed

它和watch到底有哪些不同呢?本节我们将详细其内部原理

简单来说,computed是定义在vm上的一个特殊的getter方法。之所以说特殊,是因为在vm上定义getter时,get并不是用户提供的函数,而是Vue.js内部的一个代理函数,在代理函数中可以结合watcher实现缓存与收集依赖等功能

  • 我们知道计算属性的结果会被缓存,且只有在计算属性所依赖的响应式属性或者计算属性的返回值发生变化时才会重新计算。 如何判断计算属性的返回值发生了改变呢?

是结合Watcher的dirty属性来分辨的,为true时,说明需要重新计算。 简单来说,计算属性会通过自身的watcher来观察它所用到的所有属性的变化,当这些属性发生变化时,计算属性会将自身的Watcher的dirty属性设置为true.

读取数据这个操作其实会触发计算属性的getter方法。计算属性的一个特点就是有缓存。计算属性函数所依赖的数据在没有发生变化的情况下,会反复读取计算属性,而计算属性函数并不会反复执行

var vm = new Vue({
  data: {
    a: 1
  },
  computed: {
    // 仅读取
    aDouble: function () {
      return this.a * 2
    },
    // 读取和设置
    aPlus: {
      get: function () {
        return this.a + 1
      },
      set: function (v) {
        this.a = v - 1
      },
      // 函数简写
      set(val) {
        this.a = v - 1
      }
    }
  }
})

所以在定义计算属性时,需要判断userDef的类型是函数还是对象。如果是函数,则将函数理解为getter函数。如果是对象,则将对象的get方法作为getter方法,set方法作为setter方法

14.7.5 初始化watch

一个对象,其中键是需要观察的表达式,值是对应的回调函数,也可以是方法名或者包含选项的对象。Vue.js实例将会在实例化时调用vm.$watch() 遍历watch对象的每一个属性

var vm = new Vue({
  data:{
    a:1,
    b:2,
    c:3,
    d:4,
    e:{
      f:{
        g:5
      }
    }
  },
  watch:{
    a:function(newVal,oldVal){
      console.log('new: %5,olg:%s',newVal,oldVal)
    },
    // 方法名
    b:'someMethod',
    // 深度watcher
    c:{
      handler:function(val,oldVal){},
      deep:true
    },
    // 该回调将会在侦听开始之后被立即调用
    d:{
      handler:function(val,oldVal){},
      immediate:true
    },
    e:[
      function handle1(val,oldVal){},
      function handle2(val,oldVal){}
    ],
    // watch vm.e.f`s value:{g:5}
    'e.f':function(val,oldVal){}
  }
})
vm.a = 2 //=> new:2,old:1

第十五章 指令的奥秘

使用自定义指令时,可以监听5种钩子函数:bind,inserted,update,componentUpdated与unbind。 指令的钩子函数被触发后,就说明指令生效了。

v-if v-for 在内部会判断是否渲染对应的vnode,对应的节点。

v-on

从模板解析到生成VNode,最终事件会被保存在VNode中,然后通过vnode.data.on得到一个节点注册的所有事件。

<button v-on:click="doThat">我是按钮</button>

通过vndoe.data.on读出下面的事件对象
{
click:function(){}
}

虚拟DOM在修补过程中会触发的全部钩子函数以及每个钩子函数的出发时机

第十六章 过滤器的奥秘

<!-- 在花括号中 -->
{{ message | capitalize }}
<!-- 在v-bind中 -->
<div v-bind:id = "rawId | formatId"></div>

<!-- 我们可以在一个组件的选项中定义本地的过滤器 -->
filters:{
  capitalize:function(value){
    if(!value)return ''
    value = value.toString()
    return value.charAt(0).toUPperCase()+value.slice(1)
  }
}

<!-- 全局过滤器 -->
Vue.filter('capitalize',function(value){
  if(!value)return ''
  value = value.toString()
  return value.charAt(0).toUPperCase()+value.slice(1)
})

new Vue({})

过滤器函数总是将表达式的值(之前的操作链的结果)作为第一参数。上述例子中,capitalize过滤器函数将收到message的值作为第一个参数

此外过滤器可以串联:{{ message | filterA | filterB }}

过滤器是JavaScript函数,因此可以接收参数: {{ message | filterA('arg1',arg2)}}

这里,filterA被定义为接收三个参数的过滤器函数。其中message的值作为第一个参数,普通字符串'arg1'作为第二个参数,表达式arg2的值作为第三个参数