Appearance
vue2 篇
介绍一下 MVVM 模式,和 MVC 模式有什么区别
MVVM 即 Model-View-ViewModel 的简写,即模型-视图-视图模型。模型(Model)指的是后端传递的数据。视图(View)指的是所看到的页面。视图模型(ViewModel)是 MVVM 模式的核心,它是连接 View 和 Model 的桥梁。
视图模型有两个方向的作用:
- 将模型(Model)转化成视图(View),即将后端传递的数据转化成所看到的页面,实现的方式是:数据绑定。
- 将视图(View)转化成模型(Model),即将所看到的页面转化成后端的数据,实现的方式是:DOM 事件监听。当这两个方向的数据转换都实现时,我们称之为数据的双向绑定。
MVC 是 Model-View-Controller 的简写,即模型-视图-控制器。M 和 V 指的意思和 MVVM 中的 M 和 V 意思一样。C 即 Controller 指的是页面业务逻辑。使用 MVC 的目的就是将 M 和 V 的代码分离。MVC 是单向通信,也就是 View 跟 Model ,必须通过 Controller 来承上启下。
MVVM 与 MVC 最大的区别就是:MVVM 实现了 View 和 Model 的自动同步,也就是当 Model 的属性改变时,我们不用再自己手动操作 Dom 元素来改变 View 的显示,而是改变属性后该属性对应 View 层显示会自动改变(双向绑定)。
Vue 的数据为什么频繁变化但只会更新一次
vue 为了优化性能,它在同一个事件循环中,会将所有数据变化都放入一个异步更新队列中,在下一次的 tick 中批量处理这些变化。
vue-router 实现原理
hash 模式和 history 模式的区别
- 表现形式的区别
- hash 模式 http:localhost/#/about
- history 模式 http:localhost/about
- 原理的区别
- hash 模式 Vue Router 默认使用的模式是 hash 模式,通过 onhashchange 事件监听 url 的变化,再通过 history 的 pushState 方法,往浏览器添加历史记录。不需要后台人员进行配置
- history 模式 使用的是 H5 的 History API,通过 popstate 事件监听 url 的变化,再通过 history 的 pushState 方法,往浏览器添加历史记录。需要后台人员处理刷新页面的情况(找不到当前页面,在服务端应该除了静态资源外都返回单页应用的 index.html)
js
window.addEventListener('hashchange', () => {})
window.addEventListener('popstate', () => {})
history.pushState({}, '', 'bar.html') // 往浏览器添加历史记录
history.replaceState({}, '', 'bar.html') // 往浏览器修改当前的历史纪录
history.go() // 跳转指定页面
实现
- 创建一个 VueRouter 类,有一个静态方法 install,判断此插件是否加载过,只需加载一次。当 Vue 加载时,把传入的 router 对象挂载到 Vue 实例上(只执行一次)。
- 在构造函数中,初始化,把传入的参数 options 放到自己的 this 上;创建一个 routerMap 对象,用来记录路由和组件的映射关系;再利用 Vue 提供的 observable 方法创建一个响应式数据,记录当前的路由。
- 创建 initRouterMap 事件,循环传入的 routes 路由配置信息,把路由和组件的映射关系记录到 routerMap 对象中。
- 创建 initEvent 事件,利用 popstate 事件监听 history 模式下路由的变化,利用 hashchange 事件监听 hash 模式下的变化。
- 创建 initComponent 事件,创建 router-link 和 router-view 两个组件。
- router-link 组件里面渲染一个 a 标签,然后阻止默认事件,手动改变当前路由,再通过 history 的 pushState 方法,往浏览器添加历史记录。
- router-view 组件则是直接调用 vue 的 render 方法中的 h 函数,渲染路由对应的组件。因为 当前路由是响应式的,所以路由变化的时候,会重新渲染对应的组件。
js
// my-Router.js
let _Vue = null
class VueRouter {
static install(Vue) {
// 判断此插件是否安装过
if (VueRouter.prototype.installed) {
return
}
VueRouter.prototype.installed = true
_Vue = Vue
// 把当前路由对象挂载到vue实例上
_Vue.mixin({
beforeCreate() {
// 为什么在这里挂载,因为这里的this才是vue实例
if (this.$options.router) {
_Vue.prototype.$router = this.$options.router
}
},
})
}
constructor(options) {
// 传入的对象
this.options = options
// 所有路由
this.routerMap = {}
// 当前路由
this.data = _Vue.observable({
current: '/',
})
this.init()
}
init() {
//监听浏览器地址变化
this.initEvent()
// 把所有的路由添加到routerMap中
this.initRouterMap()
// 初始化全局的路由组件
this.initComponent(_Vue)
}
initRouterMap() {
for (let item of this.options.routes) {
this.routerMap[item.path] = item.component
}
}
initEvent() {
if (this.options.mode == 'history') {
window.addEventListener('popstate', () => {
this.data.current = window.location.pathname
})
} else {
window.addEventListener('hashchange', () => {
this.data.current = window.location.hash.substr(1)
})
window.addEventListener('load', () => {
if (!window.location.hash) {
window.location.hash = '#/'
}
})
}
}
initComponent(Vue) {
let that = this
Vue.component('router-link', {
props: {
to: String,
},
render(h) {
return h(
'a',
{
attrs: {
href: this.to,
},
on: {
click: this.clickhander,
},
},
[this.$slots.default]
)
},
methods: {
clickhander(e) {
e.preventDefault()
if (that.options.mode == 'history') {
history.pushState({}, '', this.to)
} else {
history.pushState({}, '', '#' + this.to)
}
that.data.current = this.to
},
},
})
Vue.component('router-view', {
render(h) {
let app = that.routerMap[that.data.current]
return h(app)
},
})
}
}
export default VueRouter
vue-router 路由守卫
- router.beforeEach 全局前置守卫
js
const router = createRouter({ ... })
router.beforeEach((to, from, next) => {
// ...
// 返回 false 以取消导航
return false
})
- router.afterEach 全局后置钩子
js
const router = createRouter({ ... })
router.afterEach((to, from, failure) => {
})
- beforeEnter 路由独享的守卫,只在进入路由时触发
js
const routes = [
{
path: '/users/:id',
component: UserDetails,
beforeEnter: (to, from) => {
// reject the navigation
return false
},
},
]
- beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave 组件内的守卫
js
const UserDetails = {
template: `...`,
beforeRouteEnter(to, from) {
// 在渲染该组件的对应路由被验证前调用
// 不能获取组件实例 `this` !
// 因为当守卫执行时,组件实例还没被创建!
},
beforeRouteUpdate(to, from) {
// 在当前路由改变,但是该组件被复用时调用
// 举例来说,对于一个带有动态参数的路径 `/users/:id`,在 `/users/1` 和 `/users/2` 之间跳转的时候,
// 由于会渲染同样的 `UserDetails` 组件,因此组件实例会被复用。而这个钩子就会在这个情况下被调用。
// 因为在这种情况发生的时候,组件已经挂载好了,导航守卫可以访问组件实例 `this`
},
beforeRouteLeave(to, from) {
// 在导航离开渲染该组件的对应路由时调用
// 与 `beforeRouteUpdate` 一样,它可以访问组件实例 `this`
},
}
完整的导航解析流程
- 导航被触发。
- 在失活的组件里调用 beforeRouteLeave 守卫。
- 调用全局的 beforeEach 守卫。
- 在重用的组件里调用 beforeRouteUpdate 守卫(2.2+)。
- 在路由配置里调用 beforeEnter。
- 解析异步路由组件。
- 在被激活的组件里调用 beforeRouteEnter。
- 调用全局的 beforeResolve 守卫(2.5+)。
- 导航被确认。
- 调用全局的 afterEach 钩子。
- 触发 DOM 更新。
- 调用 beforeRouteEnter 守卫中传给 next 的回调函数,创建好的组件实例会作为回调函数的参数传入。
实现路由、按钮权限
路由权限
不同账号登录,获取后端返回的菜单
- Vue2 通过 addRoute 把对应的路由添加进去,每次登录之前需要重置一下路由,不然会包含以前的路由权限。通过把旧路由实例的
matcher
属性换成新路由实例的matcher
属性
js
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
const createRouter = () =>
new Router({
mode: 'history',
routes: [],
})
const router = createRouter()
export function resetRouter() {
// 重置路由
const newRouter = createRouter()
router.matcher = newRouter.matcher
}
export default router
- Vue3 通过 addRoute 把对应的路由添加进去,每次登录之前需要删除之前的路由,不然会包含以前的路由权限。通过
router.getRoutes()
方法获取所有路由,循环所有路由,再通过router.removeRoute()
方法删除
js
const list = router.getRoutes()
list.forEach((item) => {
if (item.name) {
router.removeRoute(item.name)
}
})
按钮权限
- 通过
v-if
- 通过自定义指令
js
// router.js
{
path: '/permission',
component: Layout,
name: '权限测试',
meta: {
btnPermissions: ['admin', 'supper', 'normal']
},
//页面需要的权限
children: [{
path: 'supper',
component: _import('system/supper'),
name: '权限测试页',
meta: {
btnPermissions: ['admin', 'supper']
} //页面需要的权限
},
{
path: 'normal',
component: _import('system/normal'),
name: '权限测试页',
meta: {
btnPermissions: ['admin']
} //页面需要的权限
}]
}
// 自定义指令
import Vue from 'vue'
/**权限指令**/
const has = Vue.directive('has', {
bind: function (el, binding, vnode) {
// 获取页面按钮权限
let btnPermissionsArr = [];
if(binding.value){
// 如果指令传值,获取指令参数,根据指令参数和当前登录人按钮权限做比较。
btnPermissionsArr = Array.of(binding.value);
}else{
// 否则获取路由中的参数,根据路由的btnPermissionsArr和当前登录人按钮权限做比较。
btnPermissionsArr = vnode.context.$route.meta.btnPermissions;
}
if (!Vue.prototype.$_has(btnPermissionsArr)) {
el.parentNode.removeChild(el);
}
}
});
// 权限检查方法
Vue.prototype.$_has = function (value) {
let isExist = false;
// 获取用户按钮权限
let btnPermissionsStr = sessionStorage.getItem("btnPermissions");
if (btnPermissionsStr == undefined || btnPermissionsStr == null) {
return false;
}
if (value.indexOf(btnPermissionsStr) > -1) {
isExist = true;
}
return isExist;
};
export {has}
html
<el-button @click="editClick" type="primary" v-has>编辑</el-button>
Vue 实例挂载的过程
vue 构建函数调用 _init
方法,根据传入的 option 配置对象,进行一些操作
- 初始化一些数据,像 props/data/watch/methods ,初始化生命周期,定义 $on、$off、$emit、$off 等事件。
- 初始化渲染函数,把模板生成虚拟 DOM,然后再调用 mount 函数,把虚拟 DOM 渲染成真实 DOM 并挂载到页面上。
- 挂载完成后,Vue 会对数据进行响应式处理,然后再触发 mounted 生命周期
Vue 组件通信
- props 和 $emit,父子组件传值。
- $attrs 和 $listeners,父子组件传值。
- $attrs 接收父组件传值, 它跟 props 的区别在于,它是不会包含子组件中在 props 中已接收的值,且它如果需要在传递给孙组件,是通过
v-bind="$attrs"
传递,它经常配合 inheritAttrs 选项一起使用,inheritAttrs 为 false 时,传递的值不会展示在子组件的根元素上。 - $listeners 接收父组件 (不含 .native 修饰器的) v-on 事件。它可以通过
v-on="$listeners"
传给孙组件
html
<!-- Home.vue -->
<template>
<div class="home">
<Parent
:msg="msg"
:foo="foo"
:bar="bar"
:name="name"
@click="handleClick"
@aa="handleAa"
/>
</div>
</template>
<script>
import Parent from '@/components/Parent.vue'
export default {
name: 'Home',
data() {
return {
foo: 'foo',
bar: 'bar',
name: 'sky',
msg: 'message',
age: 16,
}
},
components: {
Parent,
},
methods: {
handleClick() {
console.log(this.age)
},
handleAa() {
console.log(2)
},
},
}
</script>
html
<!-- Parent.vue -->
<template>
<div> Parent <Child v-bind="$attrs" v-on="$listeners" /></div>
</template>
<script>
import Child from '@/components/Child.vue'
export default {
data() {
return {}
},
props: ['name'],
components: {
Child,
},
mounted() {
console.log('parent', this.$attrs) // {msg: "message", foo: "foo", bar: "bar"}
console.log('parent', this.$listeners) // {click: ƒ, aa: ƒ}
this.$listeners.click() // 16
},
}
</script>
html
<!-- Child.vue -->
<template>
<div>Child {{ msg }}</div>
</template>
<script>
export default {
inheritAttrs: false, // 默认值为 true, 父组件传递的值会展示在根元素上,false 则不展示
props: ['msg'],
mounted() {
console.log('child', this.$attrs) // {foo: "foo", bar: "bar"}
console.log('child', this.$listeners) // {click: ƒ, aa: ƒ}
},
}
</script>
- provide 和 inject ,通过依赖注入传值,它是跨父子,祖孙组件传值。但是它传递的值不是响应式的,如果要实现响应式的可以有两种方法:
- 把父组件的实例 this 直接传给子孙组件,这样子孙组件都可以改父组件的数据,但是这样会传递很多不需要的数据
- 使用 Vue.observable 对 provide 的数据进行响应式处理(推荐这种)
js
// 不处理响应式
// parent.vue
export default {
data() {
return {
name: 'sky',
}
},
provide() {
return {
name: this.name,
}
},
}
// child.vue
export default {
inject: ['name'],
}
js
// 响应式
// 方法1
// parent.vue
export default {
data() {
return {
name: 'sky',
}
},
provide() {
return {
parent: this,
}
},
}
// child.vue
export default {
inject: ['parent'],
}
// 方法2
// parent.vue
export default {
provide() {
this.theme = Vue.observable({
name: 'sky',
})
return {
theme: this.theme,
}
},
}
// child.vue
export default {
inject: ['theme'],
}
- 使用 $parent 、 $children 和 ref 访问父子组件
- $on 和 $emit,它是通过一个空的 Vue 实例来作为事件中心去进行通信的。它可以跨组件,父子组件传值,传值的时候即使接收值的页面是未显示的页面也能触发,但是再切回接收页面,不会重新触发
js
// bus.js
import Vue from 'vue'
export const bus = new Vue()
// a.vue
import { bus } from '@/assets/bus'
export default {
name: 'Home',
mounted() {
bus.$on('data', (val) => {
console.log('ff')
this.ok = val
})
},
}
// b.vue
import { bus } from '@/assets/bus'
export default {
methods: {
handleClick() {
bus.$emit('data', 88)
},
},
}
- 使用 vuex
Vue 常用自定义指令
- 自定义按钮权限指令
js
// <el-button @click="editClick" type="primary" v-has>编辑</el-button>
import Vue from 'vue'
/**权限指令**/
const has = Vue.directive('has', {
bind: function (el, binding, vnode) {
// 获取页面按钮权限
let btnPermissionsArr = []
if (binding.value) {
// 如果指令传值,获取指令参数,根据指令参数和当前登录人按钮权限做比较。
btnPermissionsArr = Array.of(binding.value)
} else {
// 否则获取路由中的参数,根据路由的btnPermissionsArr和当前登录人按钮权限做比较。
btnPermissionsArr = vnode.context.$route.meta.btnPermissions
}
if (!Vue.prototype.$_has(btnPermissionsArr)) {
el.parentNode.removeChild(el)
}
},
})
// 权限检查方法
Vue.prototype.$_has = function (value) {
let isExist = false
// 获取用户按钮权限
let btnPermissionsStr = sessionStorage.getItem('btnPermissions')
if (btnPermissionsStr == undefined || btnPermissionsStr == null) {
return false
}
if (value.indexOf(btnPermissionsStr) > -1) {
isExist = true
}
return isExist
}
export { has }
- 按钮防抖,防止重复点击
js
// <button @click="sayHello" v-throttle>提交</button>
Vue.directive('throttle', {
bind: (el, binding) => {
let throttleTime = binding.value // 节流时间
if (!throttleTime) {
// 用户若不设置节流时间,则默认2s
throttleTime = 2000
}
let cbFun
el.addEventListener(
'click',
(event) => {
if (!cbFun) {
// 第一次执行
cbFun = setTimeout(() => {
cbFun = null
}, throttleTime)
} else {
event && event.stopImmediatePropagation()
}
},
true
)
},
})
Vue 常用的修饰符
vue 中修饰符分为以下五种
- 表单修饰符
- lazy 相当于把输入框的 input 事件变成了 change 事件。在我们填完信息,光标离开标签的时候,才会将值赋予给 value,也就是在 change 事件之后再进行信息同步
html<input type="text" v-model.lazy="value" /> <p>{{value}}</p>
- trim 自动过滤用户输入的首空格字符,而中间的空格不会过滤
- number 自动将用户的输入值转为数值类型,但如果这个值无法被 parseFloat 解析,则会返回原来的值
- 事件修饰符
- stop 阻止事件冒泡,相当于调用了 event.stopPropagation 方法
- prevent 阻止了事件的默认行为,相当于调用了 event.preventDefault 方法
- self 只当在 event.target 是当前元素自身时触发处理函数
- once 事件只能触发一次
- capture 使事件触发从包含这个元素的顶层开始往下触发
- passive 在移动端,当我们在监听元素滚动事件的时候,会一直触发 onscroll 事件会让我们的网页变卡,因此我们使用这个修饰符的时候,相当于给 onscroll 事件整了一个.lazy 修饰符
- native 让组件变成像 html 内置标签那样监听根元素的原生事件,否则组件上使用 v-on 只会监听自定义事件
- 鼠标按键修饰符
- left 左键点击
- right 右键点击
- middle 中键点击
- 键值修饰符
- 普通键(enter、tab、delete、space、esc、up...)
- 系统修饰键(ctrl、alt、meta、shift...)
- v-bind 修饰符
- sync 能对 props 进行一个双向绑定
Vue 中的过滤器
过滤器(filter)可以理解为一个函数,对数据进行一定的加工,再返回,不会改变原始数据,它分为全局过滤器和组件过滤器。 处理一个数据的时候,可以有多个过滤器,过滤器也可以传参。 Vue3 中已废弃 filter
Vue.filter('capitalize', function (value) {
if (!value) return ''
value = value.toString()
return value.charAt(0).toUpperCase() + value.slice(1)
})
<!-- 在双花括号中 -->
{{ message | capitalize }}
<!-- 在 `v-bind` 中 -->
<div v-bind:id="rawId | formatId"></div>
{{ message | filterA | filterB }}
其中 message 的值作为第一个参数,普通字符串 'arg1' 作为第二个参数,表达式 arg2 的值作为第三个参数
{{ message | filterA('arg1', arg2) }}
平时开发中,需要用到过滤器的地方有很多,比如单位转换、数字打点、文本格式化、时间格式化之类的等
js
// 比如我们要实现将30000 => 30,000,这时候我们就可以使用过滤器
Vue.filter('toThousandFilter', function (value) {
if (!value) return ''
value = value.toString()
return .replace(str.indexOf('.') > -1 ? /(\d)(?=(\d{3})+\.)/g : /(\d)(?=(?:\d{3})+$)/g, '$1,')
})
原理
- 在编译阶段通过 parseFilters 将过滤器编译成函数调用(串联过滤器则是一个嵌套的函数调用,前一个过滤器执行的结果是后一个过滤器函数的参数)
- 编译后通过调用 resolveFilter 函数找到对应过滤器并返回结果
- 执行结果作为参数传递给 toString 函数,而 toString 执行后,其结果会保存在 Vnode 的 text 属性中,渲染到视图
生命周期
beforeCreate 做了些参数初始化和合并,mergeOptions 中初始化 props 和 inject 等参数。在当前阶段 data、props、methods、computed 以及 watch 上的数据和方法都不能被访问
created 初始化了 Inject 、Provide 、 props 、methods 、data 、computed 和 watch。这里没有$el,如果非要想与 Dom 进行交互,可以通过 vm.$nextTick 来访问 Dom
beforeMount 检查是否存在 el 属性,存在的话进行渲染 dom 操作。
mounted 实例化 Watcher ,渲染 dom,数据完成双向绑定,可以访问到 Dom 节点
beforeUpdate 针对视图层的,只有页面中渲染的数据发生改变或者渲染,才会触发这个生命周期函数。数据更新时调用,发生在虚拟 DOM 重新渲染和打补丁(patch)之前。可以在这个钩子中进一步地更改状态,这不会触发附加的重渲染过程
updated 针对视图层的,只有页面中渲染的数据发生改变或者渲染,才会触发这个生命周期函数。发生在更新完成之后,检查当前的 watcher 列表中,是否存在当前要更新数据的 watcher ,如果存在就执行。当前阶段组件 Dom 已完成更新。要注意的是避免在此期间更改数据,因为这可能会导致无限循环的更新,该钩子在服务器端渲染期间不被调用。
beforeDestroy 实例销毁之前调用。在这一步,实例仍然完全可用。我们可以在这时进行善后收尾工作,比如清除计时器。
destroyed Vue 实例销毁后调用。调用后,Vue 实例指示的所有东西都会解绑定,所有的事件监听器会被移除,所有的子实例也会被销毁。 该钩子在服务器端渲染期间不被调用。
activated keep-alive 专属,组件被激活时调用
deactivated keep-alive 专属,组件被销毁时调用
keep-alive 原理
在 keep-alive 组件内,created 生命周期内声明了一个缓存对象和一个 key 数组;
key 数组记录目前缓存的组件 key 值,如果组件没有指定 key 值,会自动生成一个唯一的 key 值。
cache 对象会以 key 值为键,vnode 为值,用于缓存组件对应的虚拟 DOM
在 keep-alive 的渲染函数中,其基本逻辑是判断当前渲染的 vnode 是否有对应的缓存,如果有,会从缓存中读取到对应的组件实例,如果没有就会把它缓存。
当缓存的数量超过 max 设置的数值时,keep-alive 会移除 key 数组中的第一个元素,遵循的是 LRU 置换策略
如何更新数据
- 利用 activated 生命周期
- 利用路由的 组件中的 beforeRouteEnter 事件,因为每次切换路由的时候,都会执行这个事件
nextTick 原理
nextTick 方法是用来在 DOM 更新循环结束之后执行延迟回调函数的,多个 nextTick 会被合并。
利用 Event loop 事件线程去异步操作,控制 DOM 更新和 nextTick 回调先后执行,保证了能在 dom 更新后在执行回调。
具体实现原理是通过 Promise、MutationObserver、setImmediate 和 setTimeout 等技术手段进行兼容性处理,以确保在所有浏览器环境下都能正确执行回调函数。在 Vue2 中,优先使用 Promise 和 MutationObserver 这两种高效的异步操作方式,如果浏览器不支持,则使用 setImmediate 或 setTimeout 进行兼容处理。
SSR 服务端渲染
SSR 也就是服务端渲染,也就是将 Vue 在客户端把标签渲染成 HTML 的工作放在服务端完成,然后再把 html 直接返回给客户端。
传统的服务端渲染:都是在服务端完成的,即服务端将数据和页面模板渲染成 HTML ,返回给客户端,这就是早期的前后端不分离模式。
现代化的服务端渲染(同构渲染):它是结合服务端渲染 + 客户端渲染来实现的,在服务器端执行一次,用于实现服务器端渲染(首屏直出),在客户端再执行一次,用于接管页面交互,核心解决 SEO 和首屏渲染慢的问题。
- 客户端发起请求
- 服务端渲染首屏内容 + 生成客户端 SPA 相关资源
- 服务端将生成的首屏资源发送给客户端
- 客户端直接展示服务端渲染好的首屏内容
- 首屏中的 SPA 相关资源执行之后会激活客户端
- 之后客户端所有的交互都由客户端 SPA 处理
优点:SSR 有着更好的 SEO、并且首屏加载速度更快
缺点:服务端渲染只支持 beforeCreate 和 created 两个生命周期,项目需要处于 Node 的运行环境,服务器会有更大的负载需求
vue 中使用了哪些设计模式
工厂模式 - 传入参数即可创建实例 虚拟 DOM 根据参数的不同返回基础标签的 Vnode 和组件 Vnode
单例模式 - 整个程序有且仅有一个实例。vuex 和 vue-router 的插件注册方法 install 判断如果系统存在实例就直接返回掉
发布-订阅模式 (vue 事件机制)
观察者模式 (响应式数据原理)
装饰模式: (@装饰器的用法)
策略模式 策略模式指对象有某个行为,但是在不同的场景中,该行为有不同的实现方案-比如选项的合并策略
谈谈响应式
响应式就是当数据发生变化时可以被检测到。
Vue 使用响应式能连接数据层和视图层,任何一方发生变化都能去影响另一方。它可以让开发者只需要关注数据变化,不需要去处理视图。
Vue2 它是通过 Object.defineProperty 方法,去循环对象的所有属性,在 getter 方法中会进行依赖收集,在 setter 中则去触发更新,进行拦截实现响应式操作的。它只能拦截对象一开始就有的属性。
如果是数组,则是通过重写数组的 7 个方法(push、pop、shift、unshift、splice、sort、reserve),然后通过拦截这些方法去收集依赖和触发更新的。如果也通过 Object.defineProperty 方法去拦截的话,会浪费太多性能
Vue2 新增删除对象属性的无法监控变化,需要通过 $set、Sdelete 实现。Vue2 也不支持 ES6 中 Map、Set 的数据
Vue3 则是通过 proxy 去进行拦截的,它可以直接代理对象和数组,就不用循环对象所有属性,和重写数组的方法了。如果对象里面的属性是对象或者数组,直接再代理那个属性就行。
Vue2 初始化的时候,就会去循环所有属性进行拦截,而 Vue3 初始化的时候,只会代理最外层的对象和数组,对于属性,访问的时候才会再进行代理。
vm.$set 原理
vm.$set( target, propertyName/index, value )
- 如果目标是数组,使用 vue 实现的变异方法 splice 实现响应式
- 如果目标是对象,判断属性存在,即为响应式,直接赋值。如果属性不存在,则调用 defineReactive 方法进行响应式处理
- 如果目标本身就不是响应式,直接赋值
简单实现观察者模式
vue 响应式,就是观察者模式实现的。
它就是有一个存储所有观察者的数组,当事件发生时,会循环这个数组,调用观察者中的 update 方法,从而进行通知。
观察者模式是由具体目标调度,比如当事件触发,Dep 就会去调用观察者的方法,所以观察者模式的订阅者与发布者之间是存在依赖的。
观察者 -- Watcher
- update() 这里处理当事件发生时,所要做的事情
目标 -- Dep
- subs 存储所有观察者的数组
- addSub() 添加观察者的方法,参数是观察者对象
- notify() 循环 subs,通知所有观察者,调用观察者的 update 方法
js
class Dep {
constructor() {
this.subs = []
}
addSub(sub) {
// 收集依赖
if (sub && sub.update) {
this.subs.push(sub)
}
}
notify() {
// 通知
if (!this.subs.length) return
this.subs.forEach((sub) => {
sub.update()
})
}
}
class Watcher {
update() {
console.log('update')
}
}
let dep = new Dep()
let watch = new Watcher()
dep.addSub(watch)
dep.notify()
简单实现发布-订阅模式
发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在。
思路:
- 定义一个类,初始化的时候,定义一个对象 subs,用来存储各种事件,以及订阅这个事件的数组对象
- 创建一个订阅方法,把订阅的事件和对象存储到 subs 中,如果没有这个事件,则创建一个,有的话,则添加到订阅的数组对象中
- 创建一个发布方法,把发布事件,所订阅的所有对象,都通知一遍
js
class EventEmitter {
constructor() {
// 存储所有事件对象
this.subs = {}
}
$on(eventType, fn) {
// 订阅
this.subs[eventType] = this.subs[eventType] || []
this.subs[eventType].push(fn)
}
$emit(eventType, ...params) {
// 发布
if (this.subs[eventType]) {
this.subs[eventType].forEach((fn) => {
fn.call(fn, ...params)
})
}
}
}
let eve = new EventEmitter()
eve.$on('click', function (data, two) {
console.log('click1', data, two)
})
eve.$on('click', function (data) {
console.log('click2', data)
})
eve.$on('change', function () {
console.log('change')
})
eve.$emit('click', 1, 2)
eve.$emit('change')
虚拟 DOM
虚拟 DOM(Virtual DOM)是使用 js 对象来描述 DOM,它的本质就是 js 对象。
为什么使用虚拟 DOM
- 具备跨平台的优势,因为虚拟 DOM 是 js 对象,所以它不依赖真实的平台环境,如 weex、小程序、React Native 等
- 可以避免操作 DOM,只需关注业务代码实现过程,从而提高开发效率
- 提升渲染性能,因为不用频繁的操作真实 DOM,不过虚拟 DOM 适用于大量、频繁的更新下,如果是很简单的页面,不使用虚拟 DOM 还更好点
Vue 中创建虚拟 DOM 的过程
- 把模板使用 render 函数生成虚拟 DOM
- render 函数中通过 createElement 函数创建 vnode
- 创建完后调用 update 函数进行处理,内部调用 patch 方法把虚拟 DOM 转成真实 DOM
key 的作用
key 主要是用于 diff 算法,用来判断节点是否相同。
如果不使用 key, vue 会尽可能的尝试就地修改或复用相同类型的算法逻辑。
html
<input v-if="bol" />
<input type="password" v-else />
<!-- 这个时候没有设置 key ,当去修改 bol 的值时,输入框里面的内容不会清空,相当于vue只是修改了输入框的 type -->
<!-- 如果给两个输入框分别设置 key,则会删除之前的 输入框,再创建新的输入框 -->
对于 for 循环来说,如果使用 index 作为 key 也会导致问题,vue 也会尽可能的尝试就地修改或复用相同类型的算法逻辑。它就会导致一些固定元素(写死的元素标签),直接复用了原来索引位置的,不会重新生成。
如下面的例子:for 循环不设置 key,或者设置 key 为 index,都会导致固定元素 input[checkbox]
直接复用,比如未添加水果之前,选中了第一个苹果,点击添加之后,还是选中的第一个,但是第一个水果变成了荔枝。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="https://unpkg.com/vue@2.7.16/dist/vue.js"></script>
</head>
<body>
<div id="app">
<button @click="list.unshift('荔枝')">添加水果</button>
<li v-for="(item, index) in list" :key="index">
<input type="checkbox" />
{{item}}
</li>
</div>
<script>
var app = new Vue({
el: '#app',
data: {
list: ['苹果', '香蕉', '西瓜'],
},
})
</script>
</body>
</html>
diff 算法
遵循 同级比较,再比较子节点 的规则
- 新节点是文本节点,且跟旧节点不相等,则直接把 DOM 的内容设置为新节点的 text 内容。
- 新节点不是文本节点:
- 如果新旧节点都有子节点,并且都不相等的情况下,则调用 updateChildren 方法,通过从首尾节点对比的方式,把差异更新到 DOM 上。
- 如果新节点有子节点,旧节点没有子节点,则调用 addVnodes 方法,把新节点的子节点转成 DOM 元素,并添加到 DOM 树上。
- 如果新节点没有子节点,旧节点有子节点,则调用 removeVnodes 方法,删除旧节点中的子节点。
- 如果旧节点是文本节点,新节点没有文本节点和子节点的情况下,清空对应的 DOM 元素的文本内容
谈谈双向绑定,以及它的原理
双向绑定靠的是指令 v-model ,它是修改数据可以改变视图,修改视图也能改变数据。
输入框的 v-model 是 value + input 事件,其实 vue 还做了当输入中文的时候,在打字的过程中,不会把拼音显示在视图上,它在 input 事件中判断了
event.target.composing
为 true 的时候,直接 return 打断复选框的 v-model 是 checked + change 事件
自定义组件的 v-model
- vue2 是利用 props 的 value + emit 的 input 事件, 可以通过设置 model 来修改对于的值,不支持多个 v-model,如果需要支持多个,可以利用 .sync 修饰符
js{ model: { prop: 'title', event: 'change' }, props: ['title'] }
<!-- Parent.vue --> <UserName :title.sync="title" /> <!-- Child.vue --> { props: ['title'] } <button @click="$emit('update:title', '你好')">修改</button>
- vue3 则是利用 props 的 modelValue + emit 的 update:modelValue 事件,vue3 支持多个 v-model
- 3.4 版本之前
<!-- Parent.vue --> <UserName v-model="first" v-model:title="title" /> <!-- Child.vue --> const props = defineProps(['modelValue', 'title']) const emit = defineEmits(['update:modelValue', 'update:title']) <input :value="props.modelValue" @input="emit('update:modelValue', $event.target.value)" /> <input type="text" :value="title" @input="$emit('update:title', $event.target.value)" />
- 3.4 版本之后,利用 defineModel 方法,如果需要额外的 prop 选项,应该在 defineModel 名称之后传递:
<!-- Parent.vue --> <UserName v-model="first" v-model:title="title" /> <!-- Child.vue --> const model = defineModel({ required: true }) const title = defineModel('title', { required: true, default: 0 }) <input v-model="model" /> <input type="text" v-model="title" />