Vue 面试知识点总结【持续更新中~】
本文内容来自网络,整理出来分享于大家~~
一、剖析 Vue.js 内部运行机制
参考至小册
剖析 Vue.js 内部运行机制
先来一张总体图,然后我们对每一部分详细分析。
1、初始化与挂载
new Vue
之后回调用一个_init
方法去初始化,会初始化data
、props
、methods
、声明周期
、watch
、computed
、事件
等。其中最重要的一点就是通过Object.defineProperty
来设置getter
和setter
,从而实现数据的【双向绑定响应式】和【依赖收集】。
初始化完之后会调用一个$mount
来实现挂载。如果是运行时编译,则不存在render function
,存在template
的情况需要重新编译。(我理解的意思:最开始我们需要去解析编译template
中的内容,实现依赖收集和数据绑定,最后会生成一个render function
.但是如果是运行时候比如响应数据的更改等,则不会在生成render function
,而是通过diff
算法直接操作虚拟DOM
,实现正式结点的更新)。
2、响应式系统的实现原理
Vue是一款MVVM的框架,数据模型仅仅是普通的js对象,但是在操作这些对象的时候确可以及时的响应视图的变化。依赖的就是Vue的【响应式系统】。
面试题 —— 你了解Vue的MVVM吗?
MVVM包含三层:模型层Model,视图层View,控制层ViewModel.
联系:
- 视图层变化可以被viewModel监听到,从而更改Model中的数据。是通过DOM事件监听实现。
- Model层发生变化,可以被viewModel响应到view层,从而更新视图。是通过数据绑定。
总之:DOM事件监听和数据绑定是MVVM的关键。
DOM Listeners
监听页面所有View层DOM元素的变化,当发生变化,
Model
层的数据随之变化;
Data Bindings
监听
Model
层的数据,当数据发生变化,
View
层的DOM元素随之变化。
(1)Object.defineProperty
首先我们来介绍一下 Object.defineProperty,Vue.js就是基于它实现「响应式系统」的。
Object.defineProperty(obj, prop, descriptor);
descriptor的一些属性,简单介绍几个属性:
- enumerable,属性是否可枚举,默认 false。
- configurable,属性是否可以被修改或者删除,默认 false。
- get,获取属性的方法。
- set,设置属性的方法。
- writable,当且仅当该属性的writable为true时,value才能被赋值运算符改变。默认为 false。
var o = {}; // 创建一个新对象 // 【1】在对象中添加一个属性与数据描述符的示例 Object.defineProperty(o, "a", { value : 37, writable : true, enumerable : true, configurable : true }); // 对象o拥有了属性a,值为37 // 【2】在对象中添加一个属性与存取描述符的示例 var bValue; Object.defineProperty(o, "b", { get : function(){ return bValue; }, set : function(newValue){ bValue = newValue; }, enumerable : true, configurable : true }); o.b = 38; // 对象o拥有了属性b,值为38 // o.b的值现在总是与bValue相同,除非重新定义o.b
(2)实现数据的观察(observer)
这是响应式系统最为重要的一步。利用的便是我们上面提到的Object.defineProperty
。
实现一个简单的对数据的getter和setter监听:
// 遍历数据对象的每个属性,这里我们只做了一层,实际上会使用递归去处理深层次的数据 // 这里为了我们的方便理解,就假设是单层对象 function observer (value) { if (!value || (typeof value !== 'object')) { return; } Object.keys(value).forEach((key) => { defineReactive(value, key, value[key]); }); } // 函数模拟视图更新 function cb (val) { console.log("视图更新啦~", val); } // 数据对象成员的响应式监听 function defineReactive (obj, key, val) { Object.defineProperty(obj, key, { enumerable: true, // 可枚举 configurable: true, // 可配置 get: function reactiveGetter () { return val; // 当使用到我们的这个属性的时候会触发get方法,这里用来依赖收集,我们之后实现 }, set: function reactiveSetter (newVal) { // 监听数据的修改,模拟视图更新,其实这里的过程相当的复杂,diff是一个必经过程 if (newVal === val) return; val = newVal; cb(newVal); } }); } class Vue { constructor(options) { this._data = options.data; // 获取数据对象 observer(this._data); // 实现对数据中每个元素的观察,即为每个属性去设置get和set。 } } // 测试案例 let o = new Vue({ data: { test: "I am test." } }); o._data.test = "hello,test.";
上面我们实现的是一个简单的响应式原理案例,我们只是实现了对数据对象的观察。当我们的数据使用和被修改的时候会调用我们的自定义get和set方法。下面我们去了解一下,数据【依赖收集】。
(3)依赖收集
为什么要进行依赖收集呢?
new Vue({ template: `<div> <span>{{text1}}</span> <span>{{text2}}</span> <div>`, data: { text1: 'text1', text2: 'text2', text3: 'text3' } });
上面例子中,text1,text2使用了一次,text3未使用。
如果我们对某一个数据进行了修改,那么我们应该知道的哪些地方使用了该数据,为了我们视图的更新做好准备。
「依赖收集」会让
text1
这个数据知道“哦~有两个地方依赖我的数据,我变化的时候需要通知它们~”。
订阅者Dep
class Dep { constructor () { /* 用来存放Watcher对象的数组 */ this.subs = []; } /* 在subs中添加一个Watcher对象 */ addSub (sub) { this.subs.push(sub); } /* 通知所有Watcher对象更新视图 */ notify () { this.subs.forEach((sub) => { sub.update(); }) } }
订阅者对象含有两个方法,addSub用来收集watcher对象,notify用来通知watcher对象去更新视图。
观察者Watcher
class Watcher { constructor () { /* 在new一个Watcher对象时将该对象赋值给Dep.target,在get中会用到 */ Dep.target = this; } /* 更新视图的方法 */ update () { console.log("视图更新啦~"); } } Dep.target = null;
观察者对象在实例化的时候就需要绑定它所属的Dep。同时还有一个update方法去更新视图。
依赖收集原理
function defineReactive (obj, key, val) { /* 一个Dep类对象 */ const dep = new Dep(); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter () { /* 将Dep.target(即当前的Watcher对象存入dep的subs中) */ dep.addSub(Dep.target); return val; }, set: function reactiveSetter (newVal) { if (newVal === val) return; /* 在set的时候触发dep的notify来通知所有的Watcher对象更新视图 */ dep.notify(); } }); } class Vue { constructor(options) { this._data = options.data; observer(this._data); /* 新建一个Watcher观察者对象,这时候Dep.target会指向这个Watcher对象*/ // 实例化一个观察者 new Watcher(); /* 在这里模拟render的过程,为了触发test属性的get函数 */ console.log('render~', this._data.test); // 触发get之后,会将上面刚实例化的watcher对象,添加到Dep对象中。 // 注:这里只实例化了一个watcher,其实watcher对象没有我们上诉的那么简单,它记录的是当前引用的相关信息。为方便下次数据的更新时候,去更新视图 } }
当触发一个属性的get方法后,会执行我们的依赖收集。首先实例化一个watcher对象,这个watcher对象有这个属性的更新视图的方法。然后通过Dep的addSub方法将该watcher对象添加到Dep订阅者中。
【依赖收集】的关键条件:(1)触发get方法 (2)新建一个watcher对象
总结: 到了这里我们已经吧响应式系统学了,主要是get进行依赖收集,set中用过watcher观察者去更新视图。
面试题 —— 你了解Vue的响应式系统原理吗?
Vue
采用的是数据劫持的方式,当你设置data
属性的值时候,vue
就会遍历data
属性,对每一个属性通过Object.defineProperty
来设置getter
和setter
。当触发render function
渲染的时候,就会触发属性的getter
方法,同时触发getter
方法中的依赖收集,所谓的依赖收集就是将观察者Watcher
对象存放到当前闭包中的订阅者Dep
的 subs
中。形成如下所示的这样一个关系。
在修改对象的值的时候,会触发对应的setter
, setter
通知之前「依赖收集」得到的Dep
中的每一个 Watcher
,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher
就会开始调用 update
来更新视图,当然这中间还有一个patch
的过程以及使用队列来异步更新的策略。实质就是在数据变动时发布消息给订阅者,触发需要修改的watcher
中的notify
方法相应的监听回调.
3、模版编译
compile编译可以分成 Html解析parse
、优化optimize
与 转换generate
三个阶段,最终需要得到render function
。
parse解析
<div :class="c" class="demo" v-if="isShow"> <span v-for="item in sz">{{item}}</span> </div>
对HTML进行字符串解析,从而得到class、style、指令等数据,形成AST。AST是一种抽象语法树。上面的例子解析完后是:
{ /* 标签属性的map,记录了标签上属性 */ 'attrsMap': { ':class': 'c', 'class': 'demo', 'v-if': 'isShow' }, /* 解析得到的:class */ 'classBinding': 'c', /* 标签属性v-if */ 'if': 'isShow', /* v-if的条件 */ 'ifConditions': [ { 'exp': 'isShow' } ], /* 标签属性class */ 'staticClass': 'demo', /* 标签的tag */ 'tag': 'div', /* 子标签数组 */ 'children': [ { 'attrsMap': { 'v-for': "item in sz" }, /* for循环的参数 */ 'alias': "item", /* for循环的对象 */ 'for': 'sz', /* for循环是否已经被处理的标记位 */ 'forProcessed': true, 'tag': 'span', 'children': [ { /* 表达式,_s是一个转字符串的函数 */ 'expression': '_s(item)', 'text': '{{item}}' } ] } ] }
optimize优化
optimize
主要作用就跟它的名字一样,用作「优化」
。
这个涉及到后面要讲 patch
的过程,因为patch的过程实际上是将 VNode
节点进行一层一层的比对,然后将「差异」
更新到视图上。
那么一些静态节点
是不会根据数据变化而产生变化的,我们就需要为静态的节点做上一些「标记」
,在 patch 的时候我们就可以直接跳过这些被标记的节点的比对,从而达到「优化」的目的。
generate 转为 render function
generate
会将 AST
转化成 render funtion
字符串
render function 看起来就像下面:
with(this){ return (isShow) ? _c( 'div', { staticClass: "demo", class: c }, _l( (sz), function(item){ return _c('span',[_v(_s(item))]) } ) ) : _e() }
经历过这些过程以后,我们已经把 template
顺利转成了 render function
了,之后 render function
就会转换为Virtual DOM。
4、虚拟DOM(VirtualDOM)
虚拟DOM实质就是一个实打实的javascript对象。它是对真是DOM的一层映射。用对象属性来描述某个结点,以及它的子结点。由于虚拟DOM是javascript对象为基础,所以不依赖任何环境,所以具有跨平台的特性。也正式因为基于这一点,Vue具有跨平台的能力~~
我们来看一个简单的虚拟DOM实例:
class VNode { constructor (tag, data, children, text, elm) { /*当前节点的标签名*/ this.tag = tag; /*当前节点的一些数据信息,比如props、attrs等数据*/ this.data = data; /*当前节点的子节点,是一个数组*/ this.children = children; /*当前节点的文本*/ this.text = text; /*当前虚拟节点对应的真实dom节点*/ this.elm = elm; } }
我们有一段template代码:
<template> <span class="demo" v-show="isShow"> This is a span. </span> </template>
用js对象表示就是:
function render () { return new VNode( 'span', { /* 指令集合数组 */ directives: [ { /* v-show指令 */ rawName: 'v-show', expression: 'isShow', name: 'show', value: true } ], /* 静态class */ staticClass: 'demo' }, [ new VNode(undefined, undefined, undefined, 'This is a span.') ] ); }
转换成 VNode 以后的情况。
{ tag: 'span', data: { /* 指令集合数组 */ directives: [ { /* v-show指令 */ rawName: 'v-show', expression: 'isShow', name: 'show', value: true } ], /* 静态class */ staticClass: 'demo' }, text: undefined, children: [ /* 子节点是一个文本VNode节点 */ { tag: undefined, data: undefined, text: 'This is a span.', children: undefined } ] }
该种形式就可以让我们在不同的平台实现很好的兼容了。
如何产生上诉对象呢,我们需要通过一些自定义函数来实现,举一个简答例子:我们创建一个空结点。
function createEmptyVNode () { const node = new VNode(); node.text = ''; return node; }
所以虚拟DOM可以通过调用一系列自定义的内部函数来实现,最终创建的就是 一个 VNode 实例对象。
再来看我们的
render function
:
with(this){ return (isShow) ? _c( 'div', { staticClass: "demo", class: c }, _l( (sz), function(item){ return _c('span',[_v(_s(item))]) } ) ) : _e() }
上面这个 render function看到这里可能会纳闷了,这些_c,_l 到底是什么?其实他们是 Vue.js 对一些函数的简写,比如说 _c对应的是createElement 这个函数。
到了这里你是不是懂了我们之前所说的一句话了:我们之前说render function
是用来生成虚拟DOM对象的。其实render function
就是一个复杂的函数调用。最后会通过层层调用来实现一个真正的js对象(虚拟对象)。
5、数据状态更新时的差异 diff 及 patch 机制
当我们触发数据的更新时,会调用Dep
中的watcher
对象的update
方法来更新视图。最终是将新产生的 VNode
节点与老 VNode
进行一个 patch
的过程,比对得出「差异」,最终将这些「差异」更新到视图上。
patch过程其实就是利用diff算法进行一个差异比对的过程~
推荐两个diff算法执行过程的图解:
总结
无oldStartVnode则移动(参照round6) 对比头部,成功则更新并移动(参照round4) 对比尾部,成功则更新并移动(参照round1) 头尾对比,成功则更新并移动(参照round5) 尾头对比,成功则更新并移动(参照round2) 在oldKeyToIdx中根据newStartVnode的可以进行查找,成功则更新并移动(参照round3) (更新并移动:patchVnode更新对应vnode的elm,并移动指针)
我们在整个过程中使用了diff算法去逐一判断,通过patch去判断两个节点是否更新,然后作出相应的DOM操作。总之:diff算法告诉我们如何去处理同层下的新旧VNode。
Diff过程中,Vue会尽可能的复用DOM,能不移动就不移动。
6、批量异步更新策略
我们知道在我们修改data 之后其实就是一个“setter -> Dep -> Watcher -> patch -> 视图”
的过程。
假设我们有如下这么一种情况。
<template> <div> <div>{{number}}</div> <div @click="handleClick">click</div> </div> </template> export default { data () { return { number: 0 }; }, methods: { handleClick () { for(let i = 0; i < 1000; i++) { this.number++; } } } }
当我们按下 click 按钮的时候,number 会被循环增加1000次。
那么按照之前的理解,每次 number 被 +1 的时候,都会触发 number 的 setter 方法,从而根据上面的流程一直跑下来最后修改真实 DOM。那么在这个过程中,DOM 会被更新 1000 次!这样子太消耗性能了,太可怕了~。
Vue做了相应的处理:
Vue.js在默认情况下,每次触发某个数据的 setter 方法后,对应的 Watcher 对象其实会被 push 进一个队列 queue 中,这些watcher对象都设置了标识,如果是对同一个数据的更新,watcher的标识是相同的,在下一个 tick 的时候将这个队列 queue 全部拿出来 run( Watcher 对象的一个方法,用来触发 patch 操作) 一遍。run的时候会进行筛选,然后根据标识判断重复的watcher对象只执行最后的。
let watch1 = new Watcher(); let watch2 = new Watcher(); watch1.update(); watch1.update(); watch2.update();
watch1只调用最后那次。
二、Vue核心高频知识点
上面我们对Vue的底层进行了一定的了解,虽然不是源码解析,但是我们用一种简介明了的方式理解了底层的大致运行流程,下面我们针对一些面试题目,来温习一下我们Vue的知识点吧~~
1、vue.js的两个核心是什么?
(1)数据双向绑定
vue的数据响应式原理,技术上是采用Object.defineProperty和存储属性get、set来是实现的基于依赖收集的数据观测机制。核心是viewModel,保证数据和视图的一致性。
(2)组件
Vue中万物皆组件的理念使得它与虚拟DOM的契合度达到了非常好的地步。
.vue
组件的形式如下:
1、模板(template):模板声明了数据和最终展现给用户的DOM之间的映射关系。
2、初始数据(data):一个组件的初始数据状态。对于可复用的组件来说,这通常是私有的状态。
3、接受的外部参数(props):组件之间通过参数来进行数据的传递和共享。
4、方法(methods):对数据的改动操作一般都在组件的方法内进行。
5、生命周期钩子函数(lifecycle hooks):一个组件会触发多个生命周期钩子函数,最新2.0版本对于生命周期函数名称改动很大。
6、私有资源(assets):Vue.js当中将用户自定义的指令、过滤器、组件等统称为资源。一个组件可以声明自己的私有资源。私有资源只有该组件和它的子组件可以调用。
等等。
2、对于 Vue 是一套 构建用户界面 的 渐进式框架 的理解
渐进式指的是:没有多做职责以外的事。Vue只提供了组件系统和数据响应式系统两大核心。基于vue-cli的生态,则还需要vue-router、vuex等的第三方库的支持。我们学习使用Vue,可以是需要什么功能,我们就学什么功能。
Vue与React、Angular的不同是,但它是渐进的:
- 你可以在原有大系统的上面,把一两个组件改用Vue实现
- 也可以整个用它全家桶开发
- 还可以用它的视图,搭配你自己设计的整个下层用。
- 也可以函数式,都可以,它只是个轻量视图而已,只做了最核心的东西。
3、你了解Vue常用的指令吗?
- v-if:根据表达式的值的真假条件渲染元素。在切换时元素及它的数据绑定或组件被销毁并重建。(性能低)
<h1 v-if="awesome">Vue is awesome!</h1> <h1 v-else>Oh no 😢</h1>
template上也可以使用if,来是想分组。
key来管理可复用的组件:
<template v-if="loginType === 'username'"> <label>Username</label> <input placeholder="Enter your username"> </template> <template v-else> <label>Email</label> <input placeholder="Enter your email address"> </template>
vue为了尽可能的实现快速,减少不必要的性能消耗,通常会复用已有的元素,这样做会使得vue变得很快。
上例子来说,我们通过v-if来条件渲染,那么label和input元素会被高度复用,我们输入的内容在切换的过程中是不会被清除掉的。因此为了能清空输入,我们可以给input添加不一样的key值,这样每次切换都会重新渲染input组件。
<template v-if="loginType === 'username'"> <label>Username</label> <input placeholder="Enter your username" key="username-input"> </template> <template v-else> <label>Email</label> <input placeholder="Enter your email address" key="email-input"> </template>
- v-show:根据表达式之真假值,切换元素的 display CSS 属性。(效果同if,实现显示和隐藏)
<h1 v-show="ok">Hello!</h1>
show不能适用于template。
- v-for:循环指令,基于一个数组或者对象渲染一个列表,vue 2.0以上必须需配合 key值 使用。
<ul id="example-2"> <li v-for="(item, index) in items"> {{ index }} - {{ item.message }} </li> </ul>
还可以列表渲染对象。
<div v-for="(value, keyName, index) in object"> {{ index }}. {{ keyName }}: {{ value }} </div>
在遍历对象时,会按 Object.keys() 的结果遍历,但是不能保证它的结果在不同的 JavaScript 引擎下都一致。
尽量在使用for的时候哦使用key来标识,因为他可以帮我们来跟踪每一个结点,对复用和重排现有元素起着非常大的作用。因为它是 Vue 识别节点的一个通用机制,key 并不仅与 v-for 特别关联。后面我们将在指南中看到,它还具有其它用途。
<div v-for="item in items" v-bind:key="item.id"> <!-- 内容 --> </div>
- v-bind:动态地绑定一个或多个特性,或一个组件 prop 到表达式。
<a v-bind:href="url">...</a>
- v-on:用于监听指定元素的DOM事件,比如点击事件。绑定事件监听器。
<a v-on:click="doSomething">...</a>
2.6之后允许传入js表达式来动态修改传入的变量值。
<a v-bind:[attributeName]="url"> ... </a>
<a v-on:[eventName]="doSomething"> ... </a>
- v-model:实现表单输入和应用状态之间的双向绑定
- v-pre:跳过这个元素和它的子元素的编译过程。可以用来显示原始 Mustache 标签。跳过大量没有指令的节点会加快编译。
// link:'<a href="#" rel="external nofollow" >这是一个连接</a>' 如果想显示{{ }}标签,而不进行替换,使用v-pre即可跳过这个元素和它的子元素的编译过程 <span v-pre>{{ 这里的内容不会被编译 }}</span> <span v-html="link"></span>
- v-once:只渲染元素和组件一次。随后的重新渲染,元素/组件及其所有的子节点将被视为静态内容并跳过。这可以用于优化更新性能。
<div id="app"> <p v-once>{{msg}}</p> //msg不会改变 <p>{{msg}}</p> // msg会不断变化 <p> <input type="text" v-model = "msg" name=""> </p> </div> <script type="text/javascript"> let vm = new Vue({ el : '#app', data : { msg : "hello" } }); </script>
4、v-if vs v-show?
相同
都能实现DOM的显示与隐藏。都是用于条件渲染。接收boolean来判断是否显示。
不同
- 编译过程:if是真正的条件渲染,因为他会保证所包含的元素在条件发生变化时候哦的销毁与重建。show只是去更改CSS的display属性,并没有被销毁,只是我们看不到了~
- 编译条件:if具有惰性,当初始条件为false时候,包含的元素是不会创建的,只有当条件为true,才会去构建。show则是不管条件如何,都会去渲染所包含元素,只是显示与否而已。
- 性能问题:if因为每次都会进行销毁与重建,所以性能消耗较大。而show性能消耗非常小。
- 使用场景:show适合与频繁切换的场景。if适合于很少切换的场景。
5、vue对于数组的监测和对象的监测有哪些注意事项?
1、数组变异方法
vue对一些数组的方法做了包裹处理,我们在调用这些方法的时候,仍然可以触发视图的更新。
push() pop() shift() unshift() splice() sort() reverse()
比如 this.items.push({ message: ‘Baz’ })。也会触发视图的更新。
当然上面的方法会修改原来的数组,还有一些方法返回的是新的数组,并不会修改原来的数组,比如:filter()、concat() 和 slice().可以使用如下方法:
example1.items = example1.items.filter(function (item) { return item.message.match(/Foo/) })
2、vue存在不能监测数组和对象属性的异常
由于js的限制,Vue 不能检测以下数组的变动:
- 当你利用索引直接设置一个数组项时,例如:
vm.items[indexOfItem] = newValue
- 当你修改数组的长度时,例如:
vm.items.length = newLength
var vm = new Vue({ data: { items: ['a', 'b', 'c'] } }) vm.items[1] = 'x' // 不是响应性的 vm.items.length = 2 // 不是响应性的
解决办法:
// Array.prototype.splice vm.items.splice(indexOfItem, 1, newValue) // 先删除后添加
this.$set(this.items, index, newValue)
为了解决第二类问题,你可以使用 splice:
vm.items.splice(newLength)
由于js的限制,其实对象添加属性也存在一些问题:
var vm = new Vue({ data: { a: 1 } }) // `vm.a` 现在是响应式的 vm.b = 2 // `vm.b` 不是响应式的
对于已经创建的实例,Vue 不允许动态添加根级别的响应式属性
对于不是根级别的,如果要添加新的属性:
var vm = new Vue({ data: { userProfile: { name: 'Anika' } } }) this.$set(this.userProfile, "age", 27);
如果要新添加多个值:
Object.assign( {}, this.userProfile, { age: 27, favoriteColor: 'Vue Green' })
6、常用的指令修饰符?
v-on
常用一些修饰符来简单的实现我们预期的效果。
事件修饰符
.stop
– 调用 event.stopPropagation()
,禁止事件冒泡。.prevent
– 调用 event.preventDefault()
,阻止事件默认行为。.capture
– 添加事件侦听器时使用 capture 模式。捕获事件模式.self
– 只当事件是从侦听器绑定的元素本身触发时才触发回调。.{keyCode | keyAlias}
– 只当事件是从特定键触发时才触发回调。.native
– 监听组件根元素的原生事件。.once
– 只触发一次回调。.left
– (2.2.0) 只当点击鼠标左键时触发。.right
– (2.2.0) 只当点击鼠标右键时触发。.middle
– (2.2.0) 只当点击鼠标中键时触发。.passive
– (2.3.0) 以 { passive: true } 模式添加侦听器
<!-- 阻止单击事件继续传播 --> <a v-on:click.stop="doThis"></a> <!-- 提交事件不再重载页面,阻止默认事件行为 --> <form v-on:submit.prevent="onSubmit"></form> <!-- 修饰符可以串联 --> <a v-on:click.stop.prevent="doThat"></a> <!-- 只有修饰符 --> <form v-on:submit.prevent></form> <!-- 添加事件监听器时使用事件捕获模式 --> <!-- 即元素自身触发的事件先在此处理,然后才交由内部元素进行处理 --> <div v-on:click.capture="doThis">...</div> <!-- 只当在 event.target 是当前元素自身时触发处理函数 --> <!-- 即事件不是从内部元素触发的 --> <div v-on:click.self="doThat">...</div> <!-- 滚动事件的默认行为 (即滚动行为) 将会立即触发 --> <!-- 而不会等待 `onScroll` 完成 --> <!-- 这其中包含 `event.preventDefault()` 的情况 --> // 可以用于提升移动端的性能 <div v-on:scroll.passive="onScroll">...</div> <!-- 点击事件将只会触发一次 --> <a v-on:click.once="doThis"></a> <!-- 只有在 `key` 是 `Enter` 时调用 `vm.submit()` --> <input v-on:keyup.enter="submit">
为了在必要的情况下支持旧浏览器,Vue 提供了绝大多数常用的按键码的别名:
按键修饰符
.enter .tab .delete (捕获“删除”和“退格”键) .esc .space .up .down .left .right
可以用如下修饰符来实现仅在按下相应按键时才触发鼠标或键盘事件的监听器。
.ctrl .alt .shift .meta
注意:在 Mac 系统键盘上,meta 对应 command 键 (⌘)。在 Windows 系统键盘 meta 对应 Windows 徽标键 (⊞)。
<!-- Alt + C --> <input @keyup.alt.67="clear"> <!-- Ctrl + Click --> <div @click.ctrl="doSomething">Do something</div>
7、为什么在 HTML 中监听事件?
你可能注意到这种事件监听的方式违背了关注点分离 (separation of concern) 这个长期以来的优良传统。我们通常都是js中获取DOM来绑定事件,然而这种方式确全部绑定在了HTML中。
我们其实不必担心,我个人看法是:这种方式绑定在一个一个的元素上,而我们Vue
是基于虚拟DOM的,也就是说template
中的内容,最终会编译为renderfunction
,转为虚拟DOM后最终由viewModel去管理。它不会导致任何维护上的难题。相反,这样还有一些好处:
- 扫一眼 HTML 模板便能轻松定位在 JavaScript 代码里对应的方法。
- 无需在js中手动绑定事件,因此我们可以将逻辑集中,不需要在花多的精力去绑定事件等
- 当一块内容被销毁的时候,其相应的事件绑定,以及其他都会自动销毁,无需我们操心。
8、v-on可以监听多个方法吗?
on可以监听多个事件,但是不能是同一事件,会报错~~
<input type="text" :value="val" @input="inputHandler" @focus="focusHandler" @blur="blurHandler" /> // 下面这种会报错 <a href="javascript:;" @click="methodsOne" @click="methodsTwo"></a>
9、vue中key的作用是什么?
我们很多人都会对key是否能加快diff速度而产生疑惑?
diff算法只比较同层的节点,如果节点类型不同,直接干掉前面的节点,再创建并插入新的节点,不会再比较这个节点以后的子节点了。如果节点类型相同,则会重新设置该节点的属性,从而实现节点的更新。
比如我们有如下情况:
我们希望可以在B和C之间加一个F,Diff算法默认执行起来是这样的:
在没有key的情况下,会原地复用,修改节点信息,最后还会新增一个节点。
即把C更新成F,D更新成C,E更新成D,最后再插入E,这样只有在当我们的每个结点较为简单的情况下才会快速。
如果是设置了key的情况:效果如下:
从以上来看,不带有key,并且使用简单的模板,基于这个前提下,可以更有效的复用节点,diff速度来看也是不带key更加快速的,因为带key在增删节点上有耗时。这就是vue文档所说的默认模式。但是这个并不是key作用,而是没有key的情况下可以对节点就地复用,提高性能。
本人认为以下才是key提高diff算法速度的要点
diff算法用于比对新旧虚拟DOM对象,当我们在比较头尾节点无果后,会根据新节点的key去对比旧节点数组中的key,从而找到相应旧节点。如果没找到就认为是一个新增节点。如果找到了就去比对,然后更新节点。(这里可以借助于map高效的定位性来加快diff的查找速度)
还有一种情况
vue中在使用相同标签名元素的过渡切换时,也会使用到key属性,其目的也是为了让vue可以区分它们,否则vue只会替换其内部属性而不会触发过渡效果。具体例子可以看上面第三题中的input替换问题/
10、vue事件中如何使用event对象?
//html部分 <a href="javascript:void(0);" data-id="12" @click="showEvent($event)">event</a> //js部分 showEvent(event){ //获取自定义data-id console.log(event.target.dataset.id) //阻止事件冒泡 event.stopPropagation(); //阻止默认 event.preventDefault() }
11、什么是$nextTick?
vue是基于数据驱动页面的,视图的更新是异步执行的。即我们修改数据的当下,不会立即执行视图更新,而是会添加到一个异步的队列中,等当前事件循环中的数据变化全部完成之后,才会统一处理。$nextTick就是用来知道什么时候DOM更新完成的.
案例:
我们先来看这样一个场景:有一个div,默认用 v-if 将它隐藏,点击一个按钮后,改变 v-if 的值,让它显示出来,同时拿到这个div的文本内容。如果v-if的值是 false,直接去获取div内容是获取不到的,因为此时div还没有被创建出来,那么应该在点击按钮后,改变v-if的值为 true,div才会被创建,此时再去获取,示例代码如下:
<div id="app"> <div id="div" v-if="showDiv">这是一段文本</div> <button @click="getText">获取div内容</button> </div> <script> var app = new Vue({ el : "#app", data:{ showDiv : false }, methods:{ getText:function(){ this.showDiv = true; // 原生事件绑定 var text = document.getElementById('div').innnerHTML; console.log(text); } } }) </script>
这段代码并不难理解,但是运行后在控制台会抛出一个错误:Cannot read property 'innnerHTML of null
,意思就是获取不到div元素。这里就涉及Vue一个重要的概念:异步更新队列。
Vue在观察到数据变化时并不是直接更新DOM,而是开启一个队列,并缓冲在同一个事件循环中发生的所以数据改变。在缓冲时会去除重复数据,从而避免不必要的计算和DOM操作。然后,在下一个事件循环tick中,Vue刷新队列并执行实际(已去重的)工作。所以如果你用一个for循环来动态改变数据100次,其实它只会应用最后一次改变,如果没有这种机制,DOM就要重绘100次,这固然是一个很大的开销。
简单的浏览器事件机制
同步代码执行 -> 查找异步队列,推入执行栈,执行callback1[事件循环1] -> 查找异步队列,推入执行栈,执行callback2[事件循环2]…
结合nextTick的由来,可以推出每个事件循环中,nextTick触发的时机:
(1)同一事件循环中的代码执行完毕 -> (2)DOM 更新 -> (3)nextTick callback触发
当我们触发数据变动的时候,此时处于1,此时DOM还没更新,vue实现了一个$nextTick语法糖,Vue会根据当前浏览器环境优先使用原生的Promise.then和MutationObserver,如果都不支持,就会采用setTimeout代替。这个方法其实就是将我们的DOM操作代码放入了下一轮循环的异步队列中,下一轮循环中当将其掉入主线程我们才能顺利的执行回调中的代码~
举例一个业务场景:select选择我们要显示那种下面的控件。该控件依赖第三方库,需要获取DOM。
watch:{ type: function (val, oldVal) { if(val==2){ // 异步 Vue.nextTick(function () { //或者用 this.$nextTick $('#select').selectpicker(); }) } } }
理论上,我们应该不用去主动操作DOM,因为Vue的核心思想就是数据驱动DOM,但在很多业务里,我们避免不了会使用一些第三方库,比如 popper.js、swiper等,这些基于原生javascript的库都有创建和更新及销毁的完整生命周期,与Vue配合使用时,就要利用好$nextTick。
10、vue中data为什么要返回一个对象?
//为什么data函数里面要return一个对象 <script> export default { data() { return { // 返回一个唯一的对象,不要和其他组件共用一个对象进行返回 menu: MENU.data, poi: POILIST.data } } } </script>
组件是可以被重用的,组件的其他方法可以被共用,但是数据对象确不能,因为我们想要不同调用处的组件有自己的数据对象,而不能被互相影响,因此返回对象,则不会每次的引用地址就是不同的了。
11、v-if和v-for的哪个优先级更高?
当他们处于同一个元素上,for的优先级要高于if。
<li v-for="todo in todos" v-if="!todo.isComplete"> {{ todo }} </li>
上例子会根据条件进行渲染。
如果你的目的是有条件地跳过循环的执行,那么可以将 v-if 置于外层元素
<ul v-if="todos.length"> <li v-for="todo in todos"> {{ todo }} </li> </ul> <p v-else>No todos left!</p>
12、你了解keep-alive吗?
场景:当在这些组件之间切换的时候,你有时会想保持这些组件的状态,以避免反复重渲染导致的性能问题。例如我们来展开说一说这个多标签界面:
这是一个来自官网的案例,我们点击右侧之后会让左侧销毁,当点击左侧的时候会进行重建,这显然不是我们想要的了。因此呢keep-alive便是关键了。
keep-alive:主要用于保留组件状态或避免重新渲染。
比如: 有一个列表页面和一个 详情页面,那么用户就会经常执行打开详情=>返回列表=>打开详情这样的话 列表 和 详情 都是一个频率很高的页面,那么就可以对列表组件使用<keep-alive></keep-alive>进行缓存,这样用户每次返回列表的时候,都能从缓存中快速渲染,而不是重新渲染。
当组件在 <keep-alive>
内被切换,它的 activated
和 deactivated
这两个生命周期钩子函数将会被对应执行。
<!-- 基本 --> <keep-alive> <component :is="view"></component> </keep-alive> <!-- 多个条件判断的子组件 --> <keep-alive> <comp-a v-if="a > 1"></comp-a> <comp-b v-else></comp-b> </keep-alive> <!-- 和 `<transition>` 一起使用 --> <transition> <keep-alive> <component :is="view"></component> </keep-alive> </transition>
include 和 exclude 属性允许组件有条件地缓存。二者都可以用逗号分隔字符串、正则表达式或一个数组来表示:
- include:字符串或正则表达式。只有匹配的组件会被缓存。
- exclude:字符串或正则表达式。任何匹配的组件都不会被缓存。
<!-- 逗号分隔字符串 --> <keep-alive include="a,b"> <component :is="view"></component> </keep-alive> <!-- 正则表达式 (使用 `v-bind`) --> <keep-alive :include="/a|b/"> <component :is="view"></component> </keep-alive> <!-- 数组 (使用 `v-bind`) --> <keep-alive :include="['a', 'b']"> <component :is="view"></component> </keep-alive>
匹配它的局部注册名称 (父组件 components 选项的键值)。
使用<keep-alive>会将数据保留在内存中,如果要在每次进入页面的时候获取最新的数据,需要在activated阶段获取数据,承担原来created钩子中获取数据的任务。
被包含在 <keep-alive> 中创建的组件,会多出两个生命周期的钩子: activated
与 deactivated
- activated:在组件被激活时调用,在组件第一次渲染时也会被调用,之后每次keep-alive激活时被调用。
- deactivated:在组件被停用时调用。
注意:只有组件被 keep-alive 包裹时,这两个生命周期才会被调用,如果作为正常组件使用,是不会被调用,以及在 2.1.0 版本之后,使用 exclude 排除之后,就算被包裹在 keep-alive 中,这两个钩子依然不会被调用!另外在服务端渲染时此钩子也不会被调用的。
什么时候获取数据?
当引入keep-alive 的时候,页面第一次进入,钩子的触发顺序created-> mounted-> activated,退出时触发deactivated。当再次进入(前进或者后退)时,只触发activated。
我们知道 keep-alive 之后页面模板第一次初始化解析变成HTML片段后,再次进入就不在重新解析而是读取内存中的数据,即,只有当数据变化时,才使用VirtualDOM进行diff更新。故,页面进入的数据获取应该在activated中也放一份。数据下载完毕手动操作DOM的部分也应该在activated中执行才会生效。
所以,应该activated中留一份数据获取的代码,或者不要created部分,直接将created中的代码转移到activated中。
13、vue中如何编写可复用的组件?
在编写组件的时候,时刻考虑组件是否可复用是有好处的。一次性组件跟其他组件紧密耦合没关系,但是可复用组件一定要定义一个清晰的公开接口。
Vue.js组件 API 来自 三部分:prop、事件、slot:
14、vue的生命周期
vue组件经历从创建到销毁的过程。其中要经历: 开始创建 —— 初始化 —— 模版编译 —— 挂载与渲染 —— 更新与渲染 —— 卸载销毁。
每一个过程对对应了一个生命周期钩子函数,我们可以在不同阶段去书写我们的代码
-
beforeCreate
: 此时还没有进行数据的观测和事件初始化 -
created
: 已经完成了数据观测,事件初始化完成,属性和方法的运算。但是$el还没有 -
beforeMount
: 相关的render函数首次被调用,去创建虚拟DOM,准备挂载到真实DOM上 -
mounted
: 自此DOM已经完全呈现了。可以访问$el。 -
beforeUpdate
: 数据更新的时候调用,虚拟DOM会被更新。这里适合在更新之前访问现有的 DOM,比如手动移除已添加的事件监听器 -
updated
: 数据更新完成。在beforeUpdate和updated之间进行的操作就是新旧虚拟DOM的patch过程和重新渲染的过程 -
beforeDestroy
: 实例销毁之前调用。在这一步,实例仍然完全可用。 -
destroyed
: Vue 实例销毁后调用。调用后,Vue 实例指示的所有东西都会解绑定,所有的事件监听器会被移除,所有的子实例也会被销毁。销毁的过程实在beforeDestroy和destroyed之间进行的。 -
errorCaptured
: 当捕获一个来自子孙组件的错误时被调用。此钩子会收到三个参数:错误对象、发生错误的组件实例以及一个包含错误来源信息的字符串。 -
activated
: keep-alive 组件激活时调用 -
deactivated
: keep-alive 组停用时调用
仔细分析上图,我们来叙述以下Vue的生命周期过程吧~
new Vue 之后,会先做一些初始化工作,这时候会通过依赖收集对数据进行观测、事件绑定,属性计算,beforeCreated和created是这一操作的前后。created之后就已经完成了这些操作,但是$el好没有。
接下来,检查vue配置,即new Vue{}里面的el项是否存在,有就继续检查template项。没有则等到手动绑定调用vm.$mount()。对template进行编译处理,得到render function。render function是产生虚拟DOM的关键。产生虚拟DOM后会将其转为真实DOM挂载到根结点上。beforeMounted和mounted就是这一操作的之前和之后。mounted之后我们就可以拿到真实的DOM了,这时候我们可以进行一些DOM的计算和操作。
组件更新,会产生一个新的虚拟DOM,会通过diff算法进行patch差异比对操作,最终更新我们的旧的虚拟DOM,从而更新我们的真实DOM。beforeUpdated和updated是这一操作的前后阶段。
- 注意: mounted、updated不会承诺所有的子组件也都一起被挂载。如果你希望等到整个视图都渲染完毕,可以用vm.$nextTick 替换掉mounted、updated:
updated: function () { this.$nextTick(function () { // DOM更新完毕之后调用 // Code that will run only after the // entire view has been re-rendered }) }
15、如何解决非工程化项目,网速慢时初始化页面闪动问题?
使用v-cloak
指令,v-cloak
不需要表达式,它会在Vue
实例结束编译时从绑定的HTML元素上移除,经常和CSS的display:none
配合使用。
<div id="app" v-cloak> {{message}} </div> <script> var app = new Vue({ el:"#app", data:{ message:"这是一段文本" } }) </script>
这时虽然已经加了指令v-cloak,但其实并没有起到任何作用,当网速较慢、Vue.js 文件还没加载完时,在页面上会显示{{message}}的字样,直到Vue创建实例、编译模版时,DOM才会被替换,所以这个过程屏幕是有闪动的。只要加一句CSS就可以解决这个问题了:(显示这一{{message}},其实就是在created到mounted之间出现的。)
[v-cloak]{ display:none; }
在一般情况下,v-cloak是一个解决初始化慢导致页面闪动的最佳实践,对于简单的项目很实用。可以隐藏未编译的 Mustache 标签直到实例准备完毕。
16、过滤器
{{ message | capitalize }}
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) })
// 两次过滤 {{ message | filterA | filterB }} // filterA 被定义为接收单个参数的过滤器函数,表达式 message 的值将作为参数传入到函数中。然后继续调用同样被定义为接收单个参数的过滤器函数 filterB,将 filterA 的结果传递到 filterB 中。
传入自定义的参数
{{ message | filterA('arg1', arg2) }}
十个常用过滤器:
//去除空格 type 1-所有空格 2-前后空格 3-前空格 4-后空格 function trim(value, trim) { switch (trim) { case 1: return value.replace(/\s+/g, ""); case 2: return value.replace(/(^\s*)|(\s*$)/g, ""); case 3: return value.replace(/(^\s*)/g, ""); case 4: return value.replace(/(\s*$)/g, ""); default: return value; } } //任意格式日期处理 //使用格式: // {{ '2018-09-14 01:05' | formaDate(yyyy-MM-dd hh:mm:ss) }} // {{ '2018-09-14 01:05' | formaDate(yyyy-MM-dd) }} // {{ '2018-09-14 01:05' | formaDate(MM/dd) }} 等 function formaDate(value, fmt) { var date = new Date(value); var o = { "M+": date.getMonth() + 1, //月份 "d+": date.getDate(), //日 "h+": date.getHours(), //小时 "m+": date.getMinutes(), //分 "s+": date.getSeconds(), //秒 "w+": date.getDay(), //星期 "q+": Math.floor((date.getMonth() + 3) / 3), //季度 "S": date.getMilliseconds() //毫秒 }; if (/(y+)/.test(fmt)) fmt = fmt.replace(RegExp.$1, (date.getFullYear() + "").substr(4 - RegExp.$1.length)); for (var k in o) { if(k === 'w+') { if(o[k] === 0) { fmt = fmt.replace('w', '周日'); }else if(o[k] === 1) { fmt = fmt.replace('w', '周一'); }else if(o[k] === 2) { fmt = fmt.replace('w', '周二'); }else if(o[k] === 3) { fmt = fmt.replace('w', '周三'); }else if(o[k] === 4) { fmt = fmt.replace('w', '周四'); }else if(o[k] === 5) { fmt = fmt.replace('w', '周五'); }else if(o[k] === 6) { fmt = fmt.replace('w', '周六'); } }else if (new RegExp("(" + k + ")").test(fmt)) { fmt = fmt.replace(RegExp.$1, (RegExp.$1.length == 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k]).length))); } } return fmt; } //字母大小写切换 /*type 1:首字母大写 2:首页母小写 3:大小写转换 4:全部大写 5:全部小写 * */ function changeCase(str, type) { function ToggleCase(str) { var itemText = "" str.split("").forEach( function (item) { if (/^([a-z]+)/.test(item)) { itemText += item.toUpperCase(); } else if (/^([A-Z]+)/.test(item)) { itemText += item.toLowerCase(); } else { itemText += item; } }); return itemText; } switch (type) { case 1: return str.replace(/\b\w+\b/g, function (word) { return word.substring(0, 1).toUpperCase() + word.substring(1).toLowerCase(); }); case 2: return str.replace(/\b\w+\b/g, function (word) { return word.substring(0, 1).toLowerCase() + word.substring(1).toUpperCase(); }); case 3: return ToggleCase(str); case 4: return str.toUpperCase(); case 5: return str.toLowerCase(); default: return str; } } //字符串循环复制,count->次数 function repeatStr(str, count) { var text = ''; for (var i = 0; i < count; i++) { text += str; } return text; } //字符串替换 function replaceAll(str, AFindText, ARepText) { raRegExp = new RegExp(AFindText, "g"); return str.replace(raRegExp, ARepText); } //字符替换*,隐藏手机号或者身份证号等 //replaceStr(字符串,字符格式, 替换方式,替换的字符(默认*)) //ecDo.replaceStr('18819322663',[3,5,3],0) //result:188*****663 //ecDo.replaceStr('asdasdasdaa',[3,5,3],1) //result:***asdas*** //ecDo.replaceStr('1asd88465asdwqe3',[5],0) //result:*****8465asdwqe3 //ecDo.replaceStr('1asd88465asdwqe3',[5],1,'+') //result:"1asd88465as+++++" function replaceStr(str, regArr, type, ARepText) { var regtext = '', Reg = null, replaceText = ARepText || '*'; //repeatStr是在上面定义过的(字符串循环复制),大家注意哦 if (regArr.length === 3 && type === 0) { regtext = '(\\w{' + regArr[0] + '})\\w{' + regArr[1] + '}(\\w{' + regArr[2] + '})' Reg = new RegExp(regtext); var replaceCount = this.repeatStr(replaceText, regArr[1]); return str.replace(Reg, '$1' + replaceCount + '$2') } else if (regArr.length === 3 && type === 1) { regtext = '\\w{' + regArr[0] + '}(\\w{' + regArr[1] + '})\\w{' + regArr[2] + '}' Reg = new RegExp(regtext); var replaceCount1 = this.repeatStr(replaceText, regArr[0]); var replaceCount2 = this.repeatStr(replaceText, regArr[2]); return str.replace(Reg, replaceCount1 + '$1' + replaceCount2) } else if (regArr.length === 1 && type === 0) { regtext = '(^\\w{' + regArr[0] + '})' Reg = new RegExp(regtext); var replaceCount = this.repeatStr(replaceText, regArr[0]); return str.replace(Reg, replaceCount) } else if (regArr.length === 1 && type === 1) { regtext = '(\\w{' + regArr[0] + '}$)' Reg = new RegExp(regtext); var replaceCount = this.repeatStr(replaceText, regArr[0]); return str.replace(Reg, replaceCount) } } //格式化处理字符串 //ecDo.formatText('1234asda567asd890') //result:"12,34a,sda,567,asd,890" //ecDo.formatText('1234asda567asd890',4,' ') //result:"1 234a sda5 67as d890" //ecDo.formatText('1234asda567asd890',4,'-') //result:"1-234a-sda5-67as-d890" function formatText(str, size, delimiter) { var _size = size || 3, _delimiter = delimiter || ','; var regText = '\\B(?=(\\w{' + _size + '})+(?!\\w))'; var reg = new RegExp(regText, 'g'); return str.replace(reg, _delimiter); } //现金额大写转换函数 //ecDo.upDigit(168752632) //result:"人民币壹亿陆仟捌佰柒拾伍万贰仟陆佰叁拾贰元整" //ecDo.upDigit(1682) //result:"人民币壹仟陆佰捌拾贰元整" //ecDo.upDigit(-1693) //result:"欠人民币壹仟陆佰玖拾叁元整" function upDigit(n) { var fraction = ['角', '分', '厘']; var digit = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']; var unit = [ ['元', '万', '亿'], ['', '拾', '佰', '仟'] ]; var head = n < 0 ? '欠人民币' : '人民币'; n = Math.abs(n); var s = ''; for (var i = 0; i < fraction.length; i++) { s += (digit[Math.floor(n * 10 * Math.pow(10, i)) % 10] + fraction[i]).replace(/零./, ''); } s = s || '整'; n = Math.floor(n); for (var i = 0; i < unit[0].length && n > 0; i++) { var p = ''; for (var j = 0; j < unit[1].length && n > 0; j++) { p = digit[n % 10] + unit[1][j] + p; n = Math.floor(n / 10); } s = p.replace(/(零.)*零$/, '').replace(/^$/, '零') + unit[0][i] + s; //s = p + unit[0][i] + s; } return head + s.replace(/(零.)*零元/, '元').replace(/(零.)+/g, '零').replace(/^整$/, '零元整'); } //保留2位小数 function toDecimal2(x){ var f = parseFloat(x); if (isNaN(f)) { return false; } var f = Math.round(x * 100) / 100; var s = f.toString(); var rs = s.indexOf('.'); if (rs < 0) { rs = s.length; s += '.'; } while (s.length <= rs + 2) { s += '0'; } return s; } export{ trim, changeCase, repeatStr, replaceAll, replaceStr, checkPwd, formatText, upDigit, toDecimal2, formaDate }
// 找 filter/filter.js import * as filters from './filter/filter.js' //遍历所有导出的过滤器并添加到全局过滤器 Object.keys(filters).forEach((key) => { Vue.filter(key, filters[key]); })
17、单页面应用的理解
单页面应用SPA的缺点:
1、首次加载耗时长
2、SEO问题严重,不利于搜索引擎的查找
3、前进、后退、地址栏、书签等,都需要程序进行管理,页面的复杂度很高
其中前两者是他的最主要问题。
未完待续~~,接下篇
原文地址:https://segmentfault.com/a/1190000019633325