vue的原理

702 阅读5分钟

mvvm

WechatIMG512.png new Vue一个实例对象a,其中有一个属性a.b,那么在实例化的过程中,通过Object.defineProperty()会对a.b添加getter和setter,同时Vue.js会对模板做编译,解析生成一个指令对象(这里是v-text指令),每个指令对象都会关联一个Watcher,当对a.b求值的时候,就会触发它的getter,当修改a.b的值的时候,就会触发它的setter,同时会通知被关联的Watcher,然后Watcher就会再次对a.b求值,计算对比新旧值,当值改变了,Watcher就会通知到指令,调用指令的update()方法,由于指令是对DOM的封装,所以就会调用DOM的原生方法去更新视图,这样就完成了数据。

大致上是使用数据劫持订阅发布实现双向绑定。

具体步骤如下:

● 采用Object.defineProperty的observe 函数实现递归监听,达到深层监听数据变化的目的

● 然后,需要compile解析模板指令,将模板中的变量替换成数据,接着初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者。一旦数据有变动,订阅者收到通知,就会更新视图

● 接着,Watcher订阅者是Observer和Compile之间通信的桥梁,主要负责:

     1)在自身实例化时,往属性订阅器(Dep)里面添加自己

     2)自身必须有一个update()方法

     3)待属性变动,dep.notice()通知时,就调用自身的update()方法,并触发Compile中绑定的回调

● 最后,viewmodel(vue实例对象)作为数据绑定的入口,整合Observer、Compile、Watcher三者,通过Observer来监听自己的model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起Observer和Compile之间的通信桥梁,达到数据变化 (ViewModel)-》视图更新(view);视图变化(view)-》数据(ViewModel)变更的双向绑定效果。

数据劫持

采用Object.defineProperty的observe 函数实现递归监听,达到深层拦截的目的 因为 Array.prototype 上挂载的方法并不能触发 data.course.author 属性值的 setter,由于这并不属于做赋值操作,而是 push API 调用操作。然而对于框架实现来说,这显然是不满足要求的,当数组变化时我们应该也有所感知。 Vue 同样存在这样的问题,它的解决方法是:将数组的常用方法进行重写,进而覆盖掉原生的数组方法,重写之后的数组方法需要能够被拦截。

实现逻辑如下:

const arrExtend = Object.create(Array.prototype)
const arrMethods = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]

arrMethods.forEach(method => {
const oldMethod = Array.prototype[method]
const newMethod = function(...args) {
oldMethod.apply(this, args)
console.log(`${method} 方法被执行了`)
}
arrExtend[method] = newMethod
})

双向绑定实现

<imput v-model="name">

源码实现:

function replace(el, data) {
   // 省略...
   if (node.nodeType === 1) {

     let attributesArray = node.attributes

     Array.from(attributesArray).forEach(attr => {
       let attributeName = attr.name
       let attributeValue = attr.value

       if (name.includes('v-')) {
         node.value = data[attributeValue]
       }

       node.addEventListener('input', e => {
         let newVal = e.target.value
         data[attributeValue] = newVal
         // ...
         // 更改数据源,触发 setter
         // ...
       })
     })
   }

   if (node.childNodes && node.childNodes.length) {
     replace(node)
   }

模版编译原理介绍

其中模版变量使用了 {{}} 的表达方式输出模版变量。最终输出的 HTML 内容应该被合适的数据进行填充替换

compile(document.querySelector('#app'), data)

 function compile(el, data) {
   let fragment = document.createDocumentFragment()

   while (child = el.firstChild) {
     fragment.appendChild(child)
   }

   // 对 el 里面的内容进行替换
   function replace(fragment) {
     Array.from(fragment.childNodes).forEach(node => {
       let textContent = node.textContent
       let reg = /\{\{(.*?)\}\}/g

       if (node.nodeType === 3 && reg.test(textContent)) {
          const nodeTextContent = node.textContent
         const replaceText = () => {
             node.textContent = nodeTextContent.replace(reg, (matched, placeholder) => {
                 return placeholder.split('.').reduce((prev, key) => {
                     return prev[key]
                 }, data)

             })
         }
         replaceText()
       }
       // 如果还有子节点,继续递归 replace
       if (node.childNodes && node.childNodes.length) {
         replace(node)
       }
     })
   }
   replace(fragment)
   el.appendChild(fragment)
   return el
 }

代码分析:我们使用 fragment 变量储存生成的真实 HTML 节点内容。通过 replace 方法对 {{变量}} 进行数据替换,同时 {{变量}} 的表达只会出现在 nodeType === 3 的文本类型节点中,因此对于符合 node.nodeType === 3 && reg.test(textContent) 条件的情况,进行数据获取和填充。我们借助字符串 replace 方法第二个参数进行一次性替换,此时对于形如 {{data.course.title}} 的深层数据,通过 reduce 方法,获得正确的值。

因为 DOM 结构可能是多层的,所以对存在子节点的节点,依然使用递归进行 replace 替换。

这个编译过程比较简单,没有考虑到边界情况,只是单纯完成模版变量到真实 DOM 的转换,读者只需体会简单道理即可。

发布订阅模式简单应用

订阅发布模式(又称观察者模式),定义了一种一对多的关系,让多个观察者同时监听某一个主题对象,这个主题对象的状态发生改变时就会通知所有观察者对象。

发布者发出通知=》主题对象收到通知,并推送给订阅者=》订阅者执行相应操作。

computed是如何更新的?

这条线主要解决了什么时候去设置Dep.target的问题(如果没有设置该值,就不会调用dep.depend() , 即无法获取依赖)。

// src/core/instance/state.js
const computedWatcherOptions = { lazy: true }
function initComputed (vm: Component, computed: Object) {
  // 初始化watchers列表
  const watchers = vm._computedWatchers = Object.create(null)
  const isSSR = isServerRendering()

  for (const key in computed) {
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    if (!isSSR) {
      // 关注点1,给所有属性生成自己的watcher, 可以在this._computedWatchers下看到
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }

    if (!(key in vm)) {
      // 关注点2
      defineComputed(vm, key, userDef)
    }
  }
}

在初始化computed时,有2个地方需要去关注

  1. 对每一个属性都生成了一个属于自己的Watcher实例,并将 { lazy: true } 作为options传入
  2. 对每一个属性调用了defineComputed方法(本质和data一样,代理了自己的set和get方法,我们重点关注代理的get方法) 我们看看Watcher的构造函数
// src/core/observer/watcher.js
constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: Object
  ) {
    this.vm = vm
    vm._watchers.push(this)
    if (options) {
      this.deep = !!options.deep
      this.user = !!options.user
      this.lazy = !!options.lazy
      this.sync = !!options.sync
    } else {
      this.deep = this.user = this.lazy = this.sync = false
    }
    this.cb = cb
    this.id = ++uid // uid for batching
    this.active = true
    this.dirty = this.lazy // 如果初始化lazy=true时(暗示是computed属性),那么dirty也是true,需要等待更新
    this.deps = []
    this.newDeps = []
    this.depIds = new Set()
    this.newDepIds = new Set()
    this.getter = expOrFn // 在computed实例化时,将具体的属性值放入this.getter中
    // 省略不相关的代码
    this.value = this.lazy
      ? undefined
      : this.get()
  }

除了日常的初始化外,还有2行重要的代码

this.dirty = this.lazy
this.getter = expOrFn

computed生成的watcher,会将watcher的lazy设置为true,以减少计算量。因此,实例化时,this.dirty也是true,标明数据需要更新操作。我们先记住现在computed中初始化对各个属性生成的watcher的dirty和lazy都设置为了true。同时,将computed传入的属性值 (一般为funtion) ,放入watchergetter中保存起来。

我们在来看看第二个关注点defineComputed所代理属性的get方法是什么

// src/core/instance/state.js
function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    // 如果找到了该属性的watcher
    if (watcher) {
      // 和上文对应,初始化时,该dirty为true,也就是说,当第一次访问computed中的属性的时候,会调用 watcher.evaluate()方法;
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

第一次访问computed中的值时,会因为初始化watcher.dirty = watcher.lazy的原因,从而调用evalute()方法,evalute()方法很简单,就是调用了watcher实例中的get方法以及设置dirty = false,我们将这两个方法放在一起

// src/core/instance/state.js
evaluate () {
  this.value = this.get()
  this.dirty = false
}
  
get () {  
// 重点1,将当前watcher放入Dep.target对象
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    // 重点2,当调用用户传入的方法时,会触发什么?
    value = this.getter.call(vm, vm)
  } catch (e) {
  } finally {
    popTarget()
    // 去除不相关代码
  }
  return value
}

在get方法中中,第一行就调用了pushTarget方法,其作用就是将Dep.target设置为所传入的watcher,即所访问的computed中属性的watcher,
然后调用了value = this.getter.call(vm, vm)方法,想一想,调用这个方法会发生什么?

this.getter 在Watcher构建函数中提到,本质就是用户传入的方法,也就是说,this.getter.call(vm, vm) 就会调用用户自己声明的方法,那么如果方法里面用到了 this.data中的值或者其他被用defineReactive包装过的对象,那么,访问this.data.或者其他被defineReactive包装过的属性,是不是就会访问被代理的该属性的get方法。我们在回头看看
get方法是什么样子的。

注意:我讲了其他被用defineReactive,这个和后面的vuex有关系,我们后面在提

get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      // 这个时候,有值了
      if (Dep.target) {
        // computed的watcher依赖了this.data的dep
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
        }
        if (Array.isArray(value)) {
          dependArray(value)
        }
      }
      return value
    }

代码注释已经写明了,就不在解释了,这个时候我们走完了一个依赖收集流程,知道了computed是如何知道依赖了谁。最后根据this.data所代理的set方法中调用的notify,就可以改变this.data的值,去更新所有依赖this.data值的computed属性value了。

那么,我们根据下面的代码,来简易拆解获取依赖并更新的过程

var vm = new Vue({
  el: '#example',
  data: {
    message: 'Hello'
  },
  computed: {
    // 计算属性的 getter
    reversedMessage: function () {
      // `this` 指向 vm 实例
      return this.message.split('').reverse().join()
    }
  }
})
vm.reversedMessage // =>  olleH
vm.message = 'World' // 
vm.reversedMessage // =>  dlroW
  1. 初始化 data和computed,分别代理其set以及get方法, 对data中的所有属性生成唯一的dep实例。
  2. 对computed中的reversedMessage生成唯一watcher,并保存在vm._computedWatchers中
  3. 访问 reversedMessage,设置Dep.target指向reversedMessage的watcher,调用该属性具体方法reversedMessage
  4. 方法中访问this.message,即会调用this.message代理的get方法,将this.message的dep加入reversedMessage的watcher,同时该dep中的subs添加这个watcher
  5. 设置vm.message = 'World' ,调用message代理的set方法触发dep的notify方法'
  6. 因为是computed属性,只是将watcher中的dirty设置为true
  7. 最后一步vm.reversedMessage,访问其get方法时,得知reversedMessagewatcher.dirty为true,调用watcher.evaluate() 方法获取新的值。

这样,也可以解释了为什么有些时候当computed没有被访问(或者没有被模板依赖),当修改了this.data值后,通过vue-tools发现其computed中的值没有变化的原因,因为没有触发到其get方法

vuex插件原理

vuex是通过mixin判断vue实例是否有store,然后挂载store,通过单独创建一个vue实例实现响应式,mutations和actions用发布订阅的方法实现辅助函数也还好, 就是返回一个函数,然后函数里面调用this.$store.emit:

Vue.use(Vuex) new Vuex.Store({})

所以暴露出去两个方法,install(vue.use会调用install)和Store方法,且vuex是个类或者构造函数:

// src/store.js
export function install (_Vue) {
  if (Vue && _Vue === Vue) {
    return
  }
  Vue = _Vue
  applyMixin(Vue)
}

看vuex源码,调用了applyMixin方法,然后执行Vue.mixin({ beforeCreate: vuexInit });通过mixin方法获取实例。所以执行vuexInit的时候就能通过this获取实例是否有store属性(new Vue的时候把store传入),有就给vue实例添加$store:

// src/mixins.js
// 对应applyMixin方法
export default function (Vue) {
  const version = Number(Vue.version.split('.')[0])

  if (version >= 2) {
    Vue.mixin({ beforeCreate: vuexInit })
  } else {
    const _init = Vue.prototype._init
    Vue.prototype._init = function (options = {}) {
      options.init = options.init
        ? [vuexInit].concat(options.init)
        : vuexInit
      _init.call(this, options)
    }
  }

  /**
   * Vuex init hook, injected into each instances init hooks list.
   */

  function vuexInit () {
    const options = this.$options
    // store injection
    if (options.store) {
      this.$store = typeof options.store === 'function'
        ? options.store()
        : options.store
    } else if (options.parent && options.parent.$store) {
      this.$store = options.parent.$store
    }
  }
}

最核心的部分就是通过产生一个单独的vue实例实现vuex的响应式:

this._vm = new Vue({
    data:{
        $$state:state,
    }
});

源码是store.vm,代码里面搜索new Vue就能看见。官网也有文档: 以开头的property不会被Vue实例代理,因为它们可能和Vue内置的propertyAPI方法冲突。你可以使用例如vm. 开头的 property 不会被 Vue 实例代理,因为它们可能和 Vue 内置的 property、API 方法冲突。你可以使用例如 vm.data._property 的方式访问这些 property(官网 学习/api/data里面有介绍)。但其实我不明白为什么要两个$。 实现getter、mutations、actions就比较简单了:

this.mutations = {};
forEachValue(options.mutations, (fn, key) => {
    this.mutations[key] = (payload) => fn.call(this, this.state, payload)
});

差不多就是发布订阅,把所有的方法都放到一个对象里面,只是getter还加了computed做缓存。

然后我们调用commit的时候调用mutations:

commit = (type, payload) => {
    this.mutations[type](payload);
}

actions也差不多。

今天主要是知道vuex是通过mixin判断vue实例是否有store,然后挂载$store,通过单独创建一个vue实例实现响应式,mutations和actions用发布订阅的方法实现。至于模块就不说了,看了有些复杂。

辅助函数也还好,就是返回一个函数,然后函数里面调用this.$store.emit:

const mapMutations = (mutationList) => {
    let res = {};
    for (let i = 0; i < mutationList.length; i++) {
        let name = mutationList[i]
        res[name] = function(payload){
            this.$store.commit(name,payload);
        }
    }
    return res
}

为什么要区分actions 和 mutations

区分 actions 和 mutations 并不是为了解决竞态问题,而是为了能用 devtools 追踪状态变化。
事实上在 vuex 里面 actions 只是一个架构性的概念,并不是必须的,说到底只是一个函数,你在里面想干嘛都可以,只要最后触发 mutation 就行。异步竞态怎么处理那是用户自己的事情。vuex 真正限制你的只有 mutation 必须是同步的这一点(在 redux 里面就好像 reducer 必须同步返回下一个状态一样)。
同步的意义在于这样每一个 mutation 执行完成后都可以对应到一个新的状态(和 reducer 一样),这样 devtools 就可以打个 snapshot 存下来,然后就可以随便 time-travel 了。
如果你开着 devtool 调用一个异步的 action,你可以清楚地看到它所调用的 mutation 是何时被记录下来的,并且可以立刻查看它们对应的状态。其实我有个点子一直没时间做,那就是把记录下来的 mutations 做成类似 rx-marble 那样的时间线图,对于理解应用的异步状态变化很有帮助。

vuex数据持久化方案

vuex本质是一个保存在内存里的对象,一旦网页刷新,它就会被初始化,她的主要目的是为了提供了一个中央仓库,方便各组件间共享数据状态,避开了多组件的通信问题,实现数据的相应式,所以需要结合存储一起使用
方案一:

   window.addEventListener("beforeunload", () => {
      sessionStorage.setItem("store", JSON.stringify(this.$store.state));
    });
    if (sessionStorage.getItem("store")) {
      this.$store.replaceState(
        Object.assign(
          {},
          this.$store.state,
          JSON.parse(sessionStorage.getItem("store"))
        )
      );
    }

方案二:vuex-persist vuex-persistedstate

 return function (store: Store<State>) {
    if (!options.fetchBeforeUse) {
      savedState = fetchSavedState();
    }

    if (typeof savedState === "object" && savedState !== null) {
      store.replaceState(
        options.overwrite
          ? savedState
          : merge(store.state, savedState, {
              arrayMerge:
                options.arrayMerger ||
                function (store, saved) {
                  return saved;
                },
              clone: false,
            })
      );
      (options.rehydrated || function () {})(store);
    }

    (options.subscriber || subscriber)(store)(function (mutation, state) {
      if ((options.filter || filter)(mutation)) {
        (options.setState || setState)(
          key,
          (options.reducer || reducer)(state, options.paths),
          storage
        );
      }
    });
  };

方案三:

// Subscribe to store updates\
store.subscribe((mutation, state) => {\
// Store the state object as a JSON string\
localStorage.setItem('store', JSON.stringify(state));\
});

www.mikestreety.co.uk/blog/vue-js…

虚拟dom和diff算法

虚拟dom

目的:减少DOM操作的性能开销。 vdom是通过snabbdom.js库实现的,大概过程有以下三步:

  1. compile(把真实DOM编译成Vnode)
  2. diff(利用diff算法,比较oldVnode和newVnode之间有什么变化)
  3. patch(把这些变化用打补丁的方式更新到真实dom上去)

diff算法

diff算法并不是react或者vue原创的,它们只是用diff算法来比较两个vnode的差异,并只针对该部分进行原生DOM操作,而非重新渲染整个页面。

vdom中是在patch(vnode, newVnode) 比较新旧函数时会用到diff

这个函数做了以下事情:

  1. 找到对应的真实dom,称为el
  2. 判断Vnode和oldVnode是否完全相同,如果是,那么直接return
  3. 如果他们都有文本节点并且不相等,则更新el的文本节点
  4. 如果oldVnode有子节点而Vnode没有,则删除el的子节点
  5. 如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el
  6. 如果两者都有子节点,则执行updateChildren函数比较子节点

vue-router原理

vue渲染过程

  • new Vue,进行实例化
  • 挂载 $mount 方法,通过自定义 Render 方法、template、el 等生成 Render 函数,准备渲染内容
  • 通过 Watcher 进行依赖收集
  • 当数据发生变化时,Render 函数执行生成 VNode 对象
  • 通过 patch 方法,对比新旧 VNode 对象,通过 DOM Diff 算法,添加、修改、删除真正的 DOM 元素