前言
最近拿到一套高级前端的Vue面试题,从头往下看了一遍,居然大部分都是一知半解的,遂准备一道一道的认真学习总结,立志做一位Vue高级开发者!
看一下你是否也对上面的34个问题一知半解,如果和我差不多的话就来一起学习吧!
如何理解MVVM原理?
提到MVVM,很多前端开发者都会想到Vue的双向绑定,然而它们并不能划等号,MVVM是一种软件架构模式,而Vue只是一种在前端层面上的实现,其实不单在Vue里,在很多Web 框架应用里都有相关的实现。MVVM模式到底是什么呢?要说到MVVM这种模式,则必须要提及另一种大多数开发者都能耳熟能详的模式,就是MVC模式。
什么是MVC?
在前几年,前后端完全分离开之前,很多很火的后端框架都会说自己是支持MVC模式,像JAVA的SpringMVC、PHP的smarty、Nodejs的express和Koa,那么MVC的模式到底是什么样的?先看看下面这张经典的MVC模型图,Model(模型)、View(视图)、 Controller(控制器)相互依赖关系的三部分组成模型。
认识一下这三部分具体是指什么。
Model
这里的Model在MVC中实际是数据模型的概念,可以把它当成从数据库里查出来后的一条数据,或者是将查询出来的元数据经过裁剪或者处理后的一个特定数据模型结构。
View
View是视图,是将数据内容呈现给用户肉眼的界面层,View层最终会将数据模型下的信息,渲染显示成人类能易于识别感知的部分。
Controller
Controller是数据模型与View之间的桥梁层,实际界面层的各种变化都要经过它来控制,而且像用户从界面提交的数据也会经过Controller的组装检查生成数据模型,然后改变数据库里的数据内容。
MVC的使用
像接触过MVC框架的同学就知道,如果想把数据从数据库里渲染到页面上,先要查询完数据库后,将拿到的元数据进行一些处理,一般会删掉无用的字段,或者进行多个数据模型间的数据聚合,然后再给到页面模板引擎(ejs,Thymeleaf等)进行数据组装,最后组合完成进行渲染后生成HTML格式文件供浏览器展示使用。
像前面提到的各大支持MVC模式的Web开发框架,在前后端彻底分离之后就很少再提了。因为前端独立开发发布,实际相对原来的MVC模式是少了View这一层,这也让新的概念Restful出现在我们的视野里,很多新的框架又开始支持提供这种前端控制轻量级模式下的适配方案。
但是前后端分离的出现后,MVC就此没有了吗?当然不是。实际对于MVC模式没有特别明确的概念,在前后端分离之后可以尝试从不同的角度去看。可以理解整个系统在原先的MVC基础上View层进行细化,把整个前端项目当成一个View层,也可以从前端视角去看,Restful接口返回的Json数据当成一个数据模型,作为MVC的Model层,而前端Javascript自身对数据的处理是Contrller层,真正的页面渲染结果是View层。
下面以前端视角下的MVC模式中举个例子,接口返回的数据Model模型与View页面之间由Controller连接,来完成系统中的数据展示。
<!--view-->
<html>
...
<div>
<span id="name"></span>
<div id="data"></div>
</div>
...
</html>
<script>
// 生成model数据模型
function getDataApi() {
// 模拟接口返回
return {
name: 'mvc',
data: 'mvc 数据信息'
}
}
// controller控制逻辑
function pageController() {
const result = getDataApi();
document.getElementById('name').innerText = `姓名:${result.name}`;
document.getElementById('data').innerText = result.data;
}
</script>
什么是MVVM?
随着前端对于控制逻辑的越来越轻量,MVVM模式作为MVC模式的一种补充出现了,万变不离其宗,最终的目的都是将Model里的数据展示在View视图上,而MVVM相比于MVC则将前端开发者所要控制的逻辑做到更加符合轻量级的要求。
ViewModel
在Model和View之间多了叫做View-Model的一层,将模型与视图做了一层绑定关系,在理想情况下,数据模型返回什么试图就应该展示什么,看看下面这个例子。
<!--view页面-->
<html>
...
<div>
<span vm-bind-key="name"></span>
<div vm-bind-key="data"></div>
</div>
...
</html>
<script>
// 生成model数据模型
function getDataApi() {
// 模拟接口返回
return {
name: 'mvc',
data: 'mvc 数据信息'
}
}
// ViewModel控制逻辑
function pageViewModel() {
const result = getDataApi();
return result;
}
</script>
上面作为理想情况下例子,在ViewModel引入之后,视图完全由接口返回数据驱动,由开发者所控制的逻辑非常轻量。不过这里要说明的是,在MVVM模式下,Controller控制逻辑并非就没了,像操作页面DOM响应的逻辑被SDK(如Vue的内部封装实现)统一实现了,像不操作接口返回的数据是因为服务端在数据返回给前端前已经操作好了。
例子里pageViewModel函数的实现是非常关键的一步,如何将数据模型与页面视图绑定起来呢?在目前的前端领域里有三类实现,Angularjs的主动轮询检查新旧值变化更新视图、Vue利用ES5的Object.defineProperty的getter/setter方法绑定、backbone的发布订阅模式,从主动和被动的方式去实现了ViewModel的关系绑定,接下来主要看看Vue中的MVVM的实现。
Vue2.0中的MVVM实现
Vue2.0的MVVM实现中,对View-Model的实现本质利用的ES5的Object.defineProperty方法,当Object.defineProperty方法在给数据Model对象定义属性的时候先挂载一些方法,在这些方法里实现与界面的值绑定响应关系,当应用的属性被读取或者写入的时候便会触发这些方法,从而达到数据模型里的值发生变化时同步响应到页面上。
Vue的响应式原理
// html
<body>
<div>
<span>{{name}}</span>
<span>{{data}}</span>
</div>
<body>
//js
<script src="vue.js"></script>
<script>
// 生成model数据模型
function getDataApi() {
// 模拟接口返回
return {
name: 'mvc',
data: 'mvc 数据信息'
}
}
new Vue({
el: 'body',
data() {
return {
name:'',
data: '',
}
},
mounted() {
const result = getDataApi();
this.name = result.name;
this.data = result.data;
}
})
</script>
当new Vue在实例化的时候,首先将data方法里返回的对象属性都挂载上setter方法,而setter方法里将页面上的属性进行绑定,当页面加载时,浏览器提供的DOMContentloaded事件触发后,调用mounted挂载函数,开始获取接口数据,获取完成后给data里属性赋值,赋值的时候触发前面挂载好的setter方法,从而引起页面的联动,达到响应式效果。
简易实现Object.defineProperty下的绑定原理
// html
<body>
<span id="name"></span>
<body>
<script>
var data = {
name: ''
};
// Data Bindings
Object.defineProperty(data, 'name', {
get: function(){},
set: function(newValue){ // 页面响应处理
document.getElementById('name').innerText = newValue
data.name = value
},
enumerable: true,
configurable: true
});
// 页面DOM listener
document.getElementById('name').onchange = function(e) {
data.name = e.target.value;
}
</script>
实现Vue3.0版本的MVVM
这里采用Vue3.0最新的实现方式,用Proxy和Reflect来替代Object.definePropertypry的方式。至于Vue3.0为何不再采用2.0中Object.defineProperty的原因,我会在后续详写,先来介绍一下ES6里的Proxy与Reflect。
Proxy
Proxy是ES6里的新构造函数,它的作用就是代理,简单理解为有一个对象,不想完全对外暴露出去,想做一层在原对象操作前的拦截、检查、代理,这时候你就要考虑Proxy了。
const myObj = {
_id: '我是myObj的ID',
name: 'mvvm',
age: 25
}
const myProxy = new Proxy(myObj, {
get(target, propKey) {
if (propKey === 'age') {
console.log('年龄很私密,禁止访问');
return '*';
}
return target[propKey];
},
set(target, propKey, value, receiver) {
if (propKey === '_id') {
console.log('id无权修改');
return;
}
target[propKey] = value + (receiver.time || '');
},
// setPrototypeOf(target, proto) {},
// apply(target, object, args) {},
// construct(target, args) {},
// defineProperty(target, propKey, propDesc) {},
// deleteProperty(target, propKey) {},
// has(target, propKey) {},
// ownKeys(target) {},
// isExtensible(target) {},
// preventExtensions(target) {},
// getOwnPropertyDescriptor(target, propKey) {},
// getPrototypeOf(target) {},
});
myProxy._id = 34;
// id无权修改
console.log(`age is: ${myProxy.age}`);
//年龄很私密,禁止访问
// age is: *
myProxy.name = 'my name is Proxy';
console.log(myProxy);
// { _id: '我是myObj的ID', name: 'my name is Proxy', age: 25}
const newObj = {
time: ` [${new Date()}]`,
};
// 原对象原型链赋值
Object.setPrototypeOf(myProxy, newObj);
myProxy.name = 'my name is newObj';
console.log(myProxy.name);
//my name is newObj [Thu Mar 19 2020 18:33:22 GMT+0800 (GMT+08:00)]
Reflect
Reflect是ES6里的新的对象,非构造函数,不能用new操作符。可以把它跟Math类比,Math是处理JS中数学问题的方法函数集合,Reflect是JS中对象操作方法函数集合,它暴露出来的方法与Object构造函数所带的静态方法大部分重合,实际功能也类似,Reflect的出现一部分原因是想让开发者不直接使用Object这一类语言层面上的方法,还有一部分原因也是为了完善一些功能。Reflect提供的方法还有一个特点,完全与Proxy构造函数里Hander参数对象中的钩子属性一一对应。
看下面一个改变对象原型的例子。
const myObj = {
_id: '我是myObj的ID',
name: 'mvvm',
age: 25
}
const myProxy = new Proxy(myObj, {
get(target, propKey) {
return target[propKey];
},
set(target, propKey, value, receiver) {
target[propKey] = value + (receiver.time || '');
},
setPrototypeOf(target, proto) {
if (proto.status === 'enable') {
Reflect.setPrototypeOf(target, proto);
return true;
}
return false;
},
});
const newObj = {
time: ` [${new Date()}]`,
status: 'sable'
};
// 原对象原型链赋值
const result1 = Reflect.setPrototypeOf(myProxy, {
time: ` [${new Date()}]`,
status: 'disable'
});
myProxy.name = 'first set name'
console.log(result1) //false
console.log(myProxy.name); //first set name
// 原对象原型链赋值
const result2 = Reflect.setPrototypeOf(myProxy, {
time: ` [${new Date()}]`,
status: 'enable'
});
myProxy.name = 'second set name'
console.log(result1) //true
console.log(myProxy.name); //second set name [Thu Mar 19 2020 19:43:59 GMT+0800 (GMT+08:00)]
/*当执行到这里时直接报错了*/
// 原对象原型链赋值
Object.setPrototypeOf(myProxy, {
time: ` [${new Date()}]`,
status: 'disable'
});
myProxy.name = 'third set name'
console.log(myProxy.name);
解释一下上面的这段代码,通过Reflec.setPrototypeOf方法修改原对象原型时,必须经过Proxy里hander的挂载的setPrototypeOf挂载函数,在挂载函数里进行条件proto.status是否是enable筛选后,再决定是否真正修改原对象myObj的原型,最后返回true或者false来告知外部原型是否修改成功。
这里还有一个关键点,就是在代码执行到原有的Object.setPrototypeOf方法时,程序则直接抛错,这其实也是Reflect出现的一个原因,即使现在ES5里的Object有同样的功能,但是Reflect实现的更友好,更适合开发者开发应用程序。
实现MVVM
接下来使用上面的Proxy和Reflect来实现MVVM,这里将data和Proxy输出到全局Window下,方便我们模拟数据双向联动的效果。
<!DOCTYPE html>
<html>
<div>
name: <input id="name" />
age: <input id="age" />
</div>
</html>
<script>
// 与页面绑定
const data = {
name: '',
age: 0
}
// 暴露到外部,便于查看效果
window.data = data;
window.myProxy = new Proxy(data, {
set(target, propKey, value) {
// 改变数据Model时修改页面
if (propKey === 'name') {
document.getElementById('name').value = value;
} else if (propKey === 'age') {
document.getElementById('age').value = value;
}
Reflect.set(...arguments);
},
});
// 页面变化改变Model内数据
document.getElementById('name').onchange = function(e) {
Reflect.set(data, 'name', e.target.value);
}
document.getElementById('age').onchange = function(e) {
Reflect.set(data, 'age', e.target.value);
}
</script>
先打印了data,然后模拟有异步数据过来,手动修改data里的数据window.myProxy.age=25,这时候页面上的age联动变化为25,再次打印了查看data。接下来在页面上手动输入name,输入完成后触发输入框的onchange事件后,再次查看data,此时model里的数据已经变化为最新的与页面保持一致的值。
那么Vue2.0升级到3.0为什么要从将双向绑定的实现从Object.definePropertypry变成ES6的Proxy来实现呢?
下面这段代码非常简单,编写过Vue的同学都能看懂它在干什么,但是你能准确的说出这段代码在第一秒,第二秒,第三秒页面上分别有什么变化吗?
<!DOCTYPE html>
<html>
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<body>
<div id="app">
<div>{{ list }}</div>
</div>
<script>
new Vue({
el: '#app',
data: {
list: [],
},
mounted() {
setTimeout(()=>{
this.list[0] = 3
}, 1000)
setTimeout(()=>{
this.list.length = 5
}, 2000)
setTimeout(()=>{
this.$set(this.list, this.list)
}, 3000)
}
})
</script>
</body>
</html>
大家最好能动手拷贝上面的代码,本地新建HTML文件保存后打开调试查看,我这里直接说一下结果。当执行这段代码后,页面在第一秒和第二秒无变化,直到第三秒时候才会发生变化,思考一下第一秒和第二秒改变了list的值,为什么Vue的双向绑定在这里失效了呢?围绕这个问题下面开始一步一步看看Vue的数据变化监听实现机制。
Vue2.0的数据变化监听
这里由浅入深的去看,先从要监听普通数据类型看起。
1、检测属性为基本数据类型
监听普通数据类型,即要监听的对象属性的值为非对象的五种基本类型变化,这里不直接看源码,每一步都自己手动的去实现,更加便于理解。
<!DOCTYPE html>
<html>
<div>
name: <input id="name" />
</div>
</html>
<script>
// 监听Model下的name属性,当name属性有变化时要引起页面id=name的响应变化
const model = {
name: 'vue',
};
// 利用Object.defineProperty创建一个监听器
function observe(obj) {
let val = obj.name;
Object.defineProperty(obj, 'name', {
get() {
return val;
},
set(newVal) {
// 当有新值设置时,执行setter
console.log(`name变化:从${val}到${newVal}`);
// 解析到页面
compile(newVal);
val = newVal;
}
})
}
// 解析器,将变化的数据响应到页面上
function compile(val) {
document.querySelector('#name').value = val;
}
// 调用监听器,对model开始监听
observe(model);
</script>
在控制台调试过程。
上面的代码在调试的时候,我先查看了model.name初始值后,进行了重新设置,可以引起setter函数的触发执行,从而页面达到响应式效果。
但是当给name属性赋值为对象类型后,再给新对象里插入key1一个属性后,接着改变这个key1的值,这时候页面并不能得到响应式触发。
所以上面的observe的实现中,当name是普通数据类型的时候监听没有问题,而要监听的内容是对象的变化里的时候,上面的写法就有问题了。
下面看看监听对象类型属性observe函数要怎么实现。
2、检测属性为对象类型
从上面的例子里,检测属性值为对象时,不能满足监听需求,接下来进一步改造observe监听函数,解决思路很简单,如果是对象,只需再一次将当前对象下的所有普通类型的监听变化即可,如果该对象下还有对象属性,继续监听就可以了,如果你对递归很熟,马上就知道该如何解决这个问题。
<!DOCTYPE html>
<html>
<div>
name: <input id="name" />
val: <input id="val" />
list: <input id="list" />
</div>
</html>
<script>
// 监听Model下的name属性,当name属性有变化时要引起页面id=name的响应变化
const model = {
name: 'vue',
data: {
val: 1
},
list: [1]
};
// 监听函数
function observe(obj) {
// 遍历所有属性,各自监听
Object.keys(obj).map(key => {
// 将object属性特殊处理
if (typeof obj[key] === 'object') {
// 是对象属性的再次监听
observe(obj[key]);
} else {
// 非对象属性的做监听
defineReactive(obj, key, obj[key]);
}
})
}
// 利用Object.defineProperty做对象属性的做监听
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
return val;
},
set(newVal) {
// 当有新值设置时,执行setter
console.log(`${key}变化:从${val}到${newVal}`);
if (Array.isArray(obj)) {
document.querySelector(`#list`).value = newVal;
} else {
document.querySelector(`#${key}`).value = newVal;
}
val = newVal;
// 新增的属性再次进行监听
observe(newVal);
}
})
}
// 监听model下的所有属性
observe(model);
</script>
在控制台调试过程。
在上面的实际操作中,我先改变了属性name的值,触发了setter,页面收到响应,再次改变了model.data这个对象下的val属性,页面也得到响应式变化,这说明我们在之前是想observe监听不到对象属性变化的问题在上面的改造下得到了解决。
接下来要注意,在最后我改变了数组属性list下的第一个下标里的值为5,页面也得到了监听结果,但是我改变了第二个下标后,没有触发setter,接着特意去改变list的length,或者push都没有触发数组的setter,页面没有变化响应。
这里抛出两个问题:
a、我修改了数组list的第二个下标的值,并且调用length、push改变数组list后页面也没有响应到变化,是怎么回事?
b、回到文章开始示例的那一段Vue代码里的实现,我改变了Vue的data下list的下标属性值,页面是没有响应变化的,但是这里我改了list的内的值从1到5,页面响应了,这又是怎么回事?
请带着a、b两个问题继续往下看。
3、检测属性为数组对象类型
这里分析一下a问题修改数组下标的值和调用length、push方法改变数组时不触发监听器的setter函数的原因。我之前看到很多文章写Object.defineProperty不能监听到数组内的值变化,真的是这样么?
请看下面的例子,这里不绑定页面,只观察Object.defineProperty监听的数组元素,是否能监听到变化。
从上面代码里,首先监听了model数组里所有的属性,然后通过各种数组的方法来修改当前数组,得出以下几个结论。
- *直接修改数组中已有的元素是可以被监听的。
- *数组的操作方法如果是操作已经存在的被监听的元素也是可以触发setter被监听的。
- *只有push、length、pop一些特殊的方法确实不能触发setter,这跟方法的内部实现与Object.defineProperty的setter钩子的触发实现有关系,是语言层面的原因。
- *改变超过数组长度的下标的值时,值变化是不能监听到的。这个其实很好理解,不存在的属性当然是不能监听到,因为绑定监听操作在之前已经执行过了,后添加的元素属性在绑定当时都还没有存在,当然没有办法提前去监听它了。
所以综上,Object.defineProperty不能监听到数组内的值变化的说法是错误的,同时也得出了a问题的答案,语言层面不支持用Object.defineProperty监听不存在的数组元素,并且通过一些能造成数组的方法造成数组改变也不能监听到。
4、探究Vue源码,看数组的监听如何实现
对于b问题,则需要去看看Vue的源码里,为何Object.defineProperty明明能监听到数组值的变化,而它却没有实现呢?
这里分享一下我看源码的技巧,如果直接打开github一行一行看看源码是很懵逼的,我这里是直接用Vue-cli在本地生成一个Vue项目,然后在安装的node_modules下的Vue包里进行断点查看的,大家可以尝试下。
测试代码很简单,如下;
import Vue from './node_modules/_vue@2.6.11@vue/dist/vue.runtime.common.dev'
// 实例化Vue,启动起来后直接
new Vue({
data () {
return {
list: [1, 3]
}
},
})
解释一下这一块儿的源码,下面的hasProto的源码是看是否有原型存在,arrayMethods是被重写的数组方法,代码流程是如果有原型,直接修改原型上的push,pop,shift,unshift,splice, sort,reverse七个方法,如果没有原型的情况下,走copyAugment去新增这七个属性后赋值这七个方法,并没有监听。
/**
* Observe a list of Array items.
*/
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
// 监听数组元素
observe(items[i])
}
}
最后就是this.observeArray函数了,它的内部实现非常简单,它对数组元素进行了监听,什么意思呢,就是改变数组里的元素不能监听到,但是数组内的值是对象类型的,修改它依旧能得到监听响应,如改变list[0].val可以得到监听,但是改变list[0]不能,但是依旧没有对数组本身的变化进行监听。
再看看arrayMethods是如何重写数组的操作方法的。
// 记录原始Array未重写之前的API原型方法
const arrayProto = Array.prototype
// 拷贝一份上面的原型出来
const arrayMethods = Object.create(arrayProto)
// 将要重写的方法
const methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ]
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function (method) {
def(arrayMethods, method, function mutator (...args) {
// 原有的数组方法调用执行
const result = arrayProto[method].apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
// 如果是插入的数据,将其再次监听起来
if (inserted) ob.observeArray(inserted)
// 触发订阅,像页面更新响应就在这里触发
ob.dep.notify()
return result
})
})
从上面的源码里可以完整的看到了Vue2.x中重写数组方法的思路,重写之后的数组会在每次在执行数组的原始方法之后手动触发响应页面的效果。
看完源码后,问题a也水落石出了,Vue2.x中并没有实现将已存在的数组元素做监听,而是去监听造成数组变化的方法,触发这个方法的同时去调用挂载好的响应页面方法,达到页面响应式的效果。
但是也请注意并非所有的数组方法都重新写了一遍,只有push,pop,shift,unshift,splice, sort,reverse这七个。至于为什么不用Object.defineProperty去监听数组中已存在的元素变化。
作者尤雨溪的考虑是因为性能原因,给每一个数组元素绑定上监听,实际消耗很大,而受益并不大。
Vue3.0的数据变化监听
上面说了Vue3.0的监听采用的是ES6新的构造方法Proxy来代理原对象做变化检测,(对于Proxy不熟的同学可以翻看上一篇内容)而Proxy作为代理的存在,当异步触发Model里的数据变化时,必须经过Proxy这一层,在这一层则可以监听数组以及各种数据类型的变化,看看下面的例子。
简直完美,无论是数组下标赋值引起变化还是数组方法引起变化,都可以被监听到,而且既可以避开监听数组每个属性下造成的性能问题,还可以解决像pop、push方法,length方法改变数组时监听不到数组变化的问题。
接下来使用Proxy和Reflect实现Vue3.0下的双向绑定。
<!DOCTYPE html>
<html>
<div>
name: <input id="name" />
val: <input id="val" />
list: <input id="list" />
</div>
</html>
<script>
let model = {
name: 'vue',
data: {
val: 1,
},
list: [1]
}
function isObj (obj) {
return typeof obj === 'object';
}
// 监控器
function observe(data) {
// 将属性都做监控
Object.keys(data).map(key => {
if (isObj(data[key])) {
// 对象类型的继续监听它的属性
data[key] = observe(data[key]);
}
})
return defineProxy(data);
}
// 生成Proxy代理
function defineProxy(obj) {
return new Proxy(obj, {
set(obj, key, val) {
console.log(`属性${key}变化为${val}`);
compile(obj, key, val);
return Reflect.set(...arguments);
}
})
}
// 解析器,响应页面变化
function compile(obj, id, val) {
if (Array.isArray(obj)) { // 数组变化
document.querySelector('#list').value = model.list;
} else {
document.querySelector(`#${id}`).value = val;
}
}
model= observe(model);
</script>
利用Proxy和Reflect实现之后,不用在考虑数组的操作是否触发setter,只要操作经过proxy代理层,各种操作都会被被捕获到,达到页面响应式的要求。
在Vue2.x中数组变化监听的问题,其实不是Object.definePropertype方法监听不到,而是为了性能和收益比例综合考虑之下,改变了监听方式,从原本的直接监听结果变化这种思路变换到监听会导致结果变化的方法上,也就上面所提到的对数组的重写。
而Vue3.0中利用Proxy的方式则完美解决了2.0中出现的问题,所以以后面试中如果遇到Vue中对于数组监听的处理的时候,一定要分清楚是哪一个版本。