面试官:聊聊对Vue.js框架的理解

前言

今年OKR定了一条KR是每个季度进行一次前端相关技术的分享,还有十几天就到2020年了,一直忙于业务开发,没有时间准备和学习高端话题,迫于无奈,那就讲讲平时使用频率较高,却没有真正认真的了解其内部原理的 Vue.js 吧。javascript

因为本文为一次前端技术分享的演讲稿,因此尽力不贴 Vue.js 的源码,由于贴代码在实际分享中,比较枯燥,效果不佳,而更多的是以图片和文字的形式进行表达。html

分享目标:前端

  • 了解 Vue.js 的组件化机制
  • 了解 Vue.js 的响应式系统原理
  • 了解 Vue.js 中的 Virtual DOM 及 Diff 原理

注:全部内容是基于 Vue2.0 进行准备的,分享PPT正在准备中,完成以后在更新分享。vue

原文地址java

Vue.js概述

Vue 是一套用于构建用户界面的渐进式MVVM框架。那怎么理解渐进式呢?渐进式含义:强制主张最少。node

渐进式概念

Vue.js包含了声明式渲染、组件化系统、客户端路由、大规模状态管理、构建工具、数据持久化、跨平台支持等,但在实际开发中,并无强制要求开发者以后某一特定功能,而是根据需求逐渐扩展。webpack

Vue.js的核心库只关心视图渲染,且因为渐进式的特性,Vue.js便于与第三方库或既有项目整合。git

组件机制

定义:组件就是对一个功能和样式进行独立的封装,让HTML元素获得扩展,从而使得代码获得复用,使得开发灵活,更加高效。github

与HTML元素同样,Vue.js的组件拥有外部传入的属性(prop)和事件,除此以外,组件还拥有本身的状态(data)和经过数据和状态计算出来的计算属性(computed),各个维度组合起来决定组件最终呈现的样子与交互的逻辑。web

数据传递

每个组件之间的做用域是孤立的,这个意味着组件之间的数据不该该出现引用关系,即便出现了引用关系,也不容许组件操做组件内部之外的其余数据。Vue中,容许向组件内部传递prop数据,组件内部须要显性地声明该prop字段,以下声明一个child组件:

<!-- child.vue -->
<template>
    <div>{{msg}}</div>
</template>
<script> export default { props: { msg: { type: String, default: 'hello world' // 当default为引用类型时,须要使用 function 形式返回 } } } </script>
复制代码

父组件向该组件传递数据:

<!-- parent.vue -->
<template>
    <child :msg="parentMsg"></child>
</template>
<script> import child from './child'; export default { components: { child }, data () { return { parentMsg: 'some words' } } } </script>
复制代码

事件传递

Vue内部实现了一个事件总线系统,即EventBus。在Vue中可使用 EventBus 来做为沟通桥梁的概念,每个Vue的组件实例都继承了 EventBus,均可以接受事件$on和发送事件$emit

如上面一个例子,child.vue 组件想修改 parent.vue 组件的 parentMsg 数据,怎么办呢?为了保证数据流的可追溯性,直接修改组件内 prop 的 msg 字段是不提倡的,且例子中为非引用类型 String,直接修改也修改不了,这个时候须要将修改 parentMsg 的事件传递给 child.vue,让 child.vue 来触发修改 parentMsg 的事件。如:

<!-- child.vue -->
<template>
    <div>{{msg}}</div>
</template>
<script> export default { props: { msg: { type: String, default: 'hello world' } }, methods: { changeMsg(newMsg) { this.$emit('updateMsg', newMsg); } } } </script>
复制代码

父组件:

<!-- parent.vue -->
<template>
    <child :msg="parentMsg" @updateMsg="changeParentMsg"></child>
</template>
<script> import child from './child'; export default { components: { child }, data () { return { parentMsg: 'some words' } }, methods: { changeParentMsg: function (newMsg) { this.parentMsg = newMsg } } } </script>
复制代码

父组件 parent.vue 向子组件 child.vue 传递了 updateMsg 事件,在子组件实例化的时候,子组件将 updateMsg 事件使用$on函数注册到组件内部,须要触发事件的时候,调用函数this.$emit来触发事件。

除了父子组件之间的事件传递,还可使用一个 Vue 实例为多层级的父子组件创建数据通讯的桥梁,如:

const eventBus = new Vue();

// 父组件中使用$on监听事件
eventBus.$on('eventName', val => {
    // ...do something
})

// 子组件使用$emit触发事件
eventBus.$emit('eventName', 'this is a message.');
复制代码

除了$on$emit之外,事件总线系统还提供了另外两个方法,$once$off,全部事件以下:

  • $on:监听、注册事件。
  • $emit:触发事件。
  • $once:注册事件,仅容许该事件触发一次,触发结束后当即移除事件。
  • $off:移除事件。

内容分发

Vue实现了一套遵循 Web Components 规范草案 的内容分发系统,即将<slot>元素做为承载分发内容的出口。

插槽slot,也是组件的一块HTML模板,这一块模板显示不显示、以及怎样显示由父组件来决定。实际上,一个slot最核心的两个问题在这里就点出来了,是显示不显示和怎样显示。

插槽又分默认插槽、具名插槽。

默认插槽

又名单个插槽、匿名插槽,与具名插槽相对,这类插槽没有具体名字,一个组件只能有一个该类插槽。

如:

<template>
<!-- 父组件 parent.vue -->
<div class="parent">
    <h1>父容器</h1>
    <child>
        <div class="tmpl">
            <span>菜单1</span>
        </div>
    </child>
</div>
</template>
复制代码
<template>
<!-- 子组件 child.vue -->
<div class="child">
    <h1>子组件</h1>
    <slot></slot>
</div>
</template>
复制代码

如上,渲染时子组件的slot标签会被父组件传入的div.tmpl替换。

具名插槽

匿名插槽没有name属性,因此叫匿名插槽。那么,插槽加了name属性,就变成了具名插槽。具名插槽能够在一个组件中出现N次,出如今不一样的位置,只须要使用不一样的name属性区分便可。

如:

<template>
<!-- 父组件 parent.vue -->
<div class="parent">
    <h1>父容器</h1>
    <child>
        <div class="tmpl" slot="up">
            <span>菜单up-1</span>
        </div>
        <div class="tmpl" slot="down">
            <span>菜单down-1</span>
        </div>
        <div class="tmpl">
            <span>菜单->1</span>
        </div>
    </child>
</div>
</template>
复制代码
<template>
    <div class="child">
        <!-- 具名插槽 -->
        <slot name="up"></slot>
        <h3>这里是子组件</h3>
        <!-- 具名插槽 -->
        <slot name="down"></slot>
        <!-- 匿名插槽 -->
        <slot></slot>
    </div>
</template>
复制代码

如上,slot 标签会根据父容器给 child 标签内传入的内容的 slot 属性值,替换对应的内容。

其实,默认插槽也有 name 属性值,为default,一样指定 slot 的 name 值为 default,同样能够显示父组件中传入的没有指定slot的内容。

做用域插槽

做用域插槽能够是默认插槽,也能够是具名插槽,不同的地方是,做用域插槽能够为 slot 标签绑定数据,让其父组件能够获取到子组件的数据。

如:

<template>
    <!-- parent.vue -->
    <div class="parent">
        <h1>这是父组件</h1>
        <current-user>
            <template slot="default" slot-scope="slotProps">
                {{ slotProps.user.name }}
            </template>
        </current-user>
    </div>
</template>
复制代码
<template>
    <!-- child.vue -->
    <div class="child">
        <h1>这是子组件</h1>
        <slot :user="user"></slot>
    </div>
</template>
<script> export default { data() { return { user: { name: '小赵' } } } } </script>
复制代码

如上例子,子组件 child 在渲染默认插槽 slot 的时候,将数据 user 传递给了 slot 标签,在渲染过程当中,父组件能够经过slot-scope属性获取到 user 数据并渲染视图。

slot 实现原理:当子组件vm实例化时,获取到父组件传入的 slot 标签的内容,存放在vm.$slot中,默认插槽为vm.$slot.default,具名插槽为vm.$slot.xxx,xxx 为 插槽名,当组件执行渲染函数时候,遇到<slot>标签,使用$slot中的内容进行替换,此时能够为插槽传递数据,若存在数据,则可曾该插槽为做用域插槽。

至此,父子组件的关系以下图:

父子组件关系图

模板渲染

Vue.js 的核心是声明式渲染,与命令式渲染不一样,声明式渲染只须要告诉程序,咱们想要的什么效果,其余的事情让程序本身去作。而命令式渲染,须要命令程序一步一步根据命令执行渲染。以下例子区分:

var arr = [1, 2, 3, 4, 5];

// 命令式渲染,关心每一步、关心流程。用命令去实现
var newArr = [];
for (var i = 0; i < arr.length; i++) {
    newArr.push(arr[i] * 2);
}

// 声明式渲染,不用关心中间流程,只须要关心结果和实现的条件
var newArr1 = arr.map(function (item) {
    return item * 2;
});
复制代码

Vue.js 实现了if、for、事件、数据绑定等指令,容许采用简洁的模板语法来声明式地将数据渲染出视图。

模板编译

为何要进行模板编译?实际上,咱们组件中的 template 语法是没法被浏览器解析的,由于它不是正确的 HTML 语法,而模板编译,就是将组件的 template 编译成可执行的 JavaScript 代码,即将 template 转化为真正的渲染函数。

模板编译分三个阶段,parseoptimizegenerate,最终生成render函数。

模板编译

parse阶段:使用正在表达式将template进行字符串解析,获得指令、class、style等数据,生成抽象语法树 AST。

optimize阶段:寻找 AST 中的静态节点进行标记,为后面 VNode 的 patch 过程当中对比作优化。被标记为 static 的节点在后面的 diff 算法中会被直接忽略,不作详细的比较。

generate阶段:根据 AST 结构拼接生成 render 函数的字符串。

预编译

对于 Vue 组件来讲,模板编译只会在组件实例化的时候编译一次,生成渲染函数以后在也不会进行编译。所以,编译对组件的 runtime 是一种性能损耗。而模板编译的目的仅仅是将template转化为render function,而这个过程,正好能够在项目构建的过程当中完成。

好比webpackvue-loader依赖了vue-template-compiler模块,在 webpack 构建过程当中,将template预编译成 render 函数,在 runtime 可直接跳过模板编译过程。

回过头看,runtime 须要是仅仅是 render 函数,而咱们有了预编译以后,咱们只须要保证构建过程当中生成 render 函数就能够。与 React 相似,在添加JSX的语法糖编译器babel-plugin-transform-vue-jsx以后,咱们能够在 Vue 组件中使用JSX语法直接书写 render 函数。

<script> export default { data() { return { msg: 'Hello JSX.' } }, render() { const msg = this.msg; return <div> {msg} </div>; } } </script>
复制代码

如上面组件,使用 JSX 以后,能够在 JS 代码中直接使用 html 标签,并且声明了 render 函数之后,咱们再也不须要声明 template。固然,假如咱们同时声明了 template 标签和 render 函数,构建过程当中,template 编译的结果将覆盖原有的 render 函数,即 template 的优先级高于直接书写的 render 函数。

相对于 template 而言,JSX 具备更高的灵活性,面对与一些复杂的组件来讲,JSX 有着自然的优点,而 template 虽然显得有些呆滞,可是代码结构上更符合视图与逻辑分离的习惯,更简单、更直观、更好维护。

须要注意的是,最后生成的 render 函数是被包裹在with语法中运行的。

小结

Vue 组件经过 prop 进行数据传递,并实现了数据总线系统EventBus,组件集成了EventBus进行事件注册监听、事件触发,使用slot进行内容分发。

除此之外,实现了一套声明式模板系统,在runtime或者预编译是对模板进行编译,生成渲染函数,供组件渲染视图使用。

响应式系统

Vue.js 是一款 MVVM 的JS框架,当对数据模型data进行修改时,视图会自动获得更新,即框架帮咱们完成了更新DOM的操做,而不须要咱们手动的操做DOM。能够这么理解,当咱们对数据进行赋值的时候,Vue 告诉了全部依赖该数据模型的组件,你依赖的数据有更新,你须要进行重渲染了,这个时候,组件就会重渲染,完成了视图的更新。

数据模型 && 计算属性 && 监听器

在组件中,能够为每一个组件定义数据模型data、计算属性computed、监听器watch

数据模型:Vue 实例在建立过程当中,对数据模型data的每个属性加入到响应式系统中,当数据被更改时,视图将获得响应,同步更新。data必须采用函数的方式 return,不使用 return 包裹的数据会在项目的全局可见,会形成变量污染;使用return包裹后数据中变量只在当前组件中生效,不会影响其余组件。

计算属性:computed基于组件响应式依赖进行计算获得结果并缓存起来。只在相关响应式依赖发生改变时它们才会从新求值,也就是说,只有它依赖的响应式数据(data、prop、computed自己)发生变化了才会从新计算。那何时应该使用计算属性呢?模板内的表达式很是便利,可是设计它们的初衷是用于简单运算的。在模板中放入太多的逻辑会让模板太重且难以维护。对于任何复杂逻辑,你都应当使用计算属性。

监听器:监听器watch做用如其名,它能够监听响应式数据的变化,响应式数据包括 data、prop、computed,当响应式数据发生变化时,能够作出相应的处理。当须要在数据变化时执行异步或开销较大的操做时,这个方式是最有用的。

响应式原理

在 Vue 中,数据模型下的全部属性,会被 Vue 使用Object.defineProperty(Vue3.0 使用 Proxy)进行数据劫持代理。响应式的核心机制是观察者模式,数据是被观察的一方,一旦发生变化,通知全部观察者,这样观察者能够作出响应,好比当观察者为视图时,视图能够作出视图的更新。

Vue.js 的响应式系统以来三个重要的概念,ObserverDepWatcher

发布者-Observer

Observe 扮演的角色是发布者,他的主要做用是在组件vm初始化的时,调用defineReactive函数,使用Object.defineProperty方法对对象的每个子属性进行数据劫持/监听,即为每一个属性添加gettersetter,将对应的属性值变成响应式。

在组件初始化时,调用initState函数,内部执行initStateinitPropsinitComputed方法,分别对datapropcomputed进行初始化,让其变成响应式。

初始化props时,对全部props进行遍历,调用defineReactive函数,将每一个 prop 属性值变成响应式,而后将其挂载到_props中,而后经过代理,把vm.xxx代理到vm._props.xxx中。

同理,初始化data时,与prop相同,对全部data进行遍历,调用defineReactive函数,将每一个 data 属性值变成响应式,而后将其挂载到_data中,而后经过代理,把vm.xxx代理到vm._data.xxx中。

初始化computed,首先建立一个观察者对象computed-watcher,而后遍历computed的每个属性,对每个属性值调用defineComputed方法,使用Object.defineProperty将其变成响应式的同时,将其代理到组件实例上,便可经过vm.xxx访问到xxx计算属性。

调度中心/订阅器-Dep

Dep 扮演的角色是调度中心/订阅器,在调用defineReactive将属性值变成响应式的过程当中,也为每一个属性值实例化了一个Dep,主要做用是对观察者(Watcher)进行管理,收集观察者和通知观察者目标更新,即当属性值数据发生改变时,会遍历观察者列表(dep.subs),通知全部的 watcher,让订阅者执行本身的update逻辑。

dep的任务是,在属性的getter方法中,调用dep.depend()方法,将观察者(即 Watcher,多是组件的render function,多是 computed,也多是属性监听 watch)保存在内部,完成其依赖收集。在属性的setter方法中,调用dep.notify()方法,通知全部观察者执行更新,完成派发更新。

观察者-Watcher

Watcher 扮演的角色是订阅者/观察者,他的主要做用是为观察属性提供回调函数以及收集依赖,当被观察的值发生变化时,会接收到来自调度中心Dep的通知,从而触发回调函数。

Watcher又分为三类,normal-watchercomputed-watcherrender-watcher

  • normal-watcher:在组件钩子函数watch中定义,即监听的属性改变了,都会触发定义好的回调函数。

  • computed-watcher:在组件钩子函数computed中定义的,每个computed属性,最后都会生成一个对应的Watcher对象,可是这类Watcher有个特色:当计算属性依赖于其余数据时,属性并不会当即从新计算,只有以后其余地方须要读取属性的时候,它才会真正计算,即具有lazy(懒计算)特性。

  • render-watcher:每个组件都会有一个render-watcher, 当data/computed中的属性改变的时候,会调用该Watcher来更新组件的视图。

这三种Watcher也有固定的执行顺序,分别是:computed-render -> normal-watcher -> render-watcher。这样就能尽量的保证,在更新组件视图的时候,computed 属性已是最新值了,若是 render-watcher 排在 computed-render 前面,就会致使页面更新的时候 computed 值为旧数据。

小结

响应式系统

Observer 负责将数据进行拦截,Watcher 负责订阅,观察数据变化, Dep 负责接收订阅并通知 Observer 和接收发布并通知全部 Watcher。

Virtual DOM

在 Vue 中,template被编译成浏览器可执行的render function,而后配合响应式系统,将render function挂载在render-watcher中,当有数据更改的时候,调度中心Dep通知该render-watcher执行render function,完成视图的渲染与更新。

DOM更新

整个流程看似通顺,可是当执行render function时,若是每次都全量删除并重建 DOM,这对执行性能来讲,无疑是一种巨大的损耗,由于咱们知道,浏览器的DOM很“昂贵”的,当咱们频繁的更新 DOM,会产生必定的性能问题。

为了解决这个问题,Vue 使用 JS 对象将浏览器的 DOM 进行的抽象,这个抽象被称为 Virtual DOM。Virtual DOM 的每一个节点被定义为VNode,当每次执行render function时,Vue 对更新先后的VNode进行Diff对比,找出尽量少的咱们须要更新的真实 DOM 节点,而后只更新须要更新的节点,从而解决频繁更新 DOM 产生的性能问题。

VNode

VNode,全称virtual node,即虚拟节点,对真实 DOM 节点的虚拟描述,在 Vue 的每个组件实例中,会挂载一个$createElement函数,全部的VNode都是由这个函数建立的。

好比建立一个 div:

// 声明 render function
render: function (createElement) {
    // 也可使用 this.$createElement 建立 VNode
    return createElement('div', 'hellow world');
}
// 以上 render 方法返回html片断 <div>hellow world</div>
复制代码

render 函数执行后,会根据VNode Tree将 VNode 映射生成真实 DOM,从而完成视图的渲染。

Diff

Diff 将新老 VNode 节点进行比对,而后将根据二者的比较结果进行最小单位地修改视图,而不是将整个视图根据新的 VNode 重绘,进而达到提高性能的目的。

patch

Vue.js 内部的 diff 被称为patch。其 diff 算法的是经过同层的树节点进行比较,而非对树进行逐层搜索遍历的方式,因此时间复杂度只有O(n),是一种至关高效的算法。

DIFF

首先定义新老节点是否相同断定函数sameVnode:知足键值key和标签名tag必须一致等条件,返回true,不然false

在进行patch以前,新老 VNode 是否知足条件sameVnode(oldVnode, newVnode),知足条件以后,进入流程patchVnode,不然被断定为不相同节点,此时会移除老节点,建立新节点。

patchVnode

patchVnode 的主要做用是断定如何对子节点进行更新,

  1. 若是新旧VNode都是静态的,同时它们的key相同(表明同一节点),而且新的 VNode 是 clone 或者是标记了 once(标记v-once属性,只渲染一次),那么只须要替换 DOM 以及 VNode 便可。

  2. 新老节点均有子节点,则对子节点进行 diff 操做,进行updateChildren,这个 updateChildren 也是 diff 的核心。

  3. 若是老节点没有子节点而新节点存在子节点,先清空老节点 DOM 的文本内容,而后为当前 DOM 节点加入子节点。

  4. 当新节点没有子节点而老节点有子节点的时候,则移除该 DOM 节点的全部子节点。

  5. 当新老节点都无子节点的时候,只是文本的替换。

updateChildren

Diff 的核心,对比新老子节点数据,断定如何对子节点进行操做,在对比过程当中,因为老的子节点存在对当前真实 DOM 的引用,新的子节点只是一个 VNode 数组,因此在进行遍历的过程当中,若发现须要更新真实 DOM 的地方,则会直接在老的子节点上进行真实 DOM 的操做,等到遍历结束,新老子节点则已同步结束。

updateChildren内部定义了4个变量,分别是oldStartIdxoldEndIdxnewStartIdxnewEndIdx,分别表示正在 Diff 对比的新老子节点的左右边界点索引,在老子节点数组中,索引在oldStartIdxoldEndIdx中间的节点,表示老子节点中为被遍历处理的节点,因此小于oldStartIdx或大于oldEndIdx的表示未被遍历处理的节点。同理,在新的子节点数组中,索引在newStartIdxnewEndIdx中间的节点,表示老子节点中为被遍历处理的节点,因此小于newStartIdx或大于newEndIdx的表示未被遍历处理的节点。

每一次遍历,oldStartIdxoldEndIdxnewStartIdxnewEndIdx之间的距离会向中间靠拢。当 oldStartIdx > oldEndIdx 或者 newStartIdx > newEndIdx 时结束循环。

img

在遍历中,取出4索引对应的 Vnode节点:

  • oldStartIdx:oldStartVnode
  • oldEndIdx:oldEndVnode
  • newStartIdx:newStartVnode
  • newEndIdx:newEndVnode

diff 过程当中,若是存在key,而且知足sameVnode,会将该 DOM 节点进行复用,不然则会建立一个新的 DOM 节点。

首先,oldStartVnodeoldEndVnodenewStartVnodenewEndVnode两两比较,一共有 2*2=4 种比较方法。

状况一:当oldStartVnodenewStartVnode知足 sameVnode,则oldStartVnodenewStartVnode进行 patchVnode,而且oldStartIdxnewStartIdx右移动。

状况二:与状况一相似,当oldEndVnodenewEndVnode知足 sameVnode,则oldEndVnodenewEndVnode进行 patchVnode,而且oldEndIdxnewEndIdx左移动。

状况三:当oldStartVnodenewEndVnode知足 sameVnode,则说明oldStartVnode已经跑到了oldEndVnode后面去了,此时oldStartVnodenewEndVnode进行 patchVnode 的同时,还须要将oldStartVnode的真实 DOM 节点移动到oldEndVnode的后面,而且oldStartIdx右移,newEndIdx左移。

状况四:与状况三相似,当oldEndVnodenewStartVnode知足 sameVnode,则说明oldEndVnode已经跑到了oldStartVnode前面去了,此时oldEndVnodenewStartVnode进行 patchVnode 的同时,还须要将oldEndVnode的真实 DOM 节点移动到oldStartVnode的前面,而且oldStartIdx右移,newEndIdx左移。

当这四种状况都不知足,则在oldStartIdxoldEndIdx之间查找与newStartVnode知足sameVnode的节点,若存在,则将匹配的节点真实 DOM 移动到oldStartVnode的前面。

若不存在,说明newStartVnode为新节点,建立新节点放在oldStartVnode前面便可。

当 oldStartIdx > oldEndIdx 或者 newStartIdx > newEndIdx,循环结束,这个时候咱们须要处理那些未被遍历到的 VNode。

当 oldStartIdx > oldEndIdx 时,说明老的节点已经遍历完,而新的节点没遍历完,这个时候须要将新的节点建立以后放在oldEndVnode后面。

当 newStartIdx > newEndIdx 时,说明新的节点已经遍历完,而老的节点没遍历完,这个时候要将没遍历的老的节点全都删除。

此时已经完成了子节点的匹配。下面是一个例子 patch 过程图:

patchChildren

总结

借用官方的一幅图:

Vue.js 实现了一套声明式渲染引擎,并在runtime或者预编译时将声明式的模板编译成渲染函数,挂载在观察者 Watcher 中,在渲染函数中(touch),响应式系统使用响应式数据的getter方法对观察者进行依赖收集(Collect as Dependency),使用响应式数据的setter方法通知(notify)全部观察者进行更新,此时观察者 Watcher 会触发组件的渲染函数(Trigger re-render),组件执行的 render 函数,生成一个新的 Virtual DOM Tree,此时 Vue 会对新老 Virtual DOM Tree 进行 Diff,查找出须要操做的真实 DOM 并对其进行更新。

原文地址

参考

相关文章
相关标签/搜索