回答面试题的套路
-
先说这个点的明确定义,或者是特性;
-
再说具体的应用场景;
-
说说自己的看法、观点;
-
可以稍微举一反三,说说同类特性,或者类似的框架,更好的方案。
一、常考-基础点
1. 对SPA单页面的理解,优缺点是什么?
2. new Vue() 发生了什么?
3. Vue.use 是干什么的?原理是什么?
4. 请说一下响应式数据的原理?
5. Vue 如何检测数组变化?
6. Vue.set 方法是如何实现的?
7. Vue 中模板编译原理?
8. Proxy 与 Object.defineProperty优劣对比
9. Vue3.x 响应式数据原理?
二、常考-生命周期
1. Vue 的生命周期方法有哪些?一般在哪一步发起请求及原因?
2. 生命周期钩子是如何实现的?
3. Vue 的父组件和子组件生命周期钩子执行顺序?
三、常考-组件通信
1. Vue 中的组件的 data 为什么是一个函数?
2. Vue 组件通信有哪几种方式?
3. 组件中写 name 选项有哪些好处及作用?
4. keep-alive 平时在哪里使用?原理是?
5. Vue.minxin 的使用场景和原理?
四、常考-路由
1. Vue-router 有几种钩子函数?具体是什么及执行流程是怎样的?
2. Vue-router 两种模式的区别?
五、常考-属性作用与对比
1. nextTick 在哪里使用?原理是什么?
2. Vue 为什么需要虚拟 DOM?虚拟 DOM 的优劣如何?
3. Vue 中 key 的作用及原理,说说你对它的理解?
4. Vue 中 diff 的原理?
5. v-if 和 v-for 的优先级
6. v-if 和 v-show 的区别
7. computed 和 watch 的区别和运用的场景?
8. 如何理解自定义指令?
9. V-model的原理是什么?
六、常考-性能优化
正文从这里开始~
一、常考-基础点
核心答案:
SPA(single-page application) 仅在 Web 页面初始化时加载相应的 HTML、JavaScript、和CSS,一旦页面加载完成,SPA不会再因为用户的操作而进行页面的重新加载或跳转;取而代之的是利用路由机制实现页面 UI 的变换,从而实现与用户交互,避免页面的重新加载。
优点:
- 用户体验好,页面切换速度快,因为改动内容不用重新加载整个页面,页面切换效果可以很酷炫;
- 实现前后端分离,架构清晰,职责分明;
- SPA 对服务器压力较小。
缺点:
- 首次加载速度慢,因为需要首次在加载页面时将 JavaScript、CSS 统一加载;
- 不利于 SEO:因为所有内容都在一个页面中动态替换显示,所以在 SEO 上有着天然的弱势。
- 结论:new Vue() 是创建 Vue 实例,内部执行了根实例的初始化过程。
- 主要包括以下步骤:
- 合并选项 options;
- $children, $refs, $createElement, $slots 等实例属性方法的初始化;
- 注册用户自定义的事件;
- 数据响应式处理;
- 生命周期钩子调用(beforeCreate created);
- 如果传入参数存在 el 属性,就去挂载对应节点。
- 总结:new Vue() 创建根实例,并准备好数据和方法,未来执行挂载时,此过程还是递归的应用于它的子组件上,最终形成一个有紧密关系的组件数。
Vue.use 是用来注册插件的,我们可以在插件中注册全局组件、指令、原型方法等。
- 检查插件是否已注册,若已注册,直接跳出;
- 处理入参,将第一个参数之后的参数归集,并在首部插入 this 上下文;
- 执行注册方法,调用插件定义的 install 方法,传入处理后的参数,若没有 install 方法并且插件本身为 function 则直接进行注册;
- 缓存插件。
核心答案:
根据数据类型来做不同处理,对数组和对象类型进行劫持并绑定依赖,当数据变化时触发依赖更新视图。
-
对象内部通过defineReactive方法,使用 Object.defineProperty() 监听数据属性的 get 来进行数据依赖收集,再通过 set 来完成数据更新的派发;
-
数组则通过重写数组方法来实现的。扩展它的 7 个变更⽅法,通过监听这些方法可以做到依赖收集和派发更新;( push/pop/shift/unshift/splice/reverse/sort )
这里在回答时可以带出一些相关知识点 (比如多层对象是通过递归来实现劫持,顺带提出vue3中是使用 proxy来实现响应式数据)
补充回答:
内部依赖收集是怎么做到的,每个属性都拥有自己的dep属性,存放他所依赖的 watcher,当属性变化后会通知自己对应的 watcher去更新。
响应式流程:
-
defineReactive 把数据定义成响应式的;
-
给属性增加一个 dep,用来收集对应的那些watcher;
-
等数据变化进行更新
dep.depend() // get 取值:进行依赖收集
dep.notify() // set 设置时:通知视图更新
这里可以引出性能优化相关的内容:1)对象层级过深,性能就会差。2)不需要响应数据的内容不要放在data中。3)object.freeze() 可以冻结数据。
源码地址:src/core/observer/index.js 158
核心答案:
数组考虑性能原因没有用 defineProperty 对数组的每一项进行拦截,而是选择重写数组方法,当数组调用到这7个方法时,执行 ob.dep.notify() 进行派发通知 Watcher 更新;
- 重写数组方法:push, pop, shift, unshift, splice, reverse, sort
补充回答:
在 Vue 中修改数组长度和通过下标修改数组值时是无法监听到的,需要通过以上7中变异方法才会触发数组对应的 Watcher 进行更新,数组中如果是对象数据类型也会递归进行劫持。
说明:如果想要改索引更新数组怎么办?
可以通过 Vue.set() 来进行数据更新,内部核心用的是 splice() 方法:
// 取出数组原型
const arrayProto = Array.prototype;
// 拷贝原型
export const arrayMethods = Object.create(arrayProto);
// 重写数组方法
def(arrayMethods, method, function mutator (...args) {});
// 调用方法时通知视图
ob.dep.notify();
源码地址:src/core/observer/array.js
核心答案:
为什么 $set 能触发更新,因为我们给对象增加了 dep 属性,当通过 $set 给数组或对象添加不存在的属性时,就会触发依赖的 Watcher 去更新,当修改数组索引时我们调用重写的 splice 方法去更新数组。
补充回答:
官网定义 Vue.set(obj, key, val)
- 如果是数组,会调用重写的splice方法(这样可以更新视图)
代码:target.splice(key, 1, val) - 如果 target 本身不是响应式,那么久不需要定义成响应式
- 如果是对象,就会调用 defineReactive(ob.value, key, val) 将属性定义成响应式的
- 通知视图更新 op.dep.notify()
源码地址:src/core/observer/index.js
核心答案:
如何将template转换成render函数(这里要注意的是我们在开发时尽量不要使用template,因为将template转化成render方法需要在运行时进行编译操作会有性能损耗,同时引用带有complier包的vue体积也会变大) 默认.vue文件中的 template处理是通过vue-loader 来进行处理的并不是通过运行时的编译。
-
将 template 模板转换成 ast 语法树 - parserHTML
-
对静态语法做静态标记 - markUp
-
重新生成代码 - codeGen
补充回答:
模板引擎的实现原理就是new Function + with来进行实现的。
vue-loader中处理template属性主要靠的是 vue-template-compiler
vue-loader
// template => ast => codegen => with+function 实现生成render方法
let {ast, render } = VueTemplateCompiler.compile(`<div>{{aaa}}</div>`)
console.log(ast, render)
// 模板引擎的实现原理 with + new Function
console.log(new Function(render).tostring())
// render方法执行完毕后生成的是虚拟 dom
// with(this){return _c('div',[_s(aaa)])}
// 代码生成
源码设置:
const ast = parse(template.trim(), options) // 将代码解析成ast语法树
if (options.optimize !== false) {
optimize(ast, options) // 优化代码 标记静态点 标记树
}
const code = generate(ast, options) // 生成代码
源码地址:src/compiler/index.js
8. Proxy 与 Object.defineProperty优劣对比
核心答案:
Proxy 的优势如下:
1)Proxy 可以直接监听对象而非属性;
2)Proxy 可以直接监听数组的变化;
3)Proxy 有多达 13 种拦截方法,不限于 apply、ownKeys、deleteProperty、has 等等是 Object.defineProperty 不具备的;
4)Proxy 返回的是一个新对象,我们可以只操作新的对象达到目的,而 Object.defineProperty 只能遍历对象属性直接修改;
5)Proxy 作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利;
Object.defineProperty 的优势如下:
兼容性好,支持 IE9,而 Proxy 的存在浏览器兼容性问题,而且无法用 polyfill 磨平,因此 Vue 的作者才声明需要等到下个大版本( 3.0 )才能用 Proxy 重写。
核心答案:
Vue3.x改用Proxy替代Object.defineProperty。因为Proxy可以直接监听对象和数组的变化,并且有多达13种拦截方法。并且作为新标准将受到浏览器厂商重点持续的性能优化。
Proxy只会代理对象的第一层,那么Vue3又是怎样处理这个问题的呢?
判断当前Reflect.get的返回值是否为Object,如果是则再通过reactive方法做代理, 这样就实现了深度观测。
监测数组的时候可能触发多次get/set,那么如何防止触发多次呢?
我们可以判断key是否为当前被代理对象target自身属性,也可以判断旧值与新值是否相等,只有满足以上两个条件之一时,才有可能执行trigger。
二、常考-生命周期
1. Vue 的生命周期方法有哪些?一般在哪一步发起请求及原因?
核心答案:
总共分为8个阶段:创建前后,载入前后,更新前后,销毁前后。
1. 创建前后
-
beforeCreate:在此阶段,Vue 实例还没有挂载元素 el 和数据对象 data 都为 undefined,还未初始化;
说明:在此阶段 data、methods、computed、watch 上的数据和方法都不能访问。 -
created:在此阶段进行了 Vue 实例进行了 data 的初始化,可以访问 data 了,但元素还没有挂载,不能访问 el;
说明:在此阶段可以进行一些初始化数据的获取,但还不能操作 DOM,如果想要操作 DOM,需要在 vm.$nextTick 方法中去访问。
2. 挂载前后
- beforeMount:此时 $el 和 data 都已经初始化了,进行了 DOM 渲染的初始化操作;
说明:在此阶段可以对数据进行更改,但不会触发 updated。 - mounted:Vue 实例挂载完成,DOM 渲染完成,
说明:在此阶段,真实 DOM 加载完成,数据完成双向绑定,可以访问到 DOM 节点,可以使用 $refs 对节点进行操作。
3. 更新前后
- beforeUpdate:响应式数据更新时调用,发生在虚拟 DOM 打补丁之前,适合在更新之前访问现有的 DOM,比如手动移除已添加的事件监听器;
说明:可以在当前阶段进行数据更改,不会造成重渲染。 - updated:虚拟 DOM 渲染完成之后触发,避免在此钩子函数中操作数据,防止死循环;
4. 销毁前后
- beforeDestroy:实例销毁前调用,此时实例还可以调用,this 能获取到实例,常用于销毁定时器、解绑事件等善后收尾工作;
- destroyed:当前阶段组件已被销毁,数据绑定被卸除,监听被移除,子组件实例也都被销毁;
补充回答:
第一次页面加载时会触发:beforeCreate, created, beforeMount, mounted。
-
created 实例已经创建完成,因为它是最早触发的原因可以进行一些数据,资源的请求。(服务器渲染支持created方法)
-
mounted 实例已经挂载完成,可以进行一些DOM操作。(接口请求)
源码地址:src/core/instance/lifecycle.js
核心答案:
Vue 生命周期钩子函数本质上就是回调函数,当创建组件实例的过程中会调用对应的钩子函数。
补充回答:
内部主要是使用callHook方法来调用对应的方法。核心是一个发布订阅模式,将钩子订阅好(内部采用数组的方式存储),在对应的阶段进行发布。
源码地址:src/core/util/options.js core/instance/lifecycle.js
核心答案:
第一次页面加载时,会触发beforeCreate、created、beforeMount、mounted这几个钩子函数。
渲染过程 :
父组件挂载完成一定是等所有子组件挂载完之后,才算是父组件挂载完成,所以子组件的 mounted 在父组件 mounted 之前
- 首次加载过程:
- 父 beforeCreate —> 父 created —> 父 beforeMount —> 子beforeCreate —> 子 created —> 子 beforeMount —> 子 mounted —> 父 mounted
- 子组件更新过程:
- 不影响父组件:子 beforeUpdate —> 子 updated
- 影响父组件:父beforeUpdate —> 子 beforeUpdate —> 子 updated —> 父 updated
- 父组件更新过程:
- 不影响子组件:父 beforeUpdate —> 父updated
- 影响子组件: 父beforeUpdate —> 子 beforeUpdate —> 子 updated —> 父 updated
- 销毁过程:
- 父 beforeDestroy —> 子 beforeDestroy —> 子 destroyed —> 父 destroyed
重要:父组件等待子组件完成后,才会执行自己对应完成的钩子。
三、常考-组件通信
组件中的data写成一个函数,数据以函数返回值形式定义,这样每复用一次组件,就会返回一份新的data,类似于给每个组件实例创建一个私有的数据空间,让各个组件实例维护各自的数据。而单纯的写成对象形式,就使得所有组件实例共用了一份data,就会造成一个变了全都会变的结果。
如果data是对象的话,对象属于引用类型,会影响到所有的实例。所以为了保证组件不同的实例之间data不冲突,data必须是一个函数。
源码地址:src/core/util/options
-
props / $emit 适用于 父子组件通信;
-
ref 与 $parent / $children 适用于 父子组件通信;
-
EventBus($emit / $on) 适用于 父子组件、隔代组件、兄弟组件;
-
$attrs / $listeners 适用于 隔代组件;
-
provide / inject 适用于 隔代组件;
祖先组件中通过 provider 来提供变量,然后在子孙组件中通过 inject 来注入变量。provide / inject API 主要解决了跨级组件间的通信问题,不过它的使用场景,主要是子组件获取上级组件的状态,跨级组件间建立了一种主动提供与依赖注入的关系。
-
Vuex 适用于 父子组件、隔代组件、兄弟组件
核心答案:
-
可以通过名字找到对应的组件 ( 递归组件 )
-
可以通过name属性实现缓存功能 (keep-alive)
-
可以通过name来识别组件 (跨级组件通信时非常重要)
Vue.extend = function () {
if(name) {
Sub.options.componentd[name] = Sub
}
}
源码地址:src/core/vdom/create-element.js
keep-alive 用于缓存组件,当数组满了的时候,采用最久未使用算法。
常用的两个属性 include / exclude,允许组件有条件的缓存。
采用 keep-alive 的组件,激活或失活时会走 activated / deactivated 生命周期钩子函数。
export default {
name: 'keep-alive',
abstract: true,
props: {
include: patternTypes, // 要缓存的有哪些
exclude: patternTypes, // 要排除的有哪些
max: [String, Number] //最大缓存数量
},
...
render() {
...
if (cache[key]) { // 通过key 找到缓存,获取实例
vnode.componentInstance = cache[key].componentInstance
// 将队列中旧的 key 删除,重新添加到队列的尾部
remove(keys, key)
keys.push(key)
} else {
cache[key] = vnode
keys.push(key)
// 删除最早缓存的
if (this.max && keys.length > parseInt(this.max)) {
pruneCacheEntry(cache, keys[0], keys, this._vnode)
}
}
vnode.data.keepAlive = true
}
}
核心答案:
Vue.minxin 的作用是抽离公共的业务逻辑,原理类似“对象的继承”,当组件初始化时,会调用 mergeOptions 方法进行合并,采用策略模式针对不同的属性进行合并,如果混入的数据和本身组件中的数据冲突,会采用“就近原则”以组件的数据为准。
补充回答:
minxin 中有很多缺陷:命名冲突、依赖问题、数据来源问题等,这里强调一下 minxin 的数据是不共享的。
源码地址:src/core/util/options.js
四、常考-路由
1. Vue-router 有几种钩子函数?具体是什么及执行流程是怎样的?
核心答案:
路由钩子的执行流程,钩子函数的种类:全局守卫、组件守卫、路由守卫。
钩子函数种类:
- 全局守卫:
beforeEach、afterEach、beforeResolve - 组件守卫:
beforeRouteEnter、beforeRouteLeave、beforeRouteUpdate - 路由守卫:
beforeEnter
执行流程:
- 导航被触发;
- 在即将失活的组件里调用离开守卫 beforeRouteLeave();
- 调用全局守卫 beforeEach;
- 在重用的组件里调用 beforeRouteUpdate 守卫;
- 在路由匹配规则里调用 beforeEnter;
- 解析异步路由组件;
- 在被激活的组件里调用 beforeRouteEnter;
- 调用全局的 beforeResolve 守卫 (2.5+);
- 导航被确认;
- 调用全局守卫 afterEach;
- 触发 DOM 更新;
- 用创建好的实例调用 beforeRouteEnter 守卫中传给 next 的回调函数。
注意各个守卫中 this 是否已赋值为当前组件实例;
核心答案:
Vue-router 有3种路由模式:hash、history、abstract
hash 模式: hash + onhashchange 事件
特点:hash 虽然在 URL 中,但是不在 HTTP 请求中;用来知道浏览器工作,对服务端安全无用,hash 不会重新加载页面;通过监听 hash(#) 的变化来执行 js 代码实现页面改变。
核心代码:
window.addEventListener("hashchange", function() {});
history 模式:history api + popState
HTML5 推出的 history api,由 pushState 记录操作历史,监听 popState 事件来监听到状态变更。
因为 只要刷新 这个url(www.ff.ff/jjkj/fdfd/fdf/fd)就会请求服务器,然而服务器上根本没有这个资源,所以就会报404,解决方案就 配置一下服务器端。
说明:
1)hash:使用 URL hash 值来作路由,兼容性好,支持所有浏览器;
2)history:依赖 HTML5 history api 和服务器配置;
3)abstract:主要用于 node.js 环境。
五、常考-属性作用与对比
核心答案:
nextTick 的回调是在下次 DOM 更新循环结束之后的延迟回调,在修改后使用这个方法,可以获取更新之后的 DOM。nextTick 内部使用了微任务和宏任务来实现延迟回调的(Promise、setImmediate、MessageChannel、setTimeout — Vue-2.5.17-beta.0)。
补充回答:
vue多次更新数据,最终会进行批处理更新。内部调用的就是nextTick实现了延迟更新,用户自定义的nextTick中的回调会被延迟到更新完成后调用,从而可以获取更新后的DOM。
2. Vue 为什么需要虚拟 DOM?虚拟 DOM 的优劣如何?
核心答案:
Virtual DOM 就是用 JS 对象来描述真实的 DOM,是对真实 DOM 的抽象,由于直接操作 DOM 性能低但是操作 JS 性能高,可以将 DOM 操作转化为 JS 对象操作,最终通过 diff 算法比对差异来更新真实 DOM(减少了对真实 DOM 的操作)。同时虚拟 DOM 不依赖平台环境从而也可以实现跨平台。
补充回答:
虚拟 DOM 的实现就是普通对象包含 tag、data、children 等属性真实节点的描述。(本质上就是在 JS 和 DOM 之间的一个缓存)
Vue2 的 Virtual DOM 借鉴了开源库 snabbdom 的实现。
Virtual DOM 映射到真实 DOM 需要经历 VNode 的 create、diff、patch 等阶段。
核心答案:
例如:v-for=“(item, itemIndex) in tabs” :key=“itemIndex”
key的作用主要是为了高效的更新虚拟DOM,其原理是vue在patch过程中通过key可以精准判断两个节点是否是同一个,从而避免频繁更新不同元素,使得整个patch过程更加高效,减少DOM操作量,提高性能。
补充回答:
1、若不设置key还可能在列表更新时引发一些隐蔽的bug
2、vue中在使用相同标签名元素的过渡切换时,也会使用到key属性,其目的也是为了让vue可以区分它们,否则vue只会替换其内部属性而不会触发过渡效果。
源码地址:src\core\vdom\patch.js - updateChildren()
核心答案:
vue的diff算法是平级比较,不考虑跨级比较的情况。内部采用深度递归的方式 + 双指针的方式进行比较。
补充回答:
-
先比较是否是相同节点
-
相同节点比较属性,并复用老节点
-
比较儿子节点,考虑老节点和新节点儿子的情况
-
优化比较:头头、尾尾、头尾、尾头
-
比对查找进行复用
Vue2 与 Vue3.x 的diff算法:
Vue2的核心Diff算法采用了双端比较的算法,同时从新旧children的两端开始进行比较,借助key值找到可复用的节点,再进行相关操作。
Vue3.x借鉴了ivi算法和 inferno算法,该算法中还运用了动态规划的思想求解最长递归子序列。(实际的实现可以结合Vue3.x源码看。)
源码地址:src/core/vdom/patch.js
核心答案:
1、v-for优先于v-if被解析
2、如果同时出现,每次渲染都会先执行循环再判断条件,无论如何循环都不可避免,浪费了性能
3、要避免出现这种情况,则在外层嵌套template,在这一层进行v-if判断,然后在内部进行v-for循环
4、如果条件出现在循环内部,可通过计算属性提前过滤掉那些不需要显示的项
源码地址:compiler/codegen/index.js
核心答案:
v-if 是真正的条件渲染,直到条件第一次变为真时,才会开始渲染。
v-show 不管初始条件是什么会渲染,并且只是简单地基于 CSS 的 “display” 属性进行切换。
注意:v-if 适用于不需要频繁切换条件的场景;v-show 则适用于需要非常频繁切换条件的场景。
7. computed 和 watch 的区别和运用的场景?
核心答案:
computed: 计算属性。依赖其它属性值,并且 computed 的值有缓存,只有它依赖的属性值发生改变,下一次获取 computed 的值时才会重新计算 computed 的值;
watch: 监听数据的变化。更多的是「观察」的作用,类似于某些数据的监听回调 ,每当监听的数据变化时都会执行回调进行后续操作;
运用场景:
1)当我们需要进行数值计算,并且依赖于其它数据时,应该使用 computed,因为可以利用 computed 的缓存特性,避免每次获取值时,都要重新计算;
2)当我们需要在数据变化时执行异步或开销较大的操作时,应该使用 watch,使用 watch 选项允许我们执行异步操作 ( 访问一个 API ),限制我们执行该操作的频率,并在我们得到最终结果前,设置中间状态。这些都是计算属性无法做到的。
核心答案:
指令的实现原理,可以从编译原理 =>代码生成=> 指令钩子实现进行概述
1、在生成 ast 语法树时,遇到指令会给当前元素添加directives属性
2、通过 genDirectives 生成指令代码
3、在patch前将指令的钩子提取到 cbs中,在patch过程中调用对应的钩子。
4、当执行指令对应钩子函数时,调用对应指令定义的方法
核心答案:
v-model本质就是一个语法糖,可以看成是value + input方法的语法糖。可以通过model属性的prop和event属性来进行自定义。原生的v-model,会根据标签的不同生成不同的事件和属性。
v-model 在内部为不同的输入元素使用不同的属性并抛出不同的事件:
1)text 和 textarea 元素使用 value 属性和 input 事件;
2)checkbox 和 radio 使用 checked 属性和 change 事件;
3)select 字段将 value 作为 prop 并将 change 作为事件。
六、常考-性能优化
1、你都做过哪些Vue的性能优化?( 统计后的结果 )
1)编码阶段
-
尽量减少data中的数据,data中的数据都会增加getter和setter,会收集对应的watcher;
-
如果需要使用v-for给每项元素绑定事件时使用事件代理;
-
SPA 页面采用keep-alive缓存组件;
-
在更多的情况下,使用v-if替代v-show;
-
key保证唯一;
-
使用路由懒加载、异步组件;
-
防抖、节流;
-
第三方模块按需导入;
-
长列表滚动到可视区域动态加载;
-
图片懒加载;
2)用户体验:
-
骨架屏;
-
PWA;
-
还可以使用缓存(客户端缓存、服务端缓存)优化、服务端开启gzip压缩等。
3)SEO 优化
-
预渲染;
-
服务端渲染 SSR;
4)打包优化
-
压缩代码;
-
Tree Shaking/Scope Hoisting;
-
使用 cdn 加载第三方模块;
-
多线程打包 happypack;
-
splitChunks 抽离公共文件;
-
sourceMap 优化;
说明:优化是个大工程,会涉及很多方面。
参考链接:
从源码角度-解读Vue常考面试题