一、什么是 MVVM?
Model–View–ViewModel (MVVM) 是一个软件架构设计模式。MVVM 源自于经典的 Model–View–Controller(MVC)模式 ,MVVM 的出现促进了前端开发与后端业务逻辑的分离,极大地提高了前端开发效率。
MVVM 的核心是 ViewModel 层,它就像是一个中转站(value converter),负责转换 Model 中的数据对象来让数据变得更容易管理和使用,该层向上与视图层进行双向数据绑定,向下与 Model 层通过接口请求进行数据交互,起呈上启下作用。如下图所示:
(1)View 层
View 是视图层,也就是用户界面。前端主要由 HTML 和 CSS 来构建 。
(2)Model 层
Model 是指数据模型,泛指后端进行的各种业务逻辑处理和数据操控,对于前端来说就是后端提供的 api 接口。
(3)ViewModel 层
ViewModel 是由前端开发人员组织生成和维护的视图数据层。在这一层,前端开发者对从后端获取的 Model 数据进行转换处理,做二次封装,以生成符合 View 层使用预期的视图数据模型。
需要注意的是 ViewModel 所封装出来的数据模型包括视图的状态和行为两部分,而 Model 层的数据模型是只包含状态的,比如页面的这一块展示什么,而页面加载进来时发生什么,点击这一块发生什么,这一块滚动时发生什么这些都属于视图行为(交互),视图状态和行为都封装在了 ViewModel 里。这样的封装使得 ViewModel 可以完整地去描述 View 层。
MVVM 框架实现了双向绑定,这样 ViewModel 的内容会实时展现在 View 层,前端开发者再也不必低效又麻烦地通过操纵 DOM 去更新视图,MVVM 框架已经把最脏最累的一块做好了,我们开发者只需要处理和维ViewModel更新数据视图就会自动得到相应更新。这样 View 层展现的不是 Model 层的数据,而是 ViewModel 的数据,由 ViewModel 负责与 Model 层交互,这就完全解耦了 View 层和 Model 层,这个解耦是至关重要的,它是前后端分离方案实施的重要一环。
我们以下通过一个 Vue 实例来说明 MVVM 的具体实现:
(1)View 层
<div id="app">
<p>{{ message }}</p>
<button v-on:click="showMessage()">Click me</button>
</div>
(2)ViewModel 层
var app = new Vue({
el: '#app',
data: { // 用于描述视图状态
message: 'Hello Vue!',
},
methods: { // 用于描述视图行为
showMessage() {
let vm = this;
alert(vm.message);
}
},
created() {
let vm = this;
// Ajax 获取 Model 层的数据
ajax({
url: '/your/server/data/api',
success(res) {
vm.message = res;
}
});
}
})
(3) Model 层
{
"url": "/your/server/data/api",
"res": {
"success": true,
"name": "LoveC",
"domain": "www.cnblogs.com"
}
}
二、Vue 是如何实现数据双向绑定的(响应式原理)?
Vue.js 的数据响应式原理是基于 JS 标准内置对象方法
Object.defineProperty()
方法来实现,该方法不兼容 IE8 及以下版本浏览器,这也是为什么 Vue.js 只能在这些版本之上的浏览器中才能运行的原因。
Vue 内部通过 Object.defineProperty
方法属性拦截的方式,把 data
对象里每个数据的读写转化成 getter/setter
,当数据变化时通知视图更新。
1、什么是MVVM数据双向绑定
Vue 数据双向绑定主要是指:数据变化更新视图,视图变化更新数据,如下图所示:
- 输入框内容变化时,
Data
中的数据同步变化。即View
=>Data
的变化。 Data
中的数据变化时,文本节点的内容同步变化。即Data
=>View
的变化。
其中,View
变化更新 Data
,可以通过事件监听的方式来实现。
所以我们本文主要讨论如何根据 Data
变化更新 View
。我们会通过实现以下 4 个步骤,来实现数据的双向绑定:
-
实现一个监听器 Observer:对数据对象进行遍历,包括子属性对象的属性,利用
Object.defineProperty()
对属性都加上setter
和getter
。这样的话,给这个对象的某个值赋值,就会触发setter
,那么就能监听数据变化。 -
实现一个解析器 Compile:解析
Vue
模板指令,将模板中的变量都替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,调用更新函数进行数据更新。 -
实现一个订阅者 Watcher:
Watcher
订阅者是Observer
和Compile
之间通信的桥梁 ,主要的任务是订阅Observer
中的属性值变化的消息,当收到属性值变化的消息时,触发解析器Compile
中对应的更新函数。 -
实现一个订阅器 Dep:订阅器采用 发布-订阅 设计模式,用来收集订阅者
Watcher
,对监听器Observer
和 订阅者Watcher
进行统一管理。
以上四个步骤的流程图表示如下:
三、Vue3.0中使用的Proxy 与 Object.defineProperty 优劣对比
Vue.js 3.0
使用proxy
方法实现响应式,两者类似,我们只需搞懂Object.defineProperty()
,proxy
也就差不多理解了。
Proxy 的优势如下:
- Proxy 可以直接监听对象而非属性;
- Proxy 可以直接监听数组的变化;
- Proxy 有多达 13 种拦截方法,不限于
apply
、ownKeys
、deleteProperty
、has
等等是Object.defineProperty
不具备的; - Proxy 返回的是一个新对象,我们可以只操作新的对象达到目的,而
Object.defineProperty
只能遍历对象属性直接修改; - Proxy 作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利;
Object.defineProperty 的优势如下:
- 兼容性好,支持 IE9,而 Proxy 的存在浏览器兼容性问题,而且无法用 polyfill 磨平,因此 Vue 的作者才声明需要等到下个大版本( 3.0 )才能用 Proxy 重写。
四、谈谈虚拟DOM
1、虚拟DOM的优缺点
优点:
- 保证性能下限: 框架的虚拟 DOM 需要适配任何上层 API 可能产生的操作,它的一些 DOM 操作的实现必须是普适的,所以它的性能并不是最优的;但是比起粗暴的 DOM 操作性能要好很多,因此框架的虚拟 DOM 至少可以保证在你不需要手动优化的情况下,依然可以提供还不错的性能,即保证性能的下限;
- 无需手动操作 DOM: 我们不再需要手动去操作 DOM,只需要写好 View-Model 的代码逻辑,框架会根据虚拟 DOM 和 数据双向绑定,帮我们以可预期的方式更新视图,极大提高我们的开发效率;
- 跨平台: 虚拟 DOM 本质上是 JavaScript 对象,而 DOM 与平台强相关,相比之下虚拟 DOM 可以进行更方便地跨平台操作,例如服务器渲染、weex 开发等等。
缺点:
- 无法进行极致优化: 虽然虚拟 DOM 合理的优化,足以应对绝大部分应用的性能需求,但在一些性能要求极高的应用中虚拟 DOM 无法进行针对性的极致优化。
2、虚拟 DOM 实现原理
虚拟 DOM 的实现原理主要包括以下 3 部分:
- 用 JS对象模拟真实 DOM 树,对真实 DOM 进行抽象;
- diff 算法 —— 比较两棵虚拟 DOM 树的差异;
- patch 算法 —— 将两个虚拟 DOM 对象的差异应用到真正的 DOM 树。
五、介绍一下Vuex ?
Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。每一个 Vuex 应用的核心就是 store
(仓库)。store
基本上就是一个容器,它包含着你的应用中大部分的状态 ( state
)。
(1)Vuex 的状态存储是响应式的。当 Vue 组件从 store
中读取状态的时候,若 store
中的状态发生变化,那么相应的组件也会相应地得到高效更新。
(2)改变 store 中的状态的唯一途径就是显式地提交 (commit) mutation。这样使得我们可以方便地跟踪每一个状态的变化。
主要包括以下几个模块:
State
:定义了应用状态的数据结构,可以在这里设置默认的初始状态。Getter
:允许组件从Store
中获取数据,mapGetters
辅助函数仅仅是将store
中的getter
映射到局部计算属性。Mutation
:是唯一更改store
中状态的方法,且必须是同步函数。Action
:用于提交mutation
,而不是直接变更状态,可以包含任意异步操作。Module
:允许将单一的store
拆分为多个store
且同时保存在单一的状态树中。
*
六、vue-router 路由模式有几种?
vue-router 有 3 种路由模式:hash
、history
、abstract
,对应的源码如下所示:
switch (mode) {
case 'history':
this.history = new HTML5History(this, options.base)
break
case 'hash':
this.history = new HashHistory(this, options.base, this.fallback)
break
case 'abstract':
this.history = new AbstractHistory(this, options.base)
break
default:
if (process.env.NODE_ENV !== 'production') {
assert(false, `invalid mode: ${mode}`)
}
}
其中,3 种路由模式的说明如下:
-
hash
(浏览器环境): 使用 URL hash 值来作路由。支持所有浏览器,包括不支持 HTML5 History Api 的浏览器; -
history
: 依赖 HTML5 History API 和服务器配置。具体可以查看 HTML5 History 模式; -
abstract
(Node.js环境) : 支持所有 JavaScript 运行环境,如 Node.js 服务器端。如果发现没有浏览器的 API,路由会自动强制进入这个模式。
七、能说下 vue-router 中常用的 hash 和 history 路由模式实现原理吗?
(1)hash 模式的实现原理
www.test.com/#/
就是 Hash URL
,当 #
后面的哈希值发生变化时,可以通过 hashchange
事件来监听到 URL
的变化,从而进行跳转页面,并且无论哈希值如何变化,服务端接收到的 URL
请求永远是 www.test.com
window.addEventListener('hashchange', () => {
// ... 具体逻辑
})
早期的前端路由的实现就是基于
location.hash
来实现的。其实现原理很简单,location.hash 的值就是 URL 中 # 后面的内容。比如下面这个网站,它的 location.hash 的值为 ‘#search’:
https://www.baidu.com#search
(2)history 模式的实现原理
History 模式是 HTML5 新推出的功能,主要使用history.pushState
和 history.replaceState
改变 URL。
这两个 API 可以在不进行刷新的情况下,操作浏览器的历史纪录。唯一不同的是,前者是新增一个历史记录,后者是直接替换当前的历史记录,如下所示:
window.history.pushState(null, null, path);
window.history.replaceState(null, null, path);
两种模式对比
Hash
模式只可以更改#
后面的内容,History
模式可以通过API
设置任意的同源URL
History
模式可以通过API
添加任意类型的数据到历史记录中,Hash
模式只能更改哈希值,也就是字符串Hash
模式无需后端配置,并且兼容性好。History 模式在用户手动输入地址或者刷新页面的时候会发起URL
请求,后端需要配置index.html
页面用于匹配不到静态资源的时候
八、Vue 组件间的通信方式?
Vue 组件间通信主要指以下 3 类通信:父子组件通信、隔代组件通信、兄弟组件通信。
(1)props
/ $emit
适用 父子组件通信
这种方法是 Vue 组件的基础,相信大部分同学耳闻能详,所以此处就不举例展开介绍。
(2)ref
与 $parent
/ $children
适用 父子组件通信
ref
:如果在普通的 DOM 元素上使用,引用指向的就是 DOM 元素;如果用在子组件上,引用就指向组件实例$parent
/$children
:访问父 / 子实例
(3)EventBus ($emit / $on)
适用于 父子、隔代、兄弟组件通信
这种方法通过一个空的 Vue 实例作为中央事件总线(事件中心),用它来触发事件和监听事件,从而实现任何组件间的通信,包括父子、隔代、兄弟组件。
实现简单的一个事件总线(发布——订阅模式)
JS实现时间总线的本质就是发布-订阅模式,达成任意组件间相互通信的作用。在一个地方触发(发布)事件,然后通过事件中心通知所有订阅者(订阅)。
class EventBus{
constructor() {
// 事件对象,存放订阅的名字和事件
this.events = {};
}
// 订阅事件的方法:监听(发布)
$on(eventName,callback) {
if (!this.events[eventName]) {
// 注意数据,一个名字可以订阅多个事件函数
this.events[eventName] = [callback];
} else {
// 存在则push到对应事件回调函数数组的尾部保存
this.events[eventName].push(callback)
}
}
// 触发事件的方法(订阅)
$emit(eventName, ...args) {
// 遍历执行所有订阅的事件
this.events[eventName] && this.events[eventName].forEach(cb => cb(...args));
}
}
//test
let eventbus = new EventBus();
eventbus.$on('sayHi', function (msg) {
console.log(msg)
});
eventbus.$emit('sayHi', Math.random())
(4)$attrs
/$listeners
适用于 隔代组件通信
-
$attrs
:包含了父作用域中不被prop
所识别 (且获取) 的特性绑定 (class
和style
除外 )。当一个组件没有声明任何prop
时,这里会包含所有父作用域的绑定 (class
和style
除外 ),并且可以通过v-bind="$attrs"
传入内部组件。通常配合inheritAttrs
选项一起使用。 -
$listeners
:包含了父作用域中的 (不含.native
修饰器的)v-on
事件监听器。它可以通过v-on="$listeners"
传入内部组件
(5)provide
/ inject
适用于 隔代组件通信
祖先组件中通过 provider
来提供变量,然后在子孙组件中通过 inject
来注入变量。
provide
/ inject
API 主要解决了跨级组件间的通信问题,不过它的使用场景,主要是子组件获取上级组件的状态,跨级组件间建立了一种主动提供与依赖注入的关系。
// 父组件 A
export default {
provide: {
data: 1
}
}
// 子组件 B
export default {
inject: ['data'],
mounted() {
// 无论跨几层都能获得父组件的 data 属性
console.log(this.data) // => 1
}
}
(6)Vuex 适用于 父子、隔代、兄弟组件通信
Vuex
是一个专为 Vue.js
应用程序开发的状态管理模式。每一个 Vuex
应用的核心就是 store
(仓库)。store
基本上就是一个容器,它包含着你的应用中大部分的状态 ( state
)。
- Vuex 的状态存储是响应式的。当 Vue 组件从
store
中读取状态的时候,若store
中的状态发生变化,那么相应的组件也会相应地得到高效更新。 - 改变
store
中的状态的唯一途径就是显式地提交(commit) mutation
。这样使得我们可以方便地跟踪每一个状态的变化。
九、v-show 与 v-if 有什么区别?
- v-show 只是在
display: none
和display: block
之间切换。无论初始条件是什么都会被渲染出来,后面只需要切换 CSS,DOM 还是一直保留着的。所以总的来说 v-show 在初始渲染时有更高的开销,但是切换开销很小,更适合于频繁切换的场景。 - v-if 的话就得说到 Vue 底层的编译了。当属性初始为
false
时,组件就不会被渲染,直到条件为true
,并且切换条件时会触发销毁/挂载组件,所以总的来说在切换时开销更高,更适合不经常切换的场景。 - 并且基于 v-if 的这种惰性渲染机制,可以在必要的时候才去渲染组件,减少整个页面的初始渲染开销。
总结:
(1)两者都会导致页面的重绘和重排,但v-show
只是改变dom
的css
,而v-if
控制的是添加和删除dom
,所以v-if
在重绘重排前还进行了添加或删除dom
元素的操作。
(2)需要多次切换某个元素的显示或隐藏时使用v-show
(3)某个元素在渲染后就一直存在或隐藏时使用v-if
(4)opacity
也可以隐藏元素,但它本身的作用并非用来隐藏元素而是设置元素的透明度,并且opacity
为0
时,该dom
同样占用着空间。
补充:display:none、visibility:hidden、opacity:0
区别
1、display: none;
DOM
结构:浏览器不会渲染display
属性为none
的元素,不占据空间;- 事件监听:无法进行
DOM
事件监听; - 性能:动态改变此属性时会引起重排,性能较差;
- 继承:不会被子元素继承,毕竟子类也不会被渲染;
transition:transition
不支持display
。
2、visibility: hidden;
DOM
结构:元素被隐藏,但是会被渲染不会消失,占据空间;- 事件监听:无法进行
DOM
事件监听; - 性 能:动态改变此属性时会引起重绘,性能较高;
- 继 承:会被子元素继承,子元素可以通过设置
visibility: visible;
来取消隐藏; transition:visibility
会立即显示,隐藏时会延时
opacity: 0;
DOM
结构:透明度为 100%,元素隐藏,占据空间;- 事件监听:可以进行
DOM
事件监听; - 性 能:提升为合成层,不会触发重绘,性能较高;
- 继 承:会被子元素继承,且,子元素并不能通过
opacity: 1
来取消隐藏; transition:opacity
可以延时显示和隐藏
十、computed 和 watch 的区别和运用的场景?
1、区别
computed
是计算属性,依赖其它属性值,并且computed
的值有缓存,只有它依赖的属性值发生改变,下一次获取computed
的值时才会重新计算computed
的值;watch
更多的是「观察」的作用,类似于某些数据的监听回调 。监听到值的变化就会执行回调,在回调中可以进行一些逻辑操作。
2、运用场景
-
当我们需要进行数值计算,并且依赖于其它数据时,应该使用
computed
,因为可以利用computed
的缓存特性,避免每次获取值时,都要重新计算; -
对于监听到值的变化需要做一些复杂业务逻辑(执行异步或开销较大的操作)的情况可以使用
watch
。使用watch
选项允许我们执行异步操作 ( 访问一个 API ),限制我们执行该操作的频率,并在我们得到最终结果前,设置中间状态。这些都是计算属性无法做到的。
十一、谈谈对 keep-alive 的了解?
keep-alive
是 Vue 内置的一个组件,如果你需要在组件切换的时候,保存一些组件的状态防止多次渲染,就可以使用 keep-alive 组件包裹需要保存的组件,其有以下特性:
-
一般结合路由和动态组件一起使用,用于缓存组件;
-
提供
include
和exclude
属性,两者都支持字符串或正则表达式,include
表示只有名称匹配的组件会被缓存,exclude
表示任何名称匹配的组件都不会被缓存 ,其中exclude
的优先级比include
高; -
keep-alive
拥有两个独有的生命周期钩子函数activated
和deactivated
,当组件被激活时,触发钩子函数activated
,当组件被移除时,触发钩子函数deactivated
。
十二、组件中 data 为什么是一个函数?
为什么组件中的
data
必须是一个函数,然后return
一个对象,而new Vue
实例里,data
可以直接是一个对象?
// data
data() {
return {
message: "子组件",
childName:this.name
}
}
// new Vue
new Vue({
el: '#app',
router,
template: '<App/>',
components: {App}
})
-
组件复用时所有组件实例都会共享
data
,如果data
是对象的话,那么这样作用域没有隔离,就会造成一个组件修改data
以后会影响到其他所有组件,所以如果组件中data
选项是一个函数,那么每个实例可以维护一份被返回对象的独立的拷贝,组件实例之间的data
属性值不会互相影响; -
当我们使用
new Vue()
的方式的时候,无论我们将data
设置为对象还是函数都是可以的,因为new Vue()
的方式是生成一个根组件,该组件不会复用,也就不存在共享 data 的情况了。
十三、Vue 中的 key 有什么作用?
key
是为 Vue
中 vnode
的唯一标记,通过这个 key
,我们的 diff
操作可以更准确、更快速。
Vue
的 diff
过程可以概括为:oldCh
和 newCh
各有两个头尾的变量 oldStartIndex
、oldEndIndex
和 newStartIndex
、newEndIndex
,它们会新节点和旧节点会进行两两对比,即一共有4种比较方式:newStartIndex 和oldStartIndex 、newEndIndex 和 oldEndIndex 、newStartIndex 和 oldEndIndex 、newEndIndex 和 oldStartIndex,如果以上 4 种比较都没匹配,如果设置了key
,就会用 key
再进行比较,在比较的过程中,遍历会往中间靠,一旦 StartIdx
> EndIdx
表明 oldCh
和 newCh
至少有一个已经遍历完了,就会结束比较。
更准确:因为带 key
就不是就地复用了,在 sameNode
函数 a.key === b.key
对比中可以避免就地复用的情况。所以会更加准确。
更快速:利用 key
的唯一性生成 map
对象来获取对应节点,比遍历方式更快,源码如下:
function createKeyToOldIdx (children, beginIdx, endIdx) {
let i, key
const map = {}
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key
if (isDef(key)) map[key] = i
}
return map
}
十四、Vue.nextTick实现原理
在DOM更新完毕之后执行一个回调,用法如下:
watch: {
playing(newPlaying) {
const audio = this.$refs.audio
//DOM 还没更新
this.$nextTick(() => {
// DOM 更新了:再进行歌曲的操作
newPlaying ? audio.play() : audio.pause()
})
}
}
nextTick
的目的就是产生一个回调函数加入task
或者microtask
中,当前栈执行完以后(可能中间还有别的排在前面的函数)调用该回调函数,起到了异步触发(即下一个tick
时触发)的目的。
nextTick
的实现比较简单,执行的目的是在microtask
或者task
中推入一个function
,在当前栈执行完毕(也许还会有一些排在前面的需要执行的任务)以后执行nextTick
传入的function
。
一共有
Promise
、MutationObserver(H5)
以及setTimeout
三种尝试得到timerFunc
( 一个函数指针,指向函数将被推送到任务队列中,等到主线程任务执行完时,任务队列中的timerFunc被调用)的方法。
优先使用Promise,在Promise不存在的情况下使用MutationObserver,这两个方法的回调函数都会在microtask中执行,它们会比setTimeout更早执行,所以优先使用。 如果上述两种方法都不支持的环境则会使用setTimeout,在task尾部推入这个函数,等待调用执行。
总结:vue的nextTick方法的实现原理:
vue
用异步队列的方式来控制DOM
更新和nextTick
回调先后执行microtask
因为其高优先级特性,能确保队列中的微任务在一次事件循环前被执行完毕- 因为兼容性问题,
vue
不得不做microtask
向macrotask
的降级方案
源码解读:
Vue.js异步更新DOM策略及nextTick
参考文章
30 道 Vue 面试题,内含详细讲解(涵盖入门到精通,自测 Vue 掌握程度)
0 到 1 掌握:Vue 核心之数据双向绑定