面试题 1 . 简述Vue的MVVM 模式?
参考回答:
MVVM 是 Model-View-ViewModel的缩写,即将数据模型与数据表现层通过数据驱动进行分离,从而只需要关系数据模型的开发,而不需要考虑页面的表现,具体说来如下:Model代表数据模型:主要用于定义数据和操作的业务逻辑。View代表页面展示组件(即dom展现形式):负责将数据模型转化成UI 展现出来。ViewModel为model和view之间的桥梁:监听模型数据的改变和控制视图行为、处理用户交互。通过双向数据绑定把 View 层和 Model 层连接了起来,而View 和 Model 之间的同步工作完全是自动的,无需人为干涉在MVVM架构下,View 和 Model 之间并没有直接的联系,而是通过ViewModel进行交互,Model 和 ViewModel 之间的交互是双向的, 因此View 数据的变化会同步到Model中,而Model 数据的变化也会立即反应到View 上。
1:Model(模型)
模型是指代表真实状态内容的领域模型(面向对象),或指代表内容的数据访问层(以数据为中心)。
2:View(视图)
就像在MVC和MVP模式中一样,视图是用户在屏幕上看到的结构、布局和外观(UI)。
3:ViewModel(视图模型)
视图模型是暴露公共属性和命令的视图的抽象。MVVM没有MVC模式的控制器,也没有MVP模式的
presenter,有的是一个绑定器。在视图模型中,绑定器在视图和数据绑定器之间进行通信。
MVVM 优点:
低耦合 :View可以独立于Model变化和修改,一个ViewModel可以绑定到不同的View上,当View变化
的时候Model可以不变,当Model变化的时候View也可以不变。
可重用性 : 可以把一些视图逻辑放在一个ViewModel里面,让很多View重用这段视图逻辑。
独立开发 : 开发人员可以专注于业务逻辑和数据的开发,设计人员可以专注于页面的设计
面试题 2 . 请简述Vue插件和组件的区别 ?
参考回答:
一、组件是什么
(1)组件的定义:
组件就是把图形、非图形的各种逻辑均抽象为一个统一的概念(组件)来实现开发的模式,在Vue中每一个.vue文件都可以视为一个组件
(2)组件的优势:
降低整个系统的耦合度,在保持接口不变的情况下,我们可以替换不同的组件快速完成需求,例如输入框,可以替换为日历、时间、范围等组件作具体的实现调试方便,由于整个系统是通过组件组合起来的,在出现问题的时候,可以用排除法直接移除组件,或者根据报错的组件快速定位问题,之所以能够快速定位,是因为每个组件之间低耦合,职责单一,所以逻辑会比分析整个系统要简单提高可维护性,由于每个组件的职责单一,并且组件在系统中是被复用的,所以对代码进行优化可获得系统的整体升级
二、插件是什么
插件通常用来为 Vue 添加全局功能。插件的功能范围没有严格的限制——一般有下面几种:
添加全局方法或者属性。如: vue-custom-element
添加全局资源:指令/过滤器/过渡等。如 vue-touch
通过全局混入来添加一些组件选项。如vue-router
添加 Vue 实例方法,通过把它们添加到 Vue.prototype 上实现。
一个库,提供自己的 API,同时提供上面提到的一个或多个功能。如vue-router
三、两者的区别
两者的区别主要表现在以下几个方面:
(1)编写形式
(2)注册形式
(3)使用场景
(4)编写形式
(5)编写组件
编写一个组件,可以有很多方式,我们最常见的就是vue单文件的这种格式,每一个.vue文件我们都可以看成是一个组件
vue文件标准格式
<br />
<template><br />
</template><br />
<script><br />
export default{ <br />
...<br />
}<br />
</script><br />
我们还可以通过template属性来编写一个组件,如果组件内容多,我们可以在外部定义template组件内容,如果组件内容并不多,我们可直接写在template属性上
<br />
<template id="testComponent"> // 组件显示的内容<br />
<div>component!</div> <br />
</template><br />
Vue.component('componentA',{
template: '#testComponent'
template: `
component
` // 组件内容少可以通过这种形式
})
四:编写插件
vue插件的实现应该暴露一个 install 方法。这个方法的第一个参数是 Vue 构造器,第二个参数是一个可选的选项对象
MyPlugin.install = function (Vue, options) {
// 1. 添加全局方法或 property
Vue.myGlobalMethod = function () {
// 逻辑...
}
// 2. 添加全局资源
Vue.directive('my-directive', {
bind (el, binding, vnode, oldVnode) {
// 逻辑...
}
...
})
// 3. 注入组件选项
Vue.mixin({
created: function () {
// 逻辑...
}
...
})
// 4. 添加实例方法
Vue.prototype.$myMethod = function (methodOptions) {
// 逻辑...
}
}
注册形式
组件注册
vue组件注册主要分为全局注册与局部注册
全局注册通过Vue.component方法,第一个参数为组件的名称,第二个参数为传入的配置项
Vue.component('my-component-name', { /* ... */ })
局部注册只需在用到的地方通过components属性注册一个组件
const component1 = {...} // 定义一个组件
export default {
components:{
component1 // 局部注册
}
}
插件注册
插件的注册通过Vue.use()的方式进行注册(安装),第一个参数为插件的名字,第二个参数是可选择的配置项
Vue.use(插件名字,{ /* ... */} )
注意的是:
注册插件的时候,需要在调用 new Vue() 启动应用之前完成
Vue.use会自动阻止多次注册相同插件,只会注册一次
五:使用场景
具体的其实在插件是什么章节已经表述了,这里在总结一下
组件 (Component) 是用来构成你的 App 的业务模块,它的目标是 App.vue
插件 (Plugin) 是用来增强你的技术栈的功能模块,它的目标是 Vue 本身
简单来说,插件就是指对Vue的功能的增强或补充
面试题 3 . 简述MVC与MVVM的区别 ?
参考回答:
MVC和MVVM的区别并不是VM完全取代了C,ViewModel存在目的在于抽离Controller中展示的业务逻辑,而不是替代Controller,其它视图操作业务等还是应该放在Controller中实现。也就是说MVVM实现的是业务逻辑组件的重用。
1:MVC中Controller演变成MVVM中的ViewModel
2:MVVM通过数据来显示视图层而不是节点操作
3:MVVM主要解决了MVC中大量的dom操作使页面渲染性能降低,加载速度变慢,影响用户体验
面试题 4 . 简述Vue组件通讯有哪些方式 ?
参考回答:
Vue组件通讯有方式:
1、props 和 $emit 父组件向子组件传递数据是通过props传递的,子组件传递给父组件是通过$emit触发事件来做到的。
2、$parent 和 $children 获取单签组件的父组件和当前组件的子组件。
3、$attrs 和 $listeners A -> B -> C。Vue2.4开始提供了$attrs和$listeners来解决这个问题。
4、父组件中通过 provide 来提供变量,然后在子组件中通过 inject 来注入变量。(官方不推荐在实际业务中适用,但是写组件库时很常用。)
5、$refs 获取组件实例。
6、envetBus 兄弟组件数据传递,这种情况下可以使用事件总线的方式。
7、vuex 状态管理
面试题 5 . 简述Vue的生命周期方法有哪些?
参考回答:
Vue的生命周期方法:
1:beforeCreate 在实例初始化之后,数据观测(data observe)和 event/watcher 事件配置之前被调用。在当前阶段 data、methods、computed 以及 watch 上的数据和方法都不能被访问。
2:created 实例已经创建完成之后被调用。在这一步,实例已经完成以下的配置:数据观测(data observe ),属性和方法的运算,watch/event 事件回调。这里没有 $el,如果非要想与 DOM 进行交互,可以通过vm.$nextTick 来访问 DOM。
3:beforeMount 在挂载开始之前被调用:相关的 render 函数首次被调用。
4:mounted 在挂载完成后发生,在当前阶段,真实的 Dom 挂载完毕,数据完成双向绑定,可以访问到 Dom节点。
5:beforeUpdate 数据更新时调用,发生在虚拟 DOM 重新渲染和打补丁 (patch)之前。可以在这个钩子中进一步地更改状态,这不会触发附加的重渲染过程。(数据修改页面未修改)
6:updated 发生在更新完成之后,当前阶段组件 Dom 已经完成更新。要注意的是避免在此期间更新数据,因为这个可能导致无限循环的更新,该钩子在服务器渲染期间不被调用。
7:beforeDestroy 实例销毁之前调用。在这一步,实例仍然完全可用。我们可以在这时进行 善后收尾工作,比如清除定时器。
8:destroyed Vue实例销毁后调用。调用后,Vue实例指示的东西都会解绑定,所有的事件监听器会被移除,左右的子实例也会被销毁,该钩子在服务器端渲染不被调用。
9:activated keep-alive 专属,组件被激活时调用
10:deactivated keep-alive 专属,组件被销毁时调用
面试题 6 . 简述 Vue 有哪些内置指令 ?
参考回答:
vue 内置指令:
v-once - 定义它的元素或组件只渲染一次,包括元素或组件的所有节点,首次渲染后,不再随数据的变化重新渲染,将被视为静态内容。
v-cloak - 这个指令保持在元素上直到关联实例结束编译 -- 解决初始化慢到页面闪动的最佳实践。
v-bind - 绑定属性,动态更新HTML元素上的属性。例如 v-bind:class。
v-on - 用于监听DOM事件。例如 v-on:click v-on:keyup
v-html - 赋值就是变量的innerHTML -- 注意防止xss攻击
v-text - 更新元素的textContent
v-model - 1、在普通标签。变成value和input的语法糖,并且会处理拼音输入法的问题。2、再组件上。也是处理value和input语法糖。
v-if / v-else / v-else-if。可以配合template使用;在render函数里面就是三元表达式。
v-show - 使用指令来实现 -- 最终会通过display来进行显示隐藏
v-for - 循环指令编译出来的结果是 -L 代表渲染列表。优先级比v-if高最好不要一起使用,尽量使用计算属性去解决。注意增加唯一key值,不要使用index作为key。
v-pre - 跳过这个元素以及子元素的编译过程,以此来加快整个项目的编译速度。
面试题 7 . 简述怎样理解 Vue 的单项数据流 ?
参考回答:
Vue的单向数据流是指数据在Vue应用中的流动方向是单向的,数据总是从父组件传到子组件,子组件没有权利修改父组件传过来的数据,只能请求父组件对原始数据进行修改。这样会防止从子组件意外改变父组件的状态,从而导致你的应用的数据流向难以理解。
注意:在子组件直接用 v-model 绑定父组件传过来的 props 这样是不规范的写法,开发环境会报警告。
如果实在要改变父组件的 props 值可以再data里面定义一个变量,并用 prop 的值初始化它,之后用$emit 通知父组件去修改。
多种方法实现:在子组件直接用 v-model 绑定父组件传过来的 props
这种单向数据流的设计有以下几个优点:
1. 易于追踪数据流:由于数据的流动方向是单向的,我们可以很容易地追踪数据的来源和去向,减少了数据流动的复杂性,提高了代码的可读性和可维护性。
2. 提高组件的可复用性:通过props将数据传递给子组件,使得子组件可以独立于父组件进行开发和测试。这样一来,我们可以更方便地复用子组件,提高了组件的可复用性。
3. 避免数据的意外修改:由于子组件不能直接修改父组件的数据,可以避免数据被意外修改的情况发生。这样可以提高应用的稳定性和可靠性。
单向数据流也有一些限制和不足之处。例如,当数据需要在多个组件之间进行共享时,通过props传递数据会变得繁琐,这时可以考虑使用Vuex等状态管理工具来管理共享数据。单向数据流也可能导致组件之间的通信变得复杂,需要通过事件的方式进行数据传递和更新。
Vue的单向数据流是一种有助于提高应用可维护性和可预测性的设计模式,通过明确数据的流动方向,使得代码更易于理解和维护。
面试题 8 . 简述 v-model 双向绑定的原理是什么?
参考回答:
v-model 本质 就是 : value + input 方法的语法糖 。可以通过 model 属性的 prop 和 event 属性来进行自定义。原生的 v-model,会根据标签的不同生成不同的事件和属性。
例如:
1. text 和 textarea 元素使用 value 属性和 input 事件
2. checkbox 和 radio 使用 checked 属性和 change 事件
3. select 字段将 value 作为 prop 并将 change 作为事件
以输入框为例,当用户在输入框输入内容时,会触发 input 事件,从而更新 value。而 value 的改变同样会更新视图,这就是 vue 中的双向绑定。双向绑定的原理,其实现思路 如下:
首先要对 数据进行劫持监听 ,所以我们需要设置 一个监听器 Observe r,用来 监听 所有属 性。如果属性发上变化了,就需要告 诉订阅者 Watcher 看是否需要更新 。
因为订阅者是有很多个,所以我们需要有一个 消息订阅器 Dep 来专门收集这些订阅者 ,然后在监听器 Observer 和订阅者 Watcher 之间 进行统一管理的。
接着,我们还需要有一个 指令解析器 Compile ,对每个节点元素进 行扫描和解析 ,将相关指令对应初始化成一个订阅者 Watcher,并替换模板数据或者绑定相应的函数,此时当订阅者 Watcher 接收到相应属性的变化,就会执行对应的更新函数,从而更新视图。
因此接下去我们执行以 下 3 个步骤,实现数据的双向绑定 :
1. 实现一个 监听器 Observer ,用来 劫持并监听所有属 性,如果有变动的,就通知订阅者。
2. 实现一个 订阅者 Watcher ,可以 收到属性的变化通知并执 行相应的函数,从而更新视图。
3. 实现一个 解析器 Compile ,可以 扫描和解析每个 节点的相关指令,并根据 初始化模板数 据以及初始化相应的订阅器
面试题 9 . 简述 Vue 2.0 响应式数据的原理( 重点 )?
参考回答:
整体思路是 数据劫持 + 观察者模式
Vue 在初始化数据时 ,会使用 Object.defineProperty 重新定义 data 中的所有属性 ,当页面 使用对 应 属性时,首先会进行 依赖收集 (收集当前组件的 watcher ),如果属性 发生变化 会通知相关 依赖进行 更新操作( 发布订阅 )
Vue2.x 采用 数据劫持结合发布订阅模式 (PubSub 模式)的方式,通过 Object.defineProperty 来劫 持 各个属性 的 setter、getter ,在 数据变动时 发 布消息给订阅者 , 触发相应的监听回 调。
当把一个普通 Javascript 对象传给 Vue 实例来作为它的 data 选项时,Vue 将遍历它的属性,用
Object.defineProperty 将它们转为 getter/setter。用户看不到 getter/setter,但是在内部它们让
Vue 追踪依赖,在属性被访问和修改时 通知变化 。
Vue 的数据 双向绑定 整合了 Observer,Compile 和 Watcher 三者,通过 Observer 来监听 自己的
model 的数据变化,通过 Compile 来解析编 译模板指令,最终 利用 Watcher 搭 起 Observer 和
Compile 之间的 通信桥梁 ,达到数据变化->视图更新,视图交互变化(例如 input 操作)->数据
model 变更的双向绑定效果。
Vue3.x 放弃了 Object.defineProperty ,使用 ES6 原生的 Proxy,来解决以前使用
Object.defineProperty 所存在的一些问题。
1、Object.defineProperty 数据劫持
2、使用 getter 收集依赖 ,setter 通知 watcher派发更新。
3、watcher 发布订阅模式。
面试题 10 . 请简述vue-router 动态路由是什么?
参考回答:
我们经常需要把某种模式匹配到的所有路由,全都映射到同个组件。例如,我们有一个
User 组件,对于所有 ID 各不相同的用户,都要使用这个组件来渲染。那么,我们可以在 vue-router 的路由路径中使用 “ 动态路径参数 ” ( dynamic segment )来达到这个效果:
const User = {
template: "
User", };
const router = new VueRouter({
routes: [
// 动态路径参数 以冒号开头
{ path: "/user/:id", component: User },
],
});
面试题 11 . 请简述Vue3.x 响应式数据原理是什么?( 重点 )
参考回答:
Vuejs 作为在众多 MVVM(Model-View-ViewModel) 框架中脱颖而出的佼佼者,无疑是值得任何一个前端开发者去深度学习的。
不可置否尤大佬的 VueJs 中有许多值得我们深入研究的内容,但是作为最核心的数据响应式 Reactivity 模块正是我们日常工作中高端相关的内容同时也是 VueJs 中最核心的内容之一。
至于 Vuejs 中的响应式原理究竟有多重要,这里我就不必累赘了。相信大家都能理解它的重要性。
不过这里我想强调的是,所谓响应式原理本质上也是基于 Js 代码的升华实现而已。你也许会觉得它很难,但是这一切只是源于你对他的未知。毕竟只要是你熟悉的 JavaScript ,那么问题就不会很大对吧。
今天我们就让我们基于最新版 Vuejs 3.2 来稍微聊聊 VueJs 中核心模块 Reactive 是如何实现数据响应式的。
前置知识
ES6 Proxy & Reflect
Proxy 是 ES6 提供给我们对于原始对象进行劫持的 Api ,同样 Reflect 内置 Api 为我们提供了对于原始对象的拦截操作。
这里我们主要是用到他们的 get 、 set 陷阱。
Typescript
TypeScript 的作用不言而喻了,文中代码我会使用 TypeScript 来书写。
Esbuild
EsBuild 是一款新型 bundle build tools ,它内部使用 Go 对于我们的代码进行打包整合。
Pnpm
pnpm 是一款优秀的包管理工具,这里我们主要用它来实现 monorepo 。
如果你还没在你的电脑上安装过 pnpm ,那么请你跟随官网安装它很简单,只需要一行 npm install -g pnpm即可。
搭建环境
工欲善其事,必先利其器。在开始之前我们首先会构建一个简陋的开发环境,便于将我们的 TypeScript 构建成为 Iife 形式,提供给浏览器中直接使用。
因为文章主要针对于响应式部分内容进行梳理,构建环境并不是我们的重点。所以我并不会深入构建环境的搭建中为大家讲解这些细节。
如果你有兴趣,可以跟着我一起来搭建这个简单的组织结构。如果你并不想动手,没关系。我们的重点会放在在之后的代码。
初始化项目目录
首先我们创建一个简单的文件夹,命名为 vue 执行 pnpm init -y 初始化 package.json 。
接下来我们依次创建:
pnpm-workspace.yaml文件
这是一个有关 pnpm 实现 monorepo 的 yaml 配置文件,我们会在稍微填充它。
.npmrc文件
这是有关 npm 的配置信息存放文件。
packages/reactivity目录
我们会在这个目录下实现核心的响应式原理代码,上边我们提过 vue3 目录架构基于 monorepo 的结构,所以这是一个独立用于维护响应式相关的模块目录。
当然,每个 packages 下的内容可以看作一个独立的项目,所以它们我们在 reactivity 目录中执行 pnpm init -y 初始化自己的 package.json。
同样新建 packages/reactivity/src 作为 reactivity 模块下的文件源代码。
packages/share目录
同样,正如它的文件夹名称,这个目录下存放所有 vuejs 下的工具方法,分享给别的模块进行引入使用。
它需要和 reactivity 维护相同的目录结构。
scripts/build.js文件
我们需要额外新建一个 scripts 文件夹,同时新建 scripts/build.js 用于存放构建时候的脚本文件。
image.png
此时目录如图所示。
安装依赖
接下来我们来依次安装需要使用到的依赖环境,在开始安装依赖之前。我们先来填充对应的 .npmrc 文件:
shamefully-hoist = true
默认情况下 pnpm 安装的依赖是会解决幽灵依赖的问题,所谓什么是幽灵依赖你可以查看这篇文章。
这里我们配置 shamefully-hoist = true 意为我们需要第三方包中的依赖提升,也就是需要所谓的幽灵依赖。
这是因为我们会在之后引入源生 Vue 对比实现效果与它是否一致。
你可以在这里详细看到它的含义。
同时,接下里让我们在 pnpm-workspace.yaml 来填入以下代码:
packages:
# 所有在 packages/ 和 components/ 子目录下的 package
- 'packages/**'
# - 'components/**'
# 不包括在 test 文件夹下的 package
# - '!**/test/**'
因为基于 monorepo 的方式来组织包代码,所以我们需要告诉 pnpm 我们的 repo 工作目录。
这里我们指定了 packages/ 为 monorepo 工作目录,此时我们的 packages 下的每一个文件夹都会被 pnpm 认为是一个独立的项目。
接下来我们去安装所需要的依赖:
pnpm install -D typescript vue esbuild minimist -w
注意,这里 -w 意为 --workspace-root ,表示我们将依赖安装在顶层目录,所以包可以共享到这些依赖。
同时 minimist 是 node-optimist 的核心解析模块,它的主要作为即为解析执行 Node 脚本时的环境变量。
填充构建
接下来我们就来填充构建部分逻辑。
更改 package.json
首先,让我们切换到项目跟目录下对于整个 repo 的 pacakge.json 进行改造。 {
"name": "@vue",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "node ./scripts/dev.js reactivity -f global"
}
,
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"esbuild": "^0.14.27",
"typescript": "^4.6.2",
"vue": "^3.2.31"
}
}
首先我们将包名称修改为作用域,@vue 表示该包是一个组织包。
其次,我们修改 scripts 脚本。表示当运行 pnpm run dev 时会执行 ./scripts/dev.js 同时传入一个 reactivity 参数以及 -f 的 global 环境变量。
更改项目内 package.json
接下来我们需要更改每个 repop 内的 package.json(以下简称 pck) 。这里我们以 reactivity 模块为例,share 我就不重复讲解了。 {
"name": "@vue/reactive",
"version": "1.0.0",
"description": "",
"main": "index.js",
"buildOptions": {
"name": "VueReactivity",
"formats": [
"esm-bundler",
"esm-browser",
"cjs",
"global"
]
}
,
"keywords": [],
"author": "",
"license": "ISC"
}
首先,我们将 reactivity 包中的名称改为作用域名 @vue/reactive 。
其次我们为 pck 中添加了一些自定义配置,分别为:
buildOptions.name 该选项表示打包生成 IIFE 时,该模块挂载在全局下的变量名。
buildOptions.formats 该选项表示该模块打包时候需要输出的模块规范。
填充scripts/dev.js
之后,让我们切换到 scripts/dev.js 来实现打包逻辑:
// scripts/dev.js
const {
build
}
= require('esbuild');
const {
resolve
}
= require('path');
const argv = require('minimist')(process.argv.slice(2));
// 获取参数 minimist
const target = argv['_'];
const format = argv['f'];
const pkg = require(resolve(__dirname, '../packages/reactivity/package.json'));
const outputFormat = format.startsWith('global')
? 'iife'
: format.startsWith('cjs')
? 'cjs'
: 'esm';
// 打包输出文件
const outfile = resolve(
__dirname,
`../packages/$ {
target
}
/dist/$ {
target
}
.$ {
outputFormat
}
.js`
);
// 调用ESbuild的NodeApi执行打包
build( {
entryPoints: [resolve(__dirname, `../packages/$ {
target
}
/src/index.ts`)],
outfile,
bundle: true, // 将所有依赖打包进入
sourcemap: true, // 是否需要sourceMap
format: outputFormat, // 输出文件格式 IIFE、CJS、ESM
globalName: pkg.buildOptions?.name, // 打包后全局注册的变量名 IIFE下生效
platform: outputFormat === 'cjs' ? 'node' : 'browser', // 平台
watch: true, // 表示检测文件变动重新打包
}
);
脚本中的已经进行了详细的注释,这里我稍微在啰嗦一些。
其次整个流程看来像是这样,首先当我们运行 npm run dev 时,相当于执行了 node ./scripts/dev.js reactivity -f global。
所以在执行对应 dev.js 时,我们通过 minimist 获得对应的环境变量 target 和 format 表示我们本次打包分别需要打包的 package 和模式,当然你也可以通过 process.argv 自己截取。
之后我们通过判断如果传入的 -f 为 global 时将它变成 iife 模式,执行 esbuild 的 Node Api 进行打包对应的模块。
需要注意的是,ESbuild 默认支持 typescript 所以不需要任何额外处理。
当然,我们此时并没有在每个包中创建对应的入口文件。让我们分别创建两个 packages/reactivity/src/index.ts以及packages/share/src/index.ts作为入口文件。
此时,当你运行 npm run dev 时,会发现会生成打包后的js文件:
image.png
写在环境结尾的话
至此,针对于一个简易版 Vuejs 的项目构建流程我们已经初步实现了。如果有兴趣深入了解这个完整流程的同学可以自行查看对应 源码。
当然这种根据环境变量进行动态打包的思想,我在之前的React-Webpack5-TypeScript打造工程化多页面应用中详细讲解过这一思路,有兴趣的同学可以自行查阅。
其实关于构建思路我大可不必在这里展开,直接讲述响应式部分代码即可。但是这一流程在我的日常工作中的确帮助过我在多页面应用业务上进行了项目构建优化。
所以我觉得还是有必要拿出来和大家稍微聊一聊这一过程,希望大家在以后业务中遇到该类场景下可以结合 Vuejs 的构建思路来设计你的项目构建流程。
响应式原理
上边我们对于构建稍稍花费了一些篇幅,接下来终于我们要步入正题进行响应式原理部分了。
首先,在开始之前我会稍微强调一些。文章中的代码并不是一比一对照源码来实现响应式原理,但是实现思想以及实现过程是和源码没有出入的。
这是因为源码中拥有非常多的条件分支判断和错误处理,同时源码中也考虑了数组、Set、Map 之类的数据结构。
这里,我们仅仅先考虑基础的对象,至于其他数据类型我会在之后的文章中详细和大家一一道来。
同时我也会在每个步骤的结尾贴出对应的源代码地址,提供给大家参照源码进行对比阅读。
开始之前
在我们开始响应式原理之前,我想和大家稍微阐述下对应背景。因为可能有部分同学对应 Vue3 中的源码并不是很了解。
在 VueJs 中的存在一个核心的 Api Effect ,这个 Api 在 Vue 3.2 版本之后暴露给了开发者去调用,在3.2之前都是 Vuejs 内部方法并不提供给开发者使用。
简单来说我们所有模版(组件)最终都会被 effect 包裹 ,当数据发生变化时 Effect 会重新执行,所以 vuejs 中的响应式原理可以说是基于 effect 来实现的 。
当然这里你仅仅需要了解,最终组件是会编译成为一个个 effect ,当响应式数据改变时会触发 effect 函数重新执行从而更新渲染页面即可。
之后我们也会详细介绍 effect 和 响应式是如何关联到一起的。
基础目录结构
首先我们来创建一些基础的目录结构:
reactivity/src/index.ts 用于统一引入导出各个模块
reactivity/src/reactivity.ts 用于维护 reactive 相关 Api。
reactivity/src/effect.ts 用户维护 effect 相关 Api。
这一步我们首先在 reactivity 中新建对应的文件:
image.png
reactive 基础逻辑处理
接下来我们首先进入相关的 reactive.ts 中去。
思路梳理
关于 Vuejs 是如何实现数据响应式,简单来说它内部利用了 Proxy Api 进行了访问/设置数据时进行了劫持。
对于数据访问时,需要进行依赖收集。记录当前数据中依赖了哪些 Effect ,当进行数据修改时候同样会进行触发更新,重新执行当前数据依赖的 Effect。简单来说,这就是所谓的响应式原理。
关于 Effect 你可以暂时的将它理解成为一个函数,当数据改变函数(Effect)重新执行从而函数执行导致页面重新渲染。
Target 实现目标
在开始书写代码之前,我们先来看看它的用法。我们先来看看 reactive 方法究竟是如何搭配 effect 进行页面的更新:
不太了解 Effect 和响应式数据的同学可以将这段代码放在浏览器下执行试试看。
首先我们使用 reactive Api 创建了一个响应式数据 reactiveData 。
之后,我们创建了一个 effect,它会接受一个 fn 作为参数 。这个 effect 内部的逻辑非常简单:它将 id 为 app 元素的内容置为 reactiveData.name 的值。
注意,这个 effect 传入的 fn 中依赖了响应式数据 reactiveData 的 name 属性,这一步通常成为依赖收集。
当 effect 被创建时,fn 会被立即执行所以 app 元素会渲染对应的 19Qingfeng 。
当 0.5s 后 timer 达到时间,我们修改了 reactiveData 响应式数据的 name 属性,此时会触发改属性依赖的 effct 重新执行,这一步同样通常被称为触发更新。
所以页面上看起来的结果就是首先渲染出 19Qingfeng 在 0.5s 后由于响应式数据的改变导致 effect 重新执行所以修改了 app 的 innerHTML 导致页面重新渲染。
这就是一个非常简单且典型的响应式数据 Demo ,之后我们会一步一步基于结果来逆推实现这个逻辑。
基础 Reactive 方法实现
接下来我们先来实现一个基础版的 Reactive 方法,具体使用 API 你可以参照 这里。
上边我们提到过 VueJs 中针对于响应式数据本质上就是基于 Proxy & Reflect 对于数据的劫持,那么自然我们会想到这样的代码:
// reactivity/src/reactivity.ts
export function isPlainObj(value: any): value is object {
return typeof value === 'object' && value !== null;
}
const reactive = (obj) => {
// 传入非对象
if (!isPlainObj(obj)) {
return obj;
}
// 声明响应式数据
const proxy = new Proxy(obj, {
get() {
// dosomething
}
,
set() {
// dosomething
}
}
);
return proxy;
}
;
上边的代码非常简单,我们创建了一个 reactive 对象,它接受传入一个 Object 类型的对象。
我们会对于函数传入的 obj 进行校验,如果传入的是 object 类型那么会直接返回。
接下来,我们会根据传入的对象 obj 创建一个 proxy 代理对象。并且会在该代理对象上针对于 get 陷阱(访问对象属性时)以及 set (修改代理对象的值时)进行劫持从而实现一系列逻辑。
依赖收集
之前我们提到过针对于 reactive 的响应式数据会在触发 get 陷阱时会进行依赖收集。
这里你可以简单将依赖收集理解为记录当前数据被哪些Effect使用到,之后我们会一步一步来实现它。
// reactivity/src/reactivity.ts
export function isPlainObj(value: any): value is object {
return typeof value === 'object' && value !== null;
}
const reactive = (obj) => {
// 传入非对象
if (!isPlainObj(obj)) {
return obj;
}
// 声明响应式数据
const proxy = new Proxy(obj, {
get(target, key, receiver) {
// 依赖收集方法 track
track(target, 'get', key);
// 调用 Reflect Api 获得原始的数据 你可以将它简单理解成为 target[key]
let result = Reflect.get(target, key, receiver);
// 依赖为对象 递归进行reactive处理
if (isPlainObj(result)) {
return reactive(result);
}
// 配合Reflect解决当访问get属性递归依赖this的问题
return result;
}
,
set() {
// dosomething
}
}
);
return proxy;
}
;
上边我们填充了在 Proxy 中的 get 陷阱的逻辑:
当访问响应式对象 proxy 中的属性时,首先会针对于对应的属性进行依赖收集。主要依靠的是 track 方法。
之后如果访问该响应式对象 key 对应的 value 仍为对象时,会再次递归调用 reactive 方法进行处理。
需要注意的是递归进行 reactive 时是一层懒处理,换句话说只有访问时才会递归处理并不是在初始化时就会针对于传入的 obj 进行递归处理。
当然这里的依赖收集主要依靠的就是 track 方法,我们会在稍后详解实现这个方法。
依赖收集
接下来我们来看看 set 陷阱中的逻辑,当触发对于 proxy 对象的属性修改时会触发 set 陷阱从而进行触发对应 Effect 的执行。
我们来一起看看对应的 set 陷阱中的逻辑:
// reactivity/src/reactivity.ts
export function isPlainObj(value: any): value is object {
return typeof value === 'object' && value !== null;
}
const reactive = (obj) => {
// 传入非对象
if (!isPlainObj(obj)) {
return obj;
}
// 声明响应式数据
const proxy = new Proxy(obj, {
get(target, key, receiver) {
// 依赖收集方法 track
track(target, 'get', key);
// 调用 Reflect Api 获得原始的数据 你可以将它简单理解成为 target[key]
let result = Reflect.get(target, key, receiver);
// 依赖为对象 递归进行reactive处理
if (isPlainObj(result)) {
return reactive(result);
}
// 配合Reflect解决当访问get属性递归依赖this的问题
return result;
}
,
// 当进行设置时进行触发更新
set(target, key, value, receiver) {
const oldValue = target[key];
// 配合Reflect解决当访问get属性递归依赖this的问题
const result = Reflect.set(target, key, value, receiver);
// 如果两次变化的值相同 那么不会触发更新
if (value !== oldValue) {
// 触发更新
trigger(target, 'set', key, value, oldValue);
}
return result;
}
,
}
);
return proxy;
}
;
同样,我们在上边填充了对应 set 陷阱之中的逻辑,当设置响应式对象时会触发对应的 set 陷阱。我们会在 set 陷阱中触发对应的 trigger 逻辑进行触发更新:将依赖的 effect 重新执行。
关于为什么我们在使用 Proxy 时需要配合 Refelct ,我在这篇文章有详细讲解。感兴趣的朋友可以查看这里 [为什么Proxy一定要配合Reflect使用?]。
上边我们完成了 reactive.ts 文件的基础逻辑,遗留了两个核心方法 track & trigger 方法。
在实现着两个方法之前,我们先来一起看看 effect 是如何被实现的。
effect 文件
effect 基础使用
让我们把视野切到 effcet.ts 中,我们稍微来回忆一下 effect Api 的用法:
const {
reactive,
effect
}
= Vue
const obj = {
name: '19Qingfeng'
}
// 创建响应式数据
const reactiveData = reactive(obj)
// 创建effect依赖响应式数据
effect(() => {
app.innerHTML = reactiveData.name
}
)
effect 基础原理
上边我们看到,effect Api 有以下特点:
effect 接受一个函数作为入参。
当调用effect(fn) 时,内部的函数会直接被调用一次。
其次,当 effect 中的依赖的响应式数据发生改变时。我们期望 effect 会重新执行,比如这里的 effect 依赖了 reactiveData.name 上的值。
接下来我们先来一起实现一个简单的 Effect Api:
function effect(fn) {
// 调用Effect创建一个的Effect实例
const _effect = new ReactiveEffect(fn);
// 调用Effect时Effect内部的函数会默认先执行一次
_effect.run();
// 创建effect函数的返回值:_effect.run() 方法(同时绑定方法中的this为_effect实例对象)
const runner = _effect.run.bind(_effect);
// 返回的runner函数挂载对应的_effect对象
runner.effect = _effect;
return runner;
}
这里我们创建了一个基础的 effect Api,可以看到它接受一个函数 fn 作为参数。
当我们运行 effect 时,会创建一个 const _effect = new ReactiveEffect(fn);
对象。
同时我们会调用 _effect.run() 这个实例方法立即执行传入的 fn ,之所以需要立即执行传入的 fn 我们在上边提到过:当代码执行到 effect(fn) 时,实际上会立即执行 fn 函数。
我们调用的 _effect.run() 实际内部也会执行 fn ,我们稍微回忆下上边的 Demo 当代码执行 effect(fn) 时候相当于执行了:
// ...
effect(() => {
app.innerHTML = reactiveData.name
}
)
会立即执行传入的 fn 也就是 () => {
app.innerHTML = reactiveData.name
}
会修改 app 节点中的内容。
同时,我们之前提到过因为 reactiveData 是一个 proxy 代理对象,当我们访问它的属性时实际上会触发它的 get 陷阱。
// effect.ts
export let activeEffect;
export function effect(fn) {
// 调用Effect创建一个的Effect实例
const _effect = new ReactiveEffect(fn);
// 调用Effect时Effect内部的函数会默认先执行一次
_effect.run();
// 创建effect函数的返回值:_effect.run() 方法(同时绑定方法中的this为_effect实例对象)
const runner = _effect.run.bind(_effect);
// 返回的runner函数挂载对应的_effect对象
runner.effect = _effect;
return runner;
}
/**
* Reactive Effect
*/
export class ReactiveEffect {
private fn: Function;
constructor(fn) {
this.fn = fn;
}
run() {
try {
activeEffect = this;
// run 方法很简单 就是执行传入的fn
return this.fn();
}
finally {
activeEffect = undefined
}
}
}
这是一个非常简单的 ReactiveEffect 实现,它的内部非常简单就是简单的记录了传入的 fn ,同时拥有一个 run 实例方法当调用 run 方法时会执行记录的 fn 函数。
同时,我们在模块内部声明了一个 activeEffect 的变量。当 我们调用运行 effect(fn) 时,实际上它会经历以下步骤:
首先用户代码中调用 effect(fn)
VueJs 内部会执行 effect 函数,同时创建一个 _effect 实例对象。立即调用 _effect.run() 实例方法。
重点就在所谓的 _effect.run() 方法中。
首先,当调用 _effect.run() 方法时,我们会执行 activeEffect = this 将声明的 activeEffect 变成当前对应的 _effect 实例对象。
同时,run() 方法接下来会调用传入的 fn() 函数。
当 fn() 执行时,如果传入的 fn() 函数存在 reactive() 包裹的响应式数据,那么实际上是会进入对应的 get 陷阱中。
当进入响应式数据的 get 陷阱中时,不要忘记我们声明全局的 activeEffect 变量,我们可以在对应响应式数据的 get 陷阱中拿到对应 activeEffect (也就是创建的 _effect) 变量。
接下来我们需要做的很简单:
在响应式数据的 get 陷阱中记录该数据依赖到的全局 activeEffect 对象(_effect)(依赖收集)也就是我们之前遗留的 track 方法。
同时:
当改变响应式数据时,我们仅仅需要找出当前对应的数据依赖的 _effect ,修改数据同时重新调用 _effect.run() 相当于重新执行了 effect(fn)中的 fn。那么此时不就是相当于修改数据页面自动更新吗?这一步就被称为依赖收集,也就是我们之前遗留的 trigger 方法。
track & trigger 方法
让我们会回到之前遗留的 track 和 trigger 逻辑中,接下来我们就尝试去实现它。
这里我们将在 effect.ts 中来实现这两个方法,将它导出提供给 reactive.ts 中使用。
思路梳理
上边我们提到过,核心思路是当代码执行到 effect(fn) 时内部会调用对应的 fn 函数执行。当 fn 执行时会触发 fn 中依赖的响应式数据的 get ,当 get 触发时我们记录到对应 声明的(activeEffect) _effect 对象和对应的响应式数据的关联即可。
当响应式数据改变时,我们取出关联的 _effect 对象,重新调用 _effect.run() 重新执行 effect(fn) 传入的 fn 函数即可。
看到这里,一些同学已经反应过来了。我们有一份记录对应 activeEffect(_effect) 和 对应的响应式数据的表,于是我们自然而然的想到使用一个 WeakMap 来存储这份关系。
之所以使用 WeakMap 来存储,第一个原因自然是我们需要存储的 key 值是非字符串类型这显然只有 map 可以。其次就是 WeakMap 的 key 并不会影响垃圾回收机制。
创建映射表
上边我们分析过,我们需要一份全局的映射表来维护 _effect 实例和依赖的响应式数据的关联:
于是我们自然想到通过一个 WeakMap 对象来维护映射关系,那么如何设计这个 WeakMap 对象呢?这里我就不卖关子了。
我们再来回忆下上述的 Demo :
// ...
const {
reactive,
effect
}
= Vue
const obj = {
name: '19Qingfeng'
}
// 创建响应式数据
const reactiveData = reactive(obj)
// 创建effect依赖响应式数据
effect(() => {
app.innerHTML = reactiveData.name
}
)
// 上述Demo的基础上增加了一个effect依赖逻辑
effect(() => {
app2.innerHTML = reactiveData.name
}
)
首先针对于响应式数据 reactiveData 它是一个对象,上述代码中的 effect 中依赖了 reactiveData 对象上的 name 属性。
所以,我们仅仅需要关联当前响应式对象中的 name 属性和对应 effect 即可。
同时,针对于同一个响应式对象的属性比如这里的 name 属性被多个 effect 依赖。自然我们可以想到一份响应式数据的属性可以和多个 effect 依赖。
根据上述的分析最终 Vuejs 中针对于这份映射表设计出来了这样的结构:
当一个 effect 中依赖对应的响应式数据时,比如上述 Demo :
我们创建的全局的 WeakMap 首先会将响应式对象的原始对象(未代理前的对象)作为 key ,value 为一个 Map 对象。
同时 effect 内部使用了上述对象的某个属性,那么此时 WeakMap 对象的该对象的值(我们刚才创建的 Map )。我们会在这个 Map 对象中设置 key 为使用到的属性,value 为一个 Set 对象。
为什么对应属性的值为一个 Set ,这非常简单。因为该属性可能会被多个 effect 依赖到。所以它的值为一个 Set 对象,当该属性被某个 effect 依赖到时,会将对应 _effect 实例对象添加进入 Set 中。
也许有部分同学乍一看对于这份映射表仍然比较模糊,没关系接下来我会用代码来描述这一过程。你可以结合代码和这段文字进行一起理解。
track 实现
接下来我们来看看 track 方法的实现:
// *用于存储响应式数据和Effect的关系Hash表
const targetMap = new WeakMap();
/**
* 依赖收集函数 当触发响应式数据的Getter时会进入track函数
* @param target 访问的原对象
* @param type 表示本次track从哪里来
* @param key 访问响应式对象的key
*/
export function track(target, type, key) {
// 当前没有激活的全局Effect 响应式数据没有关联的effect 不用收集依赖
if (!activeEffect) {
return;
}
// 查找是否存在对象
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
// 查找是否存在对应key对应的 Set effect
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 其实Set本身可以去重 这里判断下会性能优化点
const shouldTrack = !deps.has(activeEffect) && activeEffect;
if (shouldTrack) {
// *收集依赖,将 effect 进入对应WeakMap中对应的target中对应的keys
deps.add(activeEffect);
}
}
我们一行一行分析上边的 track 方法,这个方法我们之前提到过。它是在 reactive.ts中对于某个响应式属性进行依赖收集(触发proxy的 get 陷阱)时触发的,忘记了的小伙伴可以翻回去重新看下。
首先,它会判断当前 activeEffect 是否存在,所谓 actvieEffect 也就是当前是否存在 effect 。换句话说,比如这样:
// ...
app.innerHTML = reactiveData.name
那么我们有必要进行依赖收集吗,虽然 reactiveData 是一个响应式数据这不假,但是我们并没有在模板上使用它。它并不存在任何关联的 effect ,所以完全没有必要进行依赖收集。
而在这种情况下:
effect(() => {
app.innerHTML = reactiveData.name
}
)
只有我们在 effect(fn) 中,当 fn 中使用到了对应的响应式数据。简单来说也就是 activeEffect 存在值得时候,对于响应式数据的依赖收集才有意义。
其次,接下来会去全局的 targetMap 中寻找是否已经存在对应响应式数据的原始对象 -> depsMap 。如果该对象首次被收集,那么我们需要在 targetMap 中设置 key 为 target ,value 为一个新的Map。
// 查找是否存在对象
let depsMap = targetMap.get(target);
if (!depsMap) {
// 不存在则创建一个Map作为value,将target作为key放入depsMap中
targetMap.set(target, (depsMap = new Map()));
}
同时我们会继续去上一步返回的 deps ,此时的 deps 是一个 Map 。它的内部会记录改对象中被进行依赖收集的属性。
我们回去寻找 name 属性是否存在,显然它是第一次进行依赖收集。所以会进行:
// 查找是否存在对应key对应的 Set effect
let deps = depsMap.get(key);
if (!deps) {
// 同样,不存在则创建set放入
depsMap.set(key, (deps = new Set()));
}
此时,比如上方的 Demo 中,当代码执行到 effect 中的 fn 碰到响应式数据的 get 陷阱时,触发 track 函数。
我们会为全局的 targetMap 对象中首先设置 key 为 obj (reactiveData的原始对象),value 为一个 Map 。
其次,我们会为该创建的 Map 中再次进行设置 key 为该响应式对象需要被收集的属性,也就是我们在 effect 中访问到该对象的 name ,value 为一个 Set 集合。
接下里 Set 中存放什么其实很简单,我们仅需在对应 Set 中记录当前正在运行的 effct 实例对象,也就是 activeEffct 就可以达到对应的依赖收集效果。
此时,targetMap 中就会存放对应的对象和关联的 effect 了。
trigger 实现
当然,上述我们已经通过对应的 track 方法收集了相关响应式数据和对应它依赖的 effect 。
那么接下来如果当改变响应式数据时(触发 set 陷阱时)自然我们仅仅需要找到对应记录的 effect 对象,调用它的 effect.run() 重新执行不就可以让页面跟随数据改变而改变了吗。
我们来一起看看 trigger 方法:
// ... effect.ts
/**
* 触发更新函数
* @param target 触发更新的源对象
* @param type 类型
* @param key 触发更新的源对象key
* @param value 触发更新的源对象key改变的value
* @param oldValue 触发更新的源对象原始的value
*/
export function trigger(target, type, key, value, oldValue) {
// 简单来说 每次触发的时 我拿出对应的Effect去执行 就会触发页面更新
const depsMap = targetMap.get(target);
if (!depsMap) {
return;
}
let effects = depsMap.get(key);
if (!effects) {
return;
}
effects = new Set(effects);
effects.forEach((effect) => {
// 当前zheng
if (activeEffect !== effect) {
// 默认数据变化重新调用effect.run()重新执行清空当前Effect依赖重新执行Effect中fn进行重新收集依赖以及视图更新
effect.run();
}
}
);
}
接下来我们在 effect.ts 中来补充对应的 trigger 逻辑,其实 trigger 的逻辑非常简单。每当响应式数据触发 set 陷阱进行修改时,会触发对应的 trigger 函数。
他会接受对应的 5 个 参数,我们在函数的注释中已经标明了对应的参数。
当触发响应式数据的修改时,首先我们回去 targetMap 中寻找 key 为对应原对象的值,自然因为在 track 中我们已经保存了对应的值,所以当然可以拿到一个 Map 对象。
因为该 Map 对象中存在对应 key 为 name 值为该属性依赖的 effect 的 Set 集合,所以我们仅需要依次拿出对应修改的属性,比如我们调用:
// ...
const {
reactive,
effect
}
= Vue
const obj = {
name: '19Qingfeng'
}
// 创建响应式数据
const reactiveData = reactive(obj)
// 创建effect依赖响应式数据
effect(() => {
app.innerHTML = reactiveData.name
}
)
// 修改响应式数据 触发set陷阱
reactiveData.name = 'wang.haoyu'
当我们调用 reactiveData.name = 'wang.haoyu' 时,我们会一层一层取到
targetMap 中 key 为 obj 的 depsMap(Map) 对象。
再从 depsMap 中拿到 key 为 name 属性的 Set 集合(Set 中保存该响应式对象属性依赖的 effect)。
迭代当前 Set 中的所有 effect 进行 effect.run() 重新执行 effect 对象中记录的 fn 函数。
因为我们在 reactive.ts 中的 set 陷阱中对于数据已经修改之后调用了 trigger 方法,trigger 导致重新执行 effect(fn) 中的 fn,所以自然而然 fn() 重新执行 app.innerHTML 就会变成最新的 wang.haoyu 。
整个响应式核心原理其实一点都不难对吧,核心思想还是文章开头的那句话:对于数据访问时,需要进行依赖收集。记录当前数据中依赖了哪些 Effect ,当进行数据修改时候同样会进行触发更新,重新执行当前数据依赖的 Effect。
阶段总结
其实写到这里已经 8K 多字了,原本打算是和大家过一遍整个 Vue 3.2 中关于 reactivity 的逻辑,包括各种边界情况。
比如文章中的代码其实仅仅只能说是实现了一个乞丐版的响应式原理,其他一些边界情况,比如:
多个 effect 嵌套时的处理。
多次 reactive 调用同一对象,或者对于已经 reactive 包裹的响应式对象。
每次触发更新时,对于前一次依赖收集的清理。
shallow、readonly 情况等等...
这些边界情况其实文章中的代码我都没有考虑,如果后续有小伙伴对这方面感兴趣我会再次开一篇文章去继续这次的代码去实现一个完整的 reactive 方法。
不过,透过现象看本质。VueJs 中所谓主打的数据响应式核心原理即是文章中代码所表现的思想。
我在这个代码地址,也自己实现了一版比较完整的精简版 reactivity 模块,有兴趣的同学可以自行查阅。
当然,你也可以直接参照源代码进行阅读。毕竟 Vue 3 的代码相较于 2 来说其实已经很人性化了。
面试题 12 . 请描述Vue的实现原理 ?
参考回答:
vue.js 是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调。
具体步骤:
第一步:需要observe的数据对象进行递归遍历,包括子属性对象的属性,都加上setter和getter
这样的话,给这个对象的某个值赋值,就会触发setter,那么就能监听到了数据变化
第二步:compile解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图
第三步:Watcher订阅者是Observer和Compile之间通信的桥梁,主要做的事情是:
1、在自身实例化时往属性订阅器(dep)里面添加自己
2、自身必须有一个update()方法
3、待属性变动dep.notice()通知时,能调用自身的update()方法,并触发Compile中绑定的回调,则功成身退。
第四步:MVVM作为数据绑定的入口,整合Observer、Compile和Watcher三者,通过Observer来监听自己的model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起Observer和Compile之间的通信桥梁,达到数据变化 -> 视图更新;视图交互变化(input) -> 数据model变更的双向绑定效果。
面试题 13 . 请简述什么是Vue的自定义指令?
参考回答:
自定义指令分为全局指令和组件指令,其中全局指令需要使用directive来进行定义,组件指令需要使用directives来进行定义,具体定义方法同过滤器filter或者其他生命周期,具体使用方法如下:
全局自定义指令 directive(name,{}),其中name表示定义的指令名称(定义指令的时候不需要带v-,但是在调用的时候需要哦带v-),第二个参数是一个对象,对象中包括五个自定义组件的钩子函数,具体包括:
bind函数:只调用一次,指令第一次绑定在元素上调用,即初始化调用一次,
inserted函数:并绑定元素插入父级元素(即new vue中el绑定的元素)时调用(此时父级元素不一定转化为了dom)
update函数:在元素发生更新时就会调用,可以通过比较新旧的值来进行逻辑处理
componentUpdated函数:元素更新完成后触发一次
unbind函数:在元素所在的模板删除的时候就触发一次
钩子函数对应的参数el,binding,vnode,oldnode,具体参数讲解如下:
a、el指令所绑定的元素 可以直接操组dom元素
b、binding一个对象,具体包括以下属性:
1)name:定义的指令名称 不包括v-
2)value:指令的绑定值,如果绑定的是一个计算式,value为对应计算结果
3)oldvalue:指令绑定元素的前一个值,只对update和componentUpdated钩子函数有值
4)expression:指令绑定的原始值 不对值进行任何加工
5)arg:传递给指令的参数
6)modifiers:指令修饰符,如:v-focus.show.async 则接收的modifiers为{show:true,async:true}
c、vnode:vue编译生成的虚拟dom
d、oldVnode:上一个vnode,只在update和componentUpdated钩子函数中有效
⚠️:如果不需要其他钩子函数,可以直接简写为:directive(“focus”,function(el,binding){})
面试题 14 . 简述对于Vue的diff算法理解 ?
参考回答:
1)diff算法的作用:用来修改dom的一小段,不会引起dom树的重绘
2)diff算法的实现原理:diff算法将virtual dom的某个节点数据改变后生成的新的vnode与旧节点进行比较,并替换为新的节点,具体过程就是调用patch方法,比较新旧节点,一边比较一边给真实的dom打补丁进行替换
3)具体过程详解:
a、在采用diff算法进行新旧节点进行比较的时候,比较是按照在同级进行比较的,不会进行跨级比较:
b、当数据发生改变的时候,set方法会调用dep.notify通知所有的订阅者watcher,订阅者会调用patch函数给响应的dom进行打补丁,从而更新真实的视图
c、patch函数接受两个参数,第一个是旧节点,第二个是新节点,首先判断两个节点是否值得比较,值得比较则执行patchVnode函数,不值得比较则直接将旧节点替换为新节点。如果两个节点一样就直接检查对应的子节点,如果子节点不一样就说明整个子节点全部改变不再往下对比直接进行新旧节点的整体替换
d、patchVnode函数:找到真实的dom元素;判断新旧节点是否指向同一个对象,如果是就直接返回;如果新旧节点都有文本节点,那么直接将新的文本节点赋值给dom元素并且更新旧的节点为新的节点;如果旧节点有子节点而新节点没有,则直接删除dom元素中的子节点;如果旧节点没有子节点,新节点有子节点,那么直接将新节点中的子节点更新到dom中;如果两者都有子节点,那么继续调用函数updateChildren
e、updateChildren函数:抽离出新旧节点的所有子节点,并且设置新旧节点的开始指针和结束指针,然后进行两辆比较,从而更新dom(调整顺序或者插入新的内容 结束后删掉多余的内容)
面试题 15 . 请叙述Vue与React、Angular的比较?
参考回答:
Vue
轻量级框架:只关注视图层,是一个构建数据的视图集合,大小只有几十kb;
简单易学:国人开发,中文文档,不存在语言障碍 ,易于理解和学习;
双向数据绑定:保留了angular的特点,在数据操作方面更为简单;
组件化:保留了react的优点,实现了html的封装和重用,在构建单页面应用方面有着独特的优势;
视图,数据,结构分离:使数据的更改更为简单,不需要进行逻辑代码的修改,只需要操作数据就能完成相关操作;
虚拟DOM:dom操作是非常耗费性能的, 不再使用原生的dom操作节点,极大解放dom操作,但具体操作的还是dom不过是换了另一种方式;
运行速度更快:相比较与react而言,同样是操作虚拟dom,就性能而言,vue存在很大的优势。
React
相同点:
React采用特殊的JSX语法,Vue.js在组件开发中也推崇编写.vue特殊文件格式,对文件内容都有一些约定,两者都需要编译后使用;中心思想相同:一切都是组件,组件实例之间可以嵌套;都提供合理的钩子函数,可以让开发者定制化地去处理需求;都不内置列数AJAX,Route等功能到核心包,而是以插件的方式加载;在组件开发中都支持mixins的特性。
不同点:
React采用的Virtual DOM会对渲染出来的结果做脏检查;Vue.js在模板中提供了指令,过滤器等,可以非常方便,快捷地操作Virtual DOM。
Angular
相同点:
都支持指令:内置指令和自定义指令;都支持过滤器:内置过滤器和自定义过滤器;都支持双向数据绑定;都不支持低端浏览器。
不同点:
AngularJS的学习成本高,比如增加了Dependency Injection特性,而Vue.js本身提供的API都比较简单、直观;在性能上,AngularJS依赖对数据做脏检查,所以Watcher越多越慢;Vue.js使用基于依赖追踪的观察并且使用异步队列更新,所有的数据都是独立触发的
面试题 16 . 请简述Vuex的使用 ?
参考回答:
vuex 是什么 ?
vuex 是一个专为 Vue 应用程序开发 的状态管理器, 采用集中式 存储管理 应用的所有组件的状态。每 一个 vuex 应用的核心就是 store(仓库)。“store” 基本上就是一个容器,它包含着应用中大部分 的状态 (state)。
为什么需要 vuex
由于组件只维护自身的状态(data),组件创建时或者路由切换时,组件会被初始化,从而导致 data 也 随之销毁。
使用方法
在 main.js 引入 store,注入。只用来读取的状态集中放在 store 中, 改变状态的方式是提交
mutations,这是个同步的事物,异步逻辑应该封装在 action 中。
什么场景下会使用到 vuex 如果是 vue 的小型应用,那么没有必要使用 vuex,这个时候使用 vuex 反而会带来负担。组件之间的 状态传递使用 props、自定义事件来传递即可。 但是如果 涉及到 vue 的大型应用 ,那么就需要类似于 vuex 这样的集中管 理状态的状态机来管理所有 组件的状态。例如登录状态、加入购物车、音乐播放等,总之只要是开发 vue 的大型应用,都推荐使 用 vuex 来管理所有组件状态
主要包括以下几个模块:
State:定义了应用状态的数据结构,可以在这里设置默认的初始化状态。
Getter:允许组件从Store中获取数据,mapGetters 辅助函数仅仅是将 store 中的
getter 映射到局部计算属性。
Mutation:是唯一更改 store 中状态的方法,且必须是同步函数。
Action:用于提交 mutation,而不是直接变更状态,可以包含任意异步请求。
Module:允许将单一的 Store 拆分更多个 store 且同时保存在单一的状态树中
面试题 17 . 请叙述Vue 中使用了哪些设计模式?
参考回答:
1 工厂模式 - 传入参数即可创建实例 虚拟 DOM 根据参数的不同返回基础标签的 Vnode 和组件 Vnode。
2 单例模式 - 整个程序有且仅有一个实例 vuex 和 vue-router 的插件3注册方法 install 判断如果系统存在实例就直接返回掉。
3 发布-订阅模式。(vue 事件机制)
4 观察者模式。(响应式数据原理)
5 装饰器模式(@装饰器的用法)
6 策略模式,策略模式指对象有某个行为,但是在不同的场景中,该行为有不同的实现方案 - 比如选项的合并策略。
面试题 18 . 简单描述Vue的组件渲染流程 ?
参考回答:
1、给组件创建个构造函数,基于Vue。
export default function globalApiMixin(Vue){
Vue.options = {}
Vue.mixin = function (options){
this.options = mergeOptions(this.options,options);//合并options
}
Vue.options.components = {};
Vue.options._base = Vue
Vue.component = function (id,definition){
//保证组件的隔离,每个组件都会产生一个新的类,去继承父类
definition = this.options._base.extend(definition);
this.options.components[id] = definition;
}
//给个对象返回类
Vue.extend = function (definition){//extend方法就是返回一个继承于Vue的类
//并且身上应该有父类的所有功能
let Super = this;
let Sub = function VueComponent(options){
this._init(options);
}
//原型继承
Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
Sub.options = mergeOptions(Super.options, definition);//只和vue.options合并
return Sub
}
}
2、开始生成虚拟节点,对组件进行特殊处理 data.hook = {init(){}}
export function createElement(vm, tag, data = {}, ...children) {
if(isReservedTag(tag)){
return vnode(vm, tag, data, data.key, children, undefined);
}else{
const Ctor = vm.$options.components[tag];
return createComponent(vm, tag, data, data.key, undefined, undefined,Ctor);
}
}
function createComponent(vm, tag, data, key, children, text, Ctor) {
if(isObject(Ctor)){
Ctor = vm.$options._base.extend(Ctor)
}
data.hook = {
init(vnode){
let vm = vnode.componentInstance = new Ctor({_isComponent:true})//new sub
debugger
vm.$mount();
}
}
return vnode(vm,`vue-component-${tag}`,data,key,undefined,undefined,{Ctor,children});
}
export function createTextElement(vm, text) {
return vnode(vm, undefined, undefined, undefined, undefined, text);
}
function vnode(vm, tag, data, key, children, text,componentOptions) {
return { vm, tag, data, key, children, text, componentOptions };
}
function isReservedTag(str){ //判断是否是组件
let strList = 'a,div,span,p,ul,li';
return strList.includes(str);
}
3、生成dom元素,如果当前虚拟节点上有hook.init属性,说明是组件
function createComponent(vnode){
let i = vnode.data;
if((i = i.hook) && (i = i.init)){
i(vnode);//调用init方法
}
if (vnode.componentInstance) {
//有属性说明子组件new完毕了,并且组件的真实dom挂载到了vnode。componentInstance
return true;
}
}
function createElm(vnode){
debugger
let {vm,tag,data,children,text} = vnode;
if(typeof tag === 'string'){
//判断是否是组件
if( createComponent(vnode)){
//返回组件对应的真实节点
console.log(vnode.componentInstance.$el);
return vnode.componentInstance.$el
}
vnode.el = document.createElement(tag);
if(children.length){
children.forEach(child=>{
vnode.el.appendChild(createElm(child));
})
}
}else{
vnode.el = document.createTextNode(text);
}
return vnode.el;
}
4、对组件进行new 组件().$mount()=>vm.$el; 将组件的$el插入到父容器中 (父组件)
Vue.prototype.$mount = function (el) {
debugger
const vm = this;
const options = vm.$options;
el = document.querySelector(el);
vm.$el = el;
//将模板转化成对应的渲染函数=》虚拟函数概念 vnode =》diff算法更新虚拟 dom =》产生真实节点,更新
if (!options.render) {
//没有render 用template,目前没有render
let template = options.template;
if (!template && el) {
//用户也没有传入template,就取页面上的el
template = el.outerHTML;
}
let render = compileToFunction(template);
//options.render 就是渲染函数
options.render = render;
}
debugger
mountComponent(vm, el); //组件的挂载流程
};
面试题 19 . 请说明Vue key的作用及原理 ?
参考回答:
key是虚拟DOM对象的标识,当数据发生变化时,Vue会根据[新数据]生成[新的虚拟DOM],随后Vue进行[新虚拟DOM]与[旧虚拟DOM]的差异比较
原理(比较规则):
1.旧虚拟DOM中找到了与新虚拟DOM相同的key:
a.若虚拟DOM中内容没变,直接使用之前的直DOM!
b.若虚拟DOM中内容变了,则生成新的真实DOM,随后替换掉页面中之前的真实DOM。
2.旧虚拟DOM中未找到与新虚拟DOM相同的key:
a.创建新的真实DOM,随后渲染到到页面。
index作为key可能会引发的问题:
1.若对数据进行:逆序添加、逆序删除等破坏顺序操作:
a.会产生没有必要的真实DOM更新==> 界面效果没问题,但效率低。
2.如果结构中还包含输入类的DOM:
a.会产生错误DOM更新==>界面有问题
面试题 20 . 请说明Vue的filter的理解与用法?
参考回答:
1)全局过滤器必须写在vue实例创建之前
Vue.filter('testfilter', function (value,text) { // 返回处理后的值
return value+text
})
2)局部写法:在组件实例对象里挂载。
filters: {
changemsg:(val,text)\=>{ return val + text
}
}
3)使用方式:只能使用在{{}}和:v-bind中,定义时第一个参数固定为预处理的数,后面的数为调用时传入的参数,调用时参数第一个对应定义时第二个参数,依次往后类推
{{test|changemsg(4567)}}
//多个过滤器也可以串行使用
{{name|filter1|filter2|filter3}}
4)vue-cli项目中注册多个全局过滤器写法:
//1.创建一个单独的文件定义并暴露函数对象
const filter1 = function (val) {
return val + '--1'
}
const filter2 = function (val) {
return val + '--2'
}
const filter3 = function (val) {
return val + '--3'
}
export default {
filter1,
filter2,
filter3
}
//2.导入main.js(在vue实例之前)
import filters from './filter/filter.js'
//3.循环注册过滤器
Object.keys(filters).forEach(key=>{
Vue.filter(key,filters[key])
})