vue2原理解析

247 阅读6分钟

一、常用API

1. event

经典的发布-订阅模式

1. vm.$on(event, fn)

订阅者订阅事件,按事件的名event把回调函数存储到事件中心vm._events;

2. vm.$emit(event)

发布者发布事件,根据事件名event找到所有的回调函数vm._events[event],然后遍历执行所有的回调函数;

3. vm.$off(event, fn)

移除指定事件名event和指定的fn;

3. vm.$once(event, fn)

内部就是执行vm.on,并且当回调函数执行一次后在通过vm.on,并且当回调函数执行一次后在通过vm.off移除事件的回调,这样就确保了回调函数只执行一次;

2. v-model

1. 表单元素
<input
  v-bind:value="message"
  v-on:input="message=$event.target.value">

动态绑定了input的value指向message变量,并且在触发input事件的时候去动态把message设置为目标值

2. 组件
let Child = {
  template: '<div>'
  + '<input :value="msg" @input="updateValue" placeholder="edit me">' +
  '</div>',
  props: ['msg'],
  model: {
    prop: 'msg',
    event: 'change'
  },
  methods: {
    updateValue(e) {
      this.$emit('change', e.target.value)
    }
  }
}

let vm = new Vue({
  el: '#app',
  template: '<div>' +
  '<child v-model="message"></child>' +
  '<p>Message is: {{ message }}</p>' +
  '</div>',
  data() {
    return {
      message: ''
    }
  },
  components: {
    Child
  }
})

子组件传递的value绑定到父组件的message,同时监听自定义input事件,当子组件派发input事件的时候,父组件会在事件回调函数中修改message值,同时value也会发生变化,子组件的input值被更新。

3. slot

1. 普通插槽

普通插槽是父组件编译和渲染阶段生成vnodes,所以数据的作用域是父组件实例,子组件渲染的时候直接拿到这些渲染好的vnodes。

2. 作用域插槽
let Child = {
  template: '<div class="child">' +
  '<slot text="Hello " :msg="msg"></slot>' +
  '</div>',
  data() {
    return {
      msg: 'Vue'
    }
  }
}

let vm = new Vue({
  el: '#app',
  template: '<div>' +
  '<child>' +
  '<template slot-scope="props">' +
  '<p>Hello from parent</p>' +
  '<p>{{ props.text + props.msg}}</p>' +
  '</template>' +
  '</child>' +
  '</div>',
  components: {
    Child
  }
})

作用域插槽,父组件在编译和渲染阶段并不会直接生成vnodes,而是在父节点vnode的data中保留一scopedSlots对象,存储着不同名称的插槽以及它们对应的渲染函数,只有在编译和渲染子组件阶段,才会指向这个渲染函数生成vnodes,由于是在子组件环境执行的,所以对应的数据作用域是子组件实例。

4. keep-alive

  • 包裹的组件首次渲染,除了在keep-alive中建立缓存,和普通组件渲染没什么区别;
  • 包裹的组件在有缓存的时候,就不会再执行组件的created、mounted等钩子函数,提供了activated、deacitvated钩子函数。

5. transition

vue的过渡实现分为以下几个步骤:

  • 自动嗅探目标元素是否应用了css过渡动画,如果是,在恰当的时机添加或删除css类名
  • 如果过渡组件提供了JavaScript钩子函数,这些钩子函数将在恰当的时机被调用
  • 如果没有找到JavaScript钩子函数并且没有检测到css过渡动画,DOM操作(插入/删除)在下一帧中立即执行

总结: 所以真正执行动画的是我们写的 CSS 或者是 JavaScript 钩子函数,而 Vue 的 <transition> 只是帮我们很好地管理了这些 CSS 的添加/删除,以及钩子函数的执行时机。

二、数据驱动

new Vue --> init --> $mount --> compile --> render --> vnode --> patch --> DOM

1. new Vue发生了什么

Vue初始化:

合并配置,初始化生命周期,初始化事件中心,初始化渲染,初始化data props computed watcher。

2. Vue实例挂载实现

原型方法$mount调用mountComponent实现。mountComponent核心是先实例化一个渲染Watcher, 在它的回调函数中调用updateComponent方法,在此方法中调用vm._render方法先生成虚拟Node, 最终调用vm._update更新DOM。

vm._render最终是通过执行createElement方法并返回vnode。

vm._update核心就是调用vm.__path__方法。

三、组件化

1. 渲染组件createComponent

三步关键逻辑:构造子类构造函数,安装组件钩子函数和实例化vnode。

1. 构造子类构造函数

Vue.extend的作用就是构造一个Vue的子类,使用了一个经典的原型继承的方式把一个纯对象转换一个继承于Vue的构造器Sub并返回,然后对Sub这个对象本身扩展了一些属性,;并且对配置中的props和computed做了初始化工作;最后对于这个Sub构造函数做了缓存,避免多次执行Vue.extend的时候对同一个子组件重复构造。

2. 安装组件钩子函数

installComponentHooks整个过程就是把componentVNodeHooks的钩子函数合并到data.hook中

3. 实例化Vnode

通过new VNode实例化一个vnode并返回

三、生命周期

1. new Vue

合并配置,初始化事件中心,初始化生命周期。

2. beforeCreate & created

钩子函数的调用在initState的前后,initState的作用是初始化props data methods computed等属性。beforeCreate钩子函数初始化vue-router, vuex等插件的配置。

3. beforeMount & mounted

在执行vm._render()函数渲染VNode之前,执行了beforeMount钩子函数,在执行玩vm._update()把VNode patch到真实DOM后,执行mounted钩子函数。对于同步渲染的子组件,mounted钩子函数的执行顺序是先子后父。

4. beforeUpdate & updated

beforeUpdate的执行时机是在渲染Watcher的before函数中。updated的执行在Wathcer的update函数中。

5. beforeDestroy & destroyed

beforeDestroy钩子函数的执行时机是在destroy函数执行最开始的地方,接着执行了一系列的销毁动作,包括从parentdestroy函数执行最开始的地方,接着执行了一系列的销毁动作,包括从parent的children中删掉自身,删除watcher,当前渲染的VNode执行销毁钩子函数等,执行完毕后再调用destroy钩子函数。destroy钩子函数的执行顺序是先子后父。

6. activated & deactivated

actived钩子函数的执行时机是在activateChildComponent方法执行时;deactivated钩子函数的执行时机是在deactivateChildComponent方法执行时。

7. 父子组件生命周期执行顺序

父beforeCreate -> 父created -> 父beforeMount -> 子beforeCreate -> 子created -> 子beforeMount -> 子mounted -> 父mounted
->父beforeUpdate->子beforeUpdate->子updated->父updated
->父beforeDestroy->子beforeDestroy->子destroyed->父destroyed

三、Vue-Router和Vuex

1. Vue-Router

执行install方法,安装插件

  • 利用Vue.mixin去把beforeCreate和destroyed钩子函数注入到每一个组件中
  • 执行this._router.init()方法初始化router, 用defineReactive方法把this._router变成响应式对象
  • Vue原型上定义routerrouter route属性的get方法
  • Vue.component定义全局组件和
  • 定义路由中的钩子函数的合并策略

2. Vuex

安装

执行install方法,安装插件。利用Vue.mixin全局混入一个beforeCreate钩子函数,往所有组件实例上添加一个$store的实例。

store实例化

把store作为一个数据仓库,为了方便管理仓库,把一个大的store拆成一些modules, 整个modules是一个树型结构。每个module又分别定义了state getters mutations actions, 通过递归遍历模块的方式完成了它们的初始化。为了module具有更高的封装度和复用性,还定义了namespace。定义了一个内部的Vue实例,用来建立state到getters的联系,并且可以在严格模式下检测state的变化是不是来自外部,确保改变state的唯一途径是显示地提交mutation。

四、响应式原理

(1) 三个重要对象:Observer,Watcher,Dep:
  • Observer对象:vue中的数据对象在初始化过程中转换为Observer对象。
  • Watcher对象:将模板和Observer对象结合在一起生成Watcher实例,Watcher是订阅中的订阅者。
  • Dep对象:Watcher对象和Observer对象之间纽带,每一个Observer都有一个Dep实例,用来存储订阅者Watcher。
(2) 响应式实现:
  • 1、在initState方法中将data,prop,method,computed,watch中的数据劫持, 通过observe方法与Object.defineProperty方法将相关对象转为换Observer对象。
  • 2、然后在initRender方法中解析模板,通过Watcher对象、Dep对象实现的观察者模式将模板中的 指令与数据对象建立依赖关系,使用全局对象Dep.depend实现依赖收集。
  • 3、当数据变化时,setter被调用,触发dep.notify方法, 遍历该数据依赖列表subs,然后调用每一个 watcher 的 update 方法进行视图更新。

五、模板编译

(1) 解析模板 parse

把 template 模板字符串转换成 AST 树

  • 从 options 中获取方法和配置

  • 利用正则表达式顺序解析模板

(2) 优化AST树 optimize

  • 深度遍历这个AST树,标记静态节点和标记静态根
  • 优化模板的更新

(3) 生成代码 codegen

  • 把优化后的 AST 树转换成可执行的代码

六、nextTick

(1) 事件循环

主线程的执行过程就是一个 tick,而所有的异步结果都是通过 “任务队列” 来调度。

  • 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

  • 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。

  • 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。

  • 主线程不断重复上面的第三步。

(2) Vue nextTick

  • 对于 macro task 的实现,优先检测是否支持原生 setImmediate,这是一个高版本 IE 和 Edge 才支持的特性,不支持的话再去检测是否支持原生的 MessageChannel,如果也不支持的话就会降级为 setTimeout 0
  • 而对于 micro task 的实现,则检测浏览器是否原生支持 Promise,不支持的话直接指向 macro task 的实现。
  • nextTick把传入的回调函数 cb 压入 callbacks 数组,最后一次性地根据 useMacroTask 条件执行 macroTimerFunc 或者是 microTimerFunc,而它们都会在下一个 tick 执行 flushCallbacksflushCallbacks 的逻辑非常简单,对 callbacks 遍历,然后执行相应的回调函数。

七、keep-alive是啥?能干嘛?

基本用法

三个「管理规则」属性:

  • include:只缓存名字匹配的组件(支持字符串/正则/数组)
  • exclude:不缓存名字匹配的组件(规则同上)
  • max:最多缓存多少个组件实例(超了就删最早的)
    <!-- 只缓存a、b组件 --> 
    <keep-alive include="a,b"> 
        <component :is="view"></component> 
    </keep-alive> 
    
    <!-- 正则匹配 --> 
    <keep-alive :include="/a|b/"> 
        <component :is="view"></component> 
    </keep-alive>

使用原则就一条:「需要「保留状态」或「避免重复加载」的页面」。

    {  
        path:'/list',  
        name:'itemList',  
        component:() => import('@/pages/item/list'), 
        meta:{  
            keepAlive:true, // 需要缓存  
            title:'商品列表' 
        } 
    }
    <div id="app"class="wrapper"> 
        <!-- 需要缓存的组件 --> 
        <keep-alive> 
            <router-view v-if="$route.meta.keepAlive"></router-view> 
        </keep-alive> 
        <!-- 不需要缓存的组件 --> 
        <router-view v-if="!$route.meta.keepAlive"></router-view>
    </div>

原理揭秘

keep-alive本质是个「无模板」的Vue内置组件,核心靠render函数和两个「缓存池」工 作:

  • cache对象:存组件实例(键是组件唯一key,值是组件vnode)
  • keys数组:记录缓存顺序(控制最大缓存数时用)

当组件被包裹时,它会:

  • 1.检查组件名是否符合include/exclude规则,不符合就不缓存
  • 2.生成组件唯一key(优先用vnode.key,否则用cid+tag)
  • 3.查cache有没有这个key:
    • 有→直接取缓存实例,把key移到keys末尾(表示「最近使用」)
    • 没有→把组件存进cache,key推进keys
    • 如果超了max→删掉keys里第一个(最久未使用的)

当include/exclude变化时,它还会「清理缓存」——遍历cache,把不符合新规则的组件销 毁。

缓存后数据怎么更新?

组件被缓存后,created、mounted这些钩子只会走一次,后续进入页面数据可能不会更新。咋办?

方案1:用beforeRouteEnter 每次路由进入时触发,适合需要「路由级更新」的场景:

    beforeRouteEnter(to,from,next){ 
        next(vm =>{  
            vm.getData()// 每次进页面都调接口 
        }) 
    }

方案2:用activated钩子 缓存组件被激活时触发(比beforeRouteEnter更「组件级」):

    activated(){ 
        this.getData()// 激活时更新数据 
    }

注意:服务端渲染时activated不会被调用哦~