# Vue
# 1 nextTick
在下次 dom
更新循环结束之后执行延迟回调,可用于获取更新后的 dom
状态。
- 新版本中默认是
mincrotasks
,v-on
中会使用macrotasks
macrotasks
任务的实现setImmediate
/MessageChannel
/setTimeout
常见用法如下,操作dom可在 vue 生命周期 mounted 下使用:
mounted () {
this.$nextTick(() => {
// todolist
})
}
# 2 生命周期
init
initLifecycle/Event
, 往vm上挂载各种属性callHook: beforeCreated
:实例刚创建initInjection/initState
:初始化注入和data
响应性created
:创建完成,属性已经绑定,但还未生成真实dom
- 进行元素的挂载:
$el / vm.$mount()
- 是否有
template
:解析成render function
*.vue
文件:vue-loader
会将<template>
编译成render function
beforeMount
: 模板编译/挂载之前- 执行
render function
, 生成真实的dom
, 并替换到dom tree
中 mounted
: 组件已挂载
update
- 执行diff算法,比对改变是否需要触发 UI 更新
flushScheduleQueue
watcher.before
:触发beforeUpdate
钩子 -watcher.run()
: 执行watcher
中的notify
,通知所有依赖性更新UI,具体watcher的执行可参考:watcher执行详解 (opens new window)- 触发
updated
钩子:组件已更新 actived / deactivated(keep-alive)
: 不销毁,销毁,组件激活与失活destroy
beforeDestroy
: 销毁开始- 销毁自身且递归销毁子组件以及事件监听
remove()
: 删除节点watcher.teardown()
: 清空依赖vm.$off()
: 解绑监听
destroyed
: 完成后触发钩子
上面是vue的生命周期的简单梳理,接下来我们直接以代码的形式来完成vue的初始化
new Vue({})
// 初始化Vue实例
function _init () {
// 挂载属性
initLifeCycle(vm)
// 初始化事件系统,钩子函数等
initEvent(vm)
// 编译slot、vnode
initRender(vm)
// 触发钩子
callHook(vm, 'beforeCreate')
// 添加inject功能
initInjection(vm)
// 完成数据响应性 props/data/watch/computed/methods
initState(vm)
// 添加 provide 功能
initProvide(vm)
// 触发钩子
callHook(vm, 'created')
// 挂载节点
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
// 挂载节点实现
function mountComponent (vm) {
// 获取 render function
if (!this.options.render) {
// template to render
// Vue.compile = compileToFunctions
let { render } = compileToFunctions()
this.options.render = render
}
// 触发钩子
callHook('beforeMounte')
// 初始化观察者
// render 渲染 vdom
vdom = vm.render()
// update:根据 diff 出的 patchs 挂载成真实的 dom
vm._update(vdom)
// 触发钩子
callHook(vm, 'mounted')
}
// 更新节点实现
function queueWatcher (watcher) {
nextTick(flushScheduleQueue)
}
// 清空队列
function flushScheduleQueue () {
// 遍历队列中所有修改
for () {
// beforeUpdate
watcher.before()
// 依赖局部更新节点
watcher.update()
callHook('update')
}
}
// 销毁实例实现(挂载Vue上的实例方法)
Vue.prototype.$destory = function () {
// 触发钩子
callHook(vm, 'beforeDestory')
// 删除自身及子节点
remove()
// 删除依赖
watcher.teardown()
// 删除监听
vm.$off()
// 触发钩子
callHook(vm, 'destoryed')
}
# 3 Proxy 相比于 Object.defineProperty 的优势
Object.defineProperty() 的问题主要有三个:
- 不能监听数组的变化
- 必须遍历对象的每个属性
- 必须深层遍历嵌套的对象
Proxy
在 ES2015
规范中 被正式加入, 它有如下几个特点:
- 针对对象:针对整个对象,而不是对象的某个属性,所以也就不需要对keys进行遍历。这解决了上述
Object.defineProperty()
第二个问题 - 支持数组:Proxy 不需要对数组的方法进行重载,省去了众多
hack
,减少代码量等于减少了维护成本, 而且标准的就是最好的。
除了上述两点之外, Proxy
还拥有以下优势:
- Proxy的第二个参数可以有13种拦截方法,这比起
Object.defineProperty()
要更加丰富 - Proxy作为新标准受到浏览器厂商的重点关注和性能优化,相比之下
Object.defineProperty()
是一个已有的老方法。
let data = { a: 1 };
let reactiveData = new Proxy(data, {
get: function(target, name) {
// ...
},
// ...
})
# 4 vuex
state
:状态中心mutations
:更改状态 (同步操作)actions
:异步更改状态 (异步操作)getters
:获取状态modules
:将state
分成多个modules
,便于管理
action 与 mutations 的区别,以及如何触发
vuex 模块化中,我们通常会定义 state, mutations, actions
等,
state
中定义一些数据,通常是全局状态管理的数据;
mutations
也是一个对象, 这个对象里面可以存放改变state的初始化的方法,且为 同步操作
;
action
是 异步操作
, 通常我们可以调用接口信息;
一、异步操作 actions 首先我们来说下, vue 组件中如何调用 actions 中的方法?
方案一:使用 mapActions
import { mapActions } from 'vuex';
methods: {
...mapActions(['Login']),
async handleSubmit (e) {
await this.Login(e);
}
}
方案二:使用 dispactch 触发
// Login 为 actions 中的异步函数,e 为参数
this.$store.dispatch('Login', e);
我们在模块的action 中, 异步接口请求获取到数据,然后使用同步操作的方式去修改数据的初始值。
// src/store/modules/user.js
import api from '@/api/user';
const user = {
actions: {
async Login ({ commit }, userInfo) {
try {
// todolist
const { token } = res.data;
// token
commit('setToken', token)
} catch (e) {
throw e
}
}
}
}
export default user;
二、同步操作 mutations
那我们如何进行同步修改数据的呢? commit, 使用 commit 提交 mutation, 从而进行同步操作。
const user = {
mutations: {
setToken: (state, token) => {
state.token = token;
}
}
}
export default user;
同样提交至 mutations 也有两种方案。
方案一:使用 mapMutations
import { mapMutations } from 'vuex';
methods: {
...mapMutations(['setLoginVisible']),
// 登录点击事件
toLogin () {
this.setLoginVisible(true);
}
}
方案二:使用 commit
// setLoginVisible 为 mutations 中的同步函数, true 为参数
this.$store.commit('setLoginVisible', true)
在模块的 mutations 中,修改数据如下:
const user = {
mutations: {
setLoginVisible: (state, loginVisible) => {
state.loginVisible = loginVisible;
}
}
}
export default user;
总结:actions 可以进行异步操作,可用于向后台提交数据或接收后台的数据;mutations中是同步操作,用于将数据信息写在全局数据状态中缓存。
# 5 vue-router 有哪几种导航守卫
- 全局守卫
- 路由独享守卫
- 路由组件内的守卫
全局守卫 vue-router 全局有三个守卫:
- router.beforeEach 全局前置守卫 进入路由之前
- router.beforeResolve 全局解析守卫 (2.5.0+) 在 beforeRouteEnter 调用之后调用
- router.afterEach 全局后置守卫 钩子 进入路由之后
// main.js 入口文件
import router from './router'; // 引入路由
router.beforeEach((to, from, next) => {
next();
})
router.beforeResolve((to, from, next) => {
next();
})
router.afterEach((to, from) => {
console.log('afterEach 全局后置钩子')
})
路由独享守卫 也可以单独为某些路由单独配置守卫
const router = new VueRouter({
routes: [
{
path: '/foo',
component: Foo,
beforeEnter: (to, from, next) => {
// 参数用法什么的都一样,调用顺序在全局前置守卫后面,所以不会被全局首位覆盖
}
}
]
})
路由组件内的守卫
beforeRouterEnter
进入路由前,在路由独享守卫后调用 不能获取组件实例this
, 组件实例还没被创建beforeRouteUpdate
路由复用同一个组件时,在当前路由改变,但是该组件被复用时调用 可以访问组件实例this
beforeRouteLeave
离开当前路由时,导航离开该组件的对应路由时调用,可以访问组件实例this
# 6 Vue 的路由实现:hash / history 模式
hash 模式:
- 浏览器 URL 中会显示 #
, #
以及 #
后面的字符称之为 hash
, 可以用 window.location.hash
读取
- 监听 hash
模式用的是 hashchange
特点
- hash 虽然在 URL 中, 但不被包括在 HTTP 请求中
- hash 不会重加载页面
history 模式:
- history 采用 HTML5 的新特性, 且提供了两个新方法;
- pushState()
, replaceState()
, 可以对浏览器历史记录栈进行修改,只是当它们执行修改时,虽然改变了当前的 URL, 但浏览器不会立即向后端发送请求;
- 监听 history
模式用的是 popstate
- 前端的 URL 必须和实际向后端发送请求的 URL 一致
白话文 + 图文理解:
- 形式上:hash模式url里面永远带着 # 号,开发当中默认使用这个模式。如果用户考虑url的规范那么久需要使用history模式,因为history模式没有#号,是这个正常的url, 适合推广宣传;
- 功能上:比如我们再开发app的时候有分享页面,name这个分享出去的页面就是用vue或react做的,咱们就把这个页面分享到第三方的app里, 有的app里面url是不允许带#号,所有要将#号去掉那么就要用history模式,但是使用history模式还有一个问题就是,在访问二级页面时,做刷新操作,会出现404错误,那么久需要和后端配合,配置apache或nginx的 url 重定向, 重定向首页路由就 ok 了。
- 特性: hash 模式 和 history 模式都属于浏览器自身的特性, Vue-Router 只是利用了这两个特性 (通过调用浏览器提供的接口) 来实现前端路由。
- hash模式(默认)、history模式(需配置mode: 'history')
总结
- 传统的路由指的是:当用户访问一个url时,对应的服务器会接收这个请求,然后解析url中的路径,从而执行对应的逻辑处理。这样就完成了一次路由分发
- 而前端路由是不涉及服务器的,是前端利用hash或者HTML5的history API来实现的,一般用于不同内容的展示和切换
# 7 vue 某一个对象清空 value 值,保留key
其中就是将一个对象的属性copy到另一对象
vue中:
$options 属于 vue 源码 初始化vue的选项
this.$data
获取当前状态下的 data
this.$options.data()
获取该组件初始状态下的 data
所以,下面就可以将初始状态的 data
复制到当前状态的 data
,实现重置效果
Object.assign(this.$data, this.$options.data())
当然,如果你只想重置 data
中的某一个对象或者属性;
this.form = this.$options.data().form;
某一个对象 清空 value
,保留key
Object.keys(form).forEach((key) => (form[key] = ''))
# 8 事件总线(EventBus)
许多现代框架和库的核心概念是能够将数据和UI封装在模块化、可重用的组件中。这对于开发人员可以在开发整个应用程序时避免使用编写大量重复的代码。虽然这样做非常有用,但也涉及到组件之间的数据通讯。在Vue中同样有这样的概念存在。通过前面一段时间的学习,Vue组件数据通讯常常会有父子组件,兄弟组件之间的数据通讯。也就是说在Vue中组件通讯有一定的原则。
建议把Vue核心源码看一遍,这样更能轻松理解实现的原理 [Vue 核心源码解读](https://1996f.cn/content/Source/Vue/Vue2/ || https://blog.canuse.top/content/Source/Vue/Vue2/)
# 父子组件通讯原则
为了提高组件的独立性与重用性,父组件会通过 props
向下传数据给子组件,当子组件有事情要告诉父组件时会通过 $emit
事件告诉父组件。如此确保每个组件都是独立在相对隔离的环境中运行,可以大幅提高组件的维护性。
在《Vue组件通讯》一文中有详细介绍过这部分。但这套通讯原则对于兄弟组件之间的数据通讯就有一定的诟病。当然,在Vue中有其他的方式来处理兄弟组件之间的数据通讯,比如Vuex这样的库。但在很多情况之下,咱们的应用程序不需要类似Vuex这样的库来处理组件之间的数据通讯,而可以考虑Vue中的 事件总线 ,即 EventBus
。
接下来的内容,就是来一起学习Vue中的 EventBus
相关的知识点。
# EventBus的简介
EventBus
又称为事件总线。在Vue中可以使用 EventBus
来作为沟通桥梁的概念,就像是所有组件共用相同的事件中心,可以向该中心注册发送事件或接收事件,所以组件都可以上下平行地通知其他组件,但也就是太方便所以若使用不慎,就会造成难以维护的灾难,因此才需要更完善的Vuex作为状态管理中心,将通知的概念上升到共享状态层次。
# 如何使用EventBus
在Vue的项目中怎么使用 EventBus
来实现组件之间的数据通讯呢?具体可以通过下面几个步骤来完成。
# 初始化
首先你需要做的是创建事件总线并将其导出,以便其它模块可以使用或者监听它。我们可以通过两种方式来处理。先来看第一种,新创建一个 .js
文件,比如 event-bus.js
:
// event-bus.js
import Vue from 'vue'
export const EventBus = new Vue()
你需要做的只是引入 Vue 并导出它的一个实例(在这种情况下,我称它为 EventBus
)。实质上它是一个不具备 DOM 的组件,它具有的仅仅只是它实例方法而已,因此它非常的轻便。
另外一种方式,可以直接在项目中的 main.js
初始化 EventBus
:
// main.js
Vue.prototype.$EventBus = new Vue()
注意,这种方式初始化的 EventBus
是一个 全局的事件总线 。稍后我们会花点时间专门聊一聊全局的事件总线。
现在我们已经创建了 EventBus
,接下来你需要做到的就是在你的组件中加载它,并且调用同一个方法,就如你在父子组件中互相传递消息一样。
# 发送事件
假设你有两个子组件: DecreaseCount
和 IncrementCount
,分别在按钮中绑定了 decrease()
和 increment()
方法。这两个方法做的事情很简单,就是数值递减(增) 1
,以及角度值递减(增) 180
。在这两个方法中,通过 EventBus.$emit(channel: string, callback(payload1,…))
监听 decreased
(和 incremented
)频道。
<!-- DecreaseCount.vue -->
<template>
<button @click="decrease()">-</button>
</template>
<script> import { EventBus } from "../event-bus.js";
export default {
name: "DecreaseCount",
data() {
return {
num: 1,
deg:180
};
},
methods: {
decrease() {
EventBus.$emit("decreased", {
num:this.num,
deg:this.deg
});
}
}
};
</script>
<!-- IncrementCount.vue -->
<template>
<button @click="increment()">+</button>
</template>
<script> import { EventBus } from "../event-bus.js";
export default {
name: "IncrementCount",
data() {
return {
num: 1,
deg:180
};
},
methods: {
increment() {
EventBus.$emit("incremented", {
num:this.num,
deg:this.deg
});
}
}
};
</script>
上面的示例,在 DecreaseCount
和 IncrementCount
分别发送出了 decreased
和 incremented
频道。接下来,我们需要在另一个组件中接收这两个事件,保持数据在各组件之间的通讯。
# 接收事件
现在我们可以在组件 App.vue
中使用 EventBus.$on(channel: string, callback(payload1,…))
监听 DecreaseCount
和 IncrementCount
分别发送出了 decreased
和 incremented
频道。
<!-- App.vue -->
<template>
<div id="app">
<div class="container" :style="{transform: 'rotateY(' + degValue + 'deg)'}">
<div class="front">
<div class="increment">
<IncrementCount />
</div>
<div class="show-front"> {{fontCount}} </div>
<div class="decrement">
<DecreaseCount />
</div>
</div>
<div class="back">
<div class="increment">
<IncrementCount />
</div>
<div class="show-back"> {{backCount}} </div>
<div class="decrement">
<DecreaseCount />
</div>
</div>
</div>
</div>
</template>
<script>
import IncrementCount from "./components/IncrementCount";
import DecreaseCount from "./components/DecreaseCount";
import { EventBus } from "./event-bus.js";
export default {
name: "App",
components: {
IncrementCount,
DecreaseCount
},
data() {
return {
degValue:0,
fontCount:0,
backCount:0
};
},
mounted() {
EventBus.$on("incremented", ({num,deg}) => {
this.fontCount += num
this.$nextTick(()=>{
this.backCount += num
this.degValue += deg;
})
});
EventBus.$on("decreased", ({num,deg}) => {
this.fontCount -= num
this.$nextTick(()=>{
this.backCount -= num
this.degValue -= deg;
})
});
}
};
</script>
最终得到的效果如下:
最后用一张图来描述示例中用到的 EventBus
之间的关系:
如果你只想监听一次事件的发生,可以使用 EventBus.$once(channel: string, callback(payload1,…))
。
# 移除事件监听者
如果想移除事件的监听,可以像下面这样操作:
import { eventBus } from './event-bus.js'
EventBus.$off('decreased', {})
你也可以使用 EventBus.$off(‘decreased’)
来移除应用内所有对此事件的监听。或者直接调用 EventBus.$off()
来移除所有事件频道, 注意不需要添加任何参数 。
上面就是 EventBus
的使用方式,是不是很简单。上面的示例中我们也看到了,每次使用 EventBus
时都需要在各组件中引入 event-bus.js
。事实上,我们还可以通过别的方式,让事情变得简单一些。那就是创建一个全局的 EventBus
。接下来的示例向大家演示如何在Vue项目中创建一个全局的 EventBus
。
# 全局EventBus
全局EventBus
,虽然在某些示例中不提倡使用,但它是一种非常漂亮且简单的方法,可以跨组件之间共享数据。
它的工作原理是 发布/订阅方法
,通常称为 Pub/Sub
。
这整个方法可以看作是一种设计模式,因为如果你查看它周围的东西,你会发现它更像是一种体系结构解决方案。我们将使用普通的 JavaScript
,并创建两个组件,并演示 EventBus
的工作方式。
让我们看看下图,并试着了解在这种情况下究竟发生了什么。
我们从上图中可以得出以下几点:
- 有一个全局EventBus
- 所有事件都订阅它
- 所有组件也发布到它,订阅组件获得更新
- 总结一下。所有组件都能够将事件发布到总线,然后总线由另一个组件订阅,然后订阅它的组件将得到更新
在代码中,我们将保持它非常小巧和简洁。我们将它分为两部分,将展示两个组件以及生成事件总线的代码。
# 创建全局EventBus
全局事件总线只不过是一个简单的 vue
组件。代码如下:
var EventBus = new Vue();
Object.defineProperties(Vue.prototype, {
$bus: {
get: function () {
return EventBus
}
}
})
现在,这个特定的总线使用两个方法 $on
和 $emit
。一个用于创建发出的事件,它就是$emit
;另一个用于订阅 $on
:
var EventBus = new Vue();
this.$bus.$emit('nameOfEvent',{ ... pass some event data ...});
this.$bus.$on('nameOfEvent',($event) => {
// ...
})
现在,我们创建两个简单的组件,以便最终得出结论。
接下来的这个示例中,我们创建了一个 ShowMessage
的组件用来显示信息,另外创建一个 UpdateMessage
的组件,用来更新信息。
在 UpdateMessage
组件中触发需要的事件。在这个示例中,将触发一个 updateMessage
事件,这个事件发送了 updateMessage
的频道:
<!-- UpdateMessage.vue -->
<template>
<div class="form">
<div class="form-control">
<input v-model="message" >
<button @click="updateMessage()">更新消息</button>
</div>
</div>
</template>
<script>
export default {
name: "UpdateMessage",
data() {
return {
message: "这是一条消息"
};
},
methods: {
updateMessage() {
this.$bus.$emit("updateMessage", this.message);
}
},
beforeDestroy () {
$this.$bus.$off('updateMessage')
}
};
</script>
同时在 ShowMessage
组件中监听该事件:
<!-- ShowMessage.vue -->
<template>
<div class="message">
<h1>{{ message }}</h1>
</div>
</template>
<script>
export default {
name: "ShowMessage",
data() {
return {
message: "我是一条消息"
};
},
created() {
var self = this
this.$bus.$on('updateMessage', function(value) {
self.updateMessage(value);
})
},
methods: {
updateMessage(value) {
this.message = value
}
}
};
</script>
最终的效果如下:
从上面的代码中,我们可以看到 ShowMessage
组件侦听一个名为 updateMessage
的特定事件,这个事件在组件实例化时被触发,或者你可以在创建组件时触发。另一方面,我们有另一个组件 UpdateMessage
,它有一个按钮,当有人点击它时会发出一个事件。这导致订阅组件侦听发出的事件。这产生了 Pub/Sub 模型
,该模型在兄弟姐妹之间持续存在并且非常容易实现。
# 总结
本文主要通过两个实例学习了Vue中有关于 EventBus
相关的知识点。主要涉及了 EventBus
如何实例化,又是怎么通过 $emit
发送频道信号,又是如何通过 $on
来接收频道信号。最后简单介绍了怎么创建全局的 EventBus
。从实例中我们可以了解到, EventBus
可以较好的实现兄弟组件之间的数据通讯。
# 9 vuejs内部运行机制全局概览
# 全局概览
这一节介绍 Vue.js 内部的整个流程,希望能让大家对全局有一个整体的印象,然后我们再来逐个模块进行讲解。从来没有了解过 Vue.js 实现的同学可能会对一些内容感到疑惑,这是很正常的,这一节的目的主要是为了让大家对整个流程有一个大概的认识,算是一个概览预备的过程,当把整本小册认真读完以后,再来阅读这一节,相信会有收获的。
首先我们来看一下内部流程图。
大家第一次看到这个图一定是一头雾水的,没有关系,我们来逐个讲一下这些模块的作用以及调用关系。相信讲完之后大家对 Vue.js 内部运行机制会有一个大概的认识。
# 初始化挂载
在 new Vue()
之后。 Vue 会调用 _init
函数进行初始化,也就是这里的 init
过程, 它会初始化生命周期、事件、props、methods、data、computed 与 watch 等。其中最重要的是通过 Object.defineProperty
设置 setter
与 getter
函数,用来实现 【响应式
】以及【依赖收集
】,后面会详细讲到。
初始化之后调用 $mount
会挂载组件,如果是运行时编译,即不存在 render function 但是存在 template 的情况,需要进行【编译
】步骤。
# 编译
compile 表一可以分成 parse
、optimize
与 generate
三个阶段,最终需要得到 render function。
# parse
parse
会用正则等方式解析 template 模板中的指令、class、style 等数据,形成 AST。
# optimize
optimize
的主要作用是标记 static 静态节点,这是 vue 在编译过程中的一处优化,后面当 update
更新界面时,会有一个 patch
的过程,diff算法会直接跳过静态节点,从而减少了比较的过程,优化了 patch
的性能。
# generate
generate
是将 AST 转换成 render function 字符串的过程,得到结果是 render 的字符串以及 staticRenderFns 字符串。
在经历过 patch
、optimize
与 generate
这三个阶段以后,组件中就会存在渲染 VNode 所需的 render function
了。
# 响应式
接下来就是 vue.js 响应式核心部分
这里的 getter
跟 setter
已经介绍过了,在 init
的时候通过 Object.defineProperty
进行了绑定,它使得当被设置的对象被读取的时候会执行 getter
函数, 而在被赋值的时候会执行 setter
函数。
当 render function 被渲染的时候,因为会读取所需对象的值,所以会触发 getter
函数进行 【依赖收集
】,【依赖收集
】的目的是将观察者 Watcher 对象存放到当前闭包中的订阅者 Dep 的 subs 中。 形成如下所示的一个关系。
在修改对象的值的时候,会触发对应的 setter
,setter
通知之前的 【依赖收集
】 得到的Dep 中的每一个 Watcher
,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher 就会开始调用 update
来更新视图,当然这中间还有一个 patch
的过程以及使用队列来异步更新的策略,这个后面再讲。
# Virtual Dom
我们知道,render function 会被转化成 VNode 节点。Virtual DOM 其实就是一棵以 js 对象(VNode节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。由于 Virtual DOM 是以 js 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node等。
比如说下面这样一个例子:
{
tag: 'div',
children: [
{
tag: 'a',
text: 'click me'
}
]
}
渲染后可以得到
<div>
<a>click me</a>
</div>
这只是一个简单的例子,实际上的节点有更多的属性来标志节点,比如 isStatic (代表是否为静态节点)、isComment(代表是否为注释节点)等。
# 更新视图
前面我们说到,在修改一个对象值的时候,会通过 setter -> Watcher -> update
的流程来修改对应的视图,那么最终是如何更新视图的呢?
当数据变化后,执行render function 就可以得到一个新的 VNode 节点,我们如果想要得到新的视图,最简单粗暴的方法就是直接解析这个新的 VNode 节点,然后用 innerHTML
直接全部渲染到真实 DOM 中,但是其实我们只是对其中的一小块内容进行了修改,这样做似乎有些【浪费
】。
那么我们为什么不能只修改那些「改变了的地方」呢?这个时候就要介绍我们的「patch
」了。我们会将新的 VNode
与旧的 VNode
一起传入 patch
进行比较,经过 diff 算法
得出它们的「差异」。最后我们只需要将这些「差异」的对应 DOM 进行修改即可。
# 再看全局
回过头再来看看这张图,是不是大脑中已经有一个大概的脉络了呢?
# 10 响应式系统的基本原理
# 响应式系统
Vue.js 是一款 MVVM 框架,数据模型仅仅是普通的 js 对象,但是对这些对象进行操作时,却能影响对应视图,它的核心实现是【响应式系统
】。尽管我们在使用 vue.js 进行开发时不会直接修改【响应式系统
】,但是理解它的实现有助于避开一些常见的【坑
】,也有助于在遇见一些捉摸不透的问题可以深入其原理来解决它。
# Object.defineProperty
首先来介绍一下 Object.defineProperty
, vue.js 就是基于它实现【响应式系统
】的。
首先是使用方法
/**
* obj:目标对象
* prop:需要操作的目标对象的属性名
* descriptor:描述符
* return value 传入对象
*/
Object.defineProperty(obj, prop, descriptor);
descriptor 的一些属性,简单介绍几个属性,具体可以参考 MDN 文档
。
enumerable
, 属性是否可枚举,默认 false。configurable
, 属性是否可以被修改或者删除,默认 false。get
, 获取属性的方法。set
, 设置属性的方法。
# 实现 observer
(可观察)
知道了 Object.defineProperty
以后,我们来用它使对象变成可观察的。
这一部分的内容我们在第二小节中已经初步介绍过,在 inite
的阶段会进行初始化,对数据进行【响应式化】。
为了便于理解,我们不考虑数组等复杂的情况,只对对象进行处理。
首先我们定义一个 cb
函数,这个函数用来模拟视图更新,调用它即代表更新视图,内部可以是一些更新视图的方法。
function cb(val) {
/* 渲染视图 */
console.log("视图更新啦~");
}
然后我们定义一个 defineReactive
, 这个方法通过 Object.defineProperty
来实现对对象的 【响应式】化,入参是一个obj(需要绑定的对象)、key(obj的某一个属性),val(具体的值)。经过 defineReactive
处理以后,我们的obj的key属性在【读】的时候会触发 reactiveGetter
方法,而在该属性被【写】的时候则会触发 reactiveSetter
方法。
function defineReactive (obj, key, val) {
Object.defineProperty(obj, key, {
enumerable: true, // 属性可枚举
configurable: true, // 属性可被修改或删除
get: function reactiveGetter () {
return val; // 实际上会依赖收集
},
set: function reactiveSetter (newVal) {
if (newVal === val) return;
cb(newVal);
}
})
}
当然这是不够的,我们需要在上面再封装一层 observer
。 这个函数传入一个value(需要【响应式】化的对象),通过遍历所有属性的方式对该对象的每一个属性都通过 defineReactive
处理。(注:实际上 observer 会进行递归调用,为了便于理解去掉了递归的过程)
function observer (value) {
if (!value || (typeof value !== 'object')) return;
Object.keys(value).forEach((key) => {
defineReactive(value, key, value[key])
})
}
最后,让我们用 observer
来封装一个vue吧!
在 Vue 的构造函数中,对 options
的 data
进行处理,这里的 data
相比大家很熟悉,就是平时在写 vue 项时 组件中的 data
属性(实际上是一个函数,这里当做一个对象来简单处理)。
class Vue {
// Vue 构造类
constructor (options) {
this._data = options.data;
observer(this._data);
}
}
这样我们只要 new 一个 Vue 对象,就会将 data
中的数据进行 【响应式
】化。如果我们对 data
的属性进行下面的操作,就会触发 cb
方法更新视图。
let o = new Vue({
data: {
test: "I am test."
}
})
o._data.test = "hello, world."
# 11 响应式系统的依赖收集追踪原理
# 为什么要依赖收集?
先举个例子:
我们现在有这么一个 Vue 对象。
new Vue({
template: `<div>
<span>{{text1}}</span>
<span>{{text2}}</span>
</div>`,
data: {
text1: 'text1',
text2: 'text2',
text3: 'text3',
}
})
然后我们做了这么一个操作。
this.text3 = "modify text3";
我们修改了 data
中 text3
的数据,但是因为视图中并不需要用到 text3
, 所以我们并不需要触发上一章所讲的 cb
函数来更新视图,调用 cb
显然是不正确的。
再来一个例子:
假设我们现在有一个全局的对象,我们可能会在多个 Vue 对象中用到它进行展示。
let globalObj = {
text1: 'text1',
};
let o1 = new Vue({
template: `
<div>
<span>{{text1}}</span>
</div>
`,
data: globalObj
});
let o2 = new Vue({
template: `<div>
<span>{{text1}}</span>
</div>`,
data: globalObj
});
这个时候,我们执行了如下操作。
global.text1 = 'hello,text1';
我们应该需要通知 o1
以及 o2
两个 vm 实例进行视图的更新。 【依赖收集
】会让 text1
这个数据知道“哦~有两个地方依赖我的数据,我变化的时候需要通知它们~”。
最终会形成数据与视图的一种对应关系,如下图。
接下来我们来介绍一下【依赖收集】是如何实现的。
# 订阅者 Dep
首先我们来实现一个订阅者Dep,它的主要作用是用来存放 Watcher
观察者对象。
class Dep {
constructor () {
// 用来存放 Watcher 对象的数组
this.subs = [];
}
// 在subs中添加一个 Watcher对象
addSub (sub) {
this.subs.push(sub);
}
// 通知所有watcher对象更新视图
notify () {
this.subs.forEach((sub) => {
sub.update();
})
}
}
为了便于理解我们只实现了添加的部分代码,主要是两件事情:
- 用
addSub
方法可以在目前的 Dep 对象中增加一个 watcher 的订阅操作; - 用
notify
方法通知目前 Dep 对象的 subs 中的所有 watcher 对象触发更新操作;
# 观察者 Watcher
class Watcher {
constructor () {
// 在new一个watcher对象时将该对象赋值给 Dep.target,在get中会用到
Dep.target = this;
}
// 更新视图的方法
update () {
console.log("视图更新啦~")
}
}
Dep.target = null;
# 依赖收集
接下来我们修改一下 defineReactive
以及 Vue 的构造函数,来完成依赖收集。
我们在闭包中增加一个 Dep 类的对象,用来收集 Watcher
对象。在对象被 [读] 的时候,会触发 reactiveGetter
函数把当前的 Watcher
对象(存放在 Dep.target 中)收集到 Dep
类中去。之后如果当该对象被 [写] 的时候,则会触发 reactiveSetter
方法,通知 Dep
类调用 notify
来触发所有 Watcher
对象的 update
方法更新对应视图。
function defineReactive (obj, key, val) {
// 一个Dep类对象
const dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
// 将 Dep.target (即当前的watcher对象存入dep的subs中)
dep.addSub(Dep.target);
return val;
},
set: function reactiveSetter (newVal) {
if (newVal === val) return;
// 在set的时候触发dep的notify方法来通知所有的watcher观察者对象调用自身 update 方法来更新视图
dep.notify();
}
})
}
class Vue {
constructor (options) {
this._data = options.data;
observer(this._data); // 一般这个接下来会做一个Object,keys(data) 来递归调用defineReactive方法
// 新建一个watcher观察者对象,这时候dep.target会指向这个watcher对象
new Watcher();
// 在这里模拟render的过程,为了触发test属性的get函数
console.log(`render~: ${this._data.test}`);
}
}
# 小结
总结一下。
首先在 observer
的过程中会注册 get
方法,该方法用来进行 【依赖收集
】(dep.addSub(Dep.target)
)。在它的闭包中会有一个 Dep
对象, 这个对象用来存放 Watcher
对象的实例。 其实 【依赖收集
】的过程就是把 Watcher
实例存放到对应的 Dep
对象中去。 get
方法可以让当前 watcher
对象(Dep.target
)存放到它的 subs
中 (addSub
)方法,在数据变化时,set
会调用 Dep
对象的 notify
方法 通知它内部所有的 watcher
对象用自身 update
方法进行视图更新。 (vm._update(vm._render())
)
这是 Object.defineProperty
的 set/get
方法处理的事情,那么【依赖收集
】的前提条件还有两个:
- 触发
get
方法; - 新建一个
Watcher
对象;
这个我们在 Vue 的构造类
中处理。新建一个 Watcher
对象只需要 new 出来,这时候 Dep.target
已经指向了这个 new 出来的 watcher 对象来。而触发 get 方法也很简单,实际上只要把 render function
进行渲染,那么其中的依赖的对象都会被 【读取
】,这里我们通过打印来模拟这个过程,读取 test 来触发 get
进行 【依赖收集
】
本章我们介绍了「依赖收集
」的过程,配合之前的响应式原理,已经把整个「响应式系统
」介绍完毕了。其主要就是 get
进行「依赖收集
」。set
通过观察者
来更新视图,配合下图仔细捋一捋,相信一定能搞懂它!
注:本节代码参考 《响应式系统的依赖收集追踪原理》 (opens new window)
# 12 实现 Virtual DOM 下的一个 VNode 节点
# 什么是 VNode
我们知道,render function 会被转化成 VNode 节点。Virtual DOM 其实就是一棵以 js 对象 (VNode 节点) 作为基础的树,用对象来描述节点,实际上它只是一层对真实DOM的抽象。最终可以通过一系列操作使这棵树映射到真是环境上。由于 Virtual DOM 是以 js对象为基础而不依赖真实平台环境。所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等。
# 实现一个 VNode
VNode 归根到底就是一个 js 对象,只要这个类的一些属性可以正确直观地描述清楚当前节点的信息即可。我们来实现一个简单的 VNode
类,加入一些基本属性,为了便于理解,我们先不考虑复杂的情况。
class VNode {
constructor(tag, data, children, text, elm) {
// 当前节点的标签名
this.tag = tag;
// 当前节点的一些数据信息,比如props、attrs等数据
this.data = data;
// 当前节点的子节点,是一个数组
this.children = children;
// 当前节点的文本
this.text = text;
// 当前虚拟节点对应的真是dom节点
this.elm = elm;
}
}
比如我目前有这么一个 Vue 组件。
<template>
<span class="demo" v-show="isShow">
This is a span.
</span>
</template>
用 js 代码形式就是这样的。
function render () {
return new VNode {
"span",
{
// 指令集合数组
directives: [
{
// v-show 指令
rawName: "v-show",
expression: "isShow",
name: "show",
value: true
}
],
// 静态class
staticClass: "demo",
},
[new VNode(undefined, undefined, undefined, "This is a span.")]
}
}
看看转换成 VNode 以后的情况。
{
tag: 'span',
data: {
// 指令集合数组
directives: [
{
// v-show指令
rawName: 'v-show',
expression: 'isShow',
name: 'show',
value: true
}
],
// 静态class
staticClass: 'demo'
},
text: undefined,
children: [
// 子节点是一个文本VNode节点
{
tag: undefined,
data: undefined,
text: 'This is a span.',
children: undefined
}
]
}
然后我们可以将 VNode 进一步封装一下,可以实现一些产生常用 VNode 的方法。
- 创建一个空节点
function createEmptyVNode () {
const node = new VNode();
node.text = "";
return node;
}
- 创建一个文本节点
function createTextVNode (val) {
return new VNode(undefined, undefined, undefined, String(val));
}
- 克隆一个 VNode 节点
function cloneVNode (node) {
const cloneVnode = new VNode(
node.tag,
node.data,
node.children,
node.text,
node.elm
);
return cloneVnode;
}
总的来说,VNode 就是一个 js 对象,用 js 对象的属性来描述当前节点的一些状态,用VNode 节点的形式来模拟一棵 Virtual DOM 树。
注:本节代码参考 《实现 Virtual DOM 下的一个 VNode 节点》 (opens new window)
# 13 template 模板是怎样通过 Compile 编译的
# Compile
compile
编译可以分成 parse
、optimize
、generate
三个阶段, 最终需要得到 render function。这部分内容不算 Vue.js 的响应式核心,只是用来编译的,个人认为在精力有限的情况下不需要追究其全部的实现细节,能够把握如何解析的大致流程即可。
由于解析过程比较复杂,直接上代码可能会导致不了解这部分内容的同学一头雾水,所以笔者准备提供一个 template 的示例,通过这个示例的变化来看解析的过程。但是解析的过程及结果都是将最重要的部分抽离出来展示,希望能让读者更好地了解其核心部分的实现。
<div :class="c" class="demo" v-if="isShow">
<span v-for="item in sz">{{item}}</span>
</div>
var html = '<div :class="c" class="demo" v-if="isShow"><span v-for="item in sz">{{item}}</span></div>';
接下来的过程都会依赖这个示例来进行。
# parse
首先是 parse
, parse
会用正则等方式将 template 模板中进行字符串解析,得到指令、class、style等数据,形成 AST(在计算机科学中,抽象语法树(abstract syntax tree 或者缩写为 AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。)
这个过程比较复杂,会涉及到比较多的正则进行字符串解析,我们来看一下得到的AST的样子。
// 标签属性的map,记录了标签上属性
{
/* 标签属性的map,记录了标签上属性 */
'attrsMap': {
':class': 'c',
'class': 'demo',
'v-if': 'isShow'
},
/* 解析得到的:class */
'classBinding': 'c',
/* 标签属性v-if */
'if': 'isShow',
/* v-if的条件 */
'ifConditions': [
{
'exp': 'isShow'
}
],
/* 标签属性class */
'staticClass': 'demo',
/* 标签的tag */
'tag': 'div',
/* 子标签数组 */
'children': [
{
'attrsMap': {
'v-for': "item in sz"
},
/* for循环的参数 */
'alias': "item",
/* for循环的对象 */
'for': 'sz',
/* for循环是否已经被处理的标记位 */
'forProcessed': true,
'tag': 'span',
'children': [
{
/* 表达式,_s是一个转字符串的函数 */
'expression': '_s(item)',
'text': '{{item}}'
}
]
}
]
}
最终得到的 AST 通过一些特定的属性,能够比较清晰地描述出标签的属性以及依赖关系。
接下来我们用代码来讲解一下如何使用正则来把 template 编译成我们需要的 AST 的。
# 正则
首先我们定义一下接下来我们会用到的正则。
const ncname = "[a-zA-Z_][\\w\\-\\.]*";
const singleAttrIdentifier = /([^\s"'<div>/=]+)/;
const singleAttrAssign = /(?:=)/;
const singleAttrValues = [
/"([^"]*)"+/.source,
/'([^']*)'+/.source,
/([^\s"'=<div>`]+)/.source,
];
const attribute = new RegExp(
"^\\s*" +
singleAttrIdentifier.source +
"(?:\\s*(" +
singleAttrAssign.source +
")" +
"\\s*(?:" +
singleAttrValues.join("|") +
"))?"
);
const qnameCapture = "((?:" + ncname + "\\:)?" + ncname + ")";
const startTagOpen = new RegExp("^<" + qnameCapture);
const startTagClose = /^\s*(\/?)>/;
const endTag = new RegExp("^<\\/" + qnameCapture + "[^>]*>");
const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g;
const forAliasRE = /(.*?)\s+(?:in|of)\s+(.*)/;
# advance
因为我们解析 template 采用循环进行字符串匹配的方式,所以每次匹配解析完一段我们需要将已经匹配到的去掉,头部的指针指向接下来需要匹配的部分。
function advance (n) {
index += n;
html = html.substring(n);
}
举个例子,当我们把第一个div 的头标签全部匹配完毕以后,我们需要将这部分除去,也就是向右移动43个字符。
调用 advance
函数
advance(43);
得到结果
# parseHTML
首先我们需要定义个 parseHTML
函数,在里面我们循环解析 template 字符串。
function parseHTML () {
while (html) {
let textEnd = html.indexOf("<");
if (textEnd === 0) {
if (html.match(endTag)) {
// ...process end tag
continue;
}
} else {
// ...process text
continue;
}
}
}
parseHTML
会用 while
来循环解析 template,用正则在匹配到标签头、标签尾以及文本的时候分别进行不同的处理。直到整个 template 被解析完毕。
# parseStartTag
我们来写一个 parseStartTag
函数,用来解析起始标签(“:class="c" class="demo" v-if="isShow"”部分的内容)。
function parseStartTag () {
const start = html.match(startTagOpen);
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index,
};
advance(start[0].length);
let end, attr;
while (
!(end = html.match(startTagClose)) &&
(attr = html.match(attribute))
) {
advance(attr[0].length);
match.attrs.push({
name: attr[1],
value: attr[3]
})
}
if (end) {
match.unarySlash = end[1];
advance(end[0].length);
match.end = index;
return match;
}
}
}
首先用 startTagOpen
正则得到标签的头部,可以得到 tagName
(标签名称),同时我们需要一个数组 attrs
用来存放标签内的属性。
const start = html.match(startTagOpen);
const match = {
tagName: start[1],
attrs: [],
start: index,
};
advance(start[0].length);
接下来使用 startTagClose
与 attribute
两个正则分别用来解析标签结束以及标签内的属性。这段代码用 while
循环一直到匹配到 startTagClose
为止,解析内部所有的属性。
let end, attr;
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length);
match.attrs.push({
name: attr[1],
value: attr[3],
});
}
if (end) {
match.unarySlash = end[1];
advance(end[0].length);
match.end = index;
return match;
}
# stack
此外,我们需要维护一个 stack 栈来保存已经解析好的标签头,这样我们可以根据在解析尾部标签的时候得到所属的层级关系以及父标签。同时我们定义一个 currentParent
变量用来存放当前标签的父标签节点的引用, root
变量用来指向根标签节点。
知道这个以后,我们优化一下 parseHTML ,在 startTagOpen 的 if 逻辑中加上新的处理。
if (html.match(startTagOpen)) {
const startTagMatch = parseStartTag();
const element = {
type: 1,
tag: startTagMatch.tagName,
lowerCasedTag: startTagMatch.tagName.toLowerCase(),
attrsList: startTagMatch.attrs,
attrsMap: makeAttrsMap(startTagMatch.attrs),
parent: currentParent,
children: [],
};
if (!root) {
root = element;
}
if (currentParent) {
currentParent.children.push(element);
}
stack.push(element);
currentParent = element;
continue;
}
我们将 startTagMatch
得到的结果首先封装成 element
, 这个就是最终形成的 AST 的节点,标签节点的 type 为 1;
const startTagMatch = parseStartTag();
const element = {
type: 1,
tag: startTagMatch.tagName,
attrsList: startTagMatch.attrs,
attrsMap: makeAttrsMap(startTagMatch.attrs),
parent: currentParent,
children: [],
};
然后让 root
指向根节点的引用。
if (!root) {
root = element;
}
接着我们将当前节点的 element
放入父节点 currentParent
的 children
数组中。
if (currentParent) {
currentParent.children.push(element);
}
最后将当前节点 element
压入 stack
栈中,并将 currentParent
指向当前节点,因为接下去下一个解析如果还是头标签或者文本的话,会变成为当前节点的子节点,如果是尾标签的话,那么将会从栈中取出当前节点,这种情况我们接下来要讲。
stack.push(element);
currentParent = element;
continue;
其中的 makeAttrsMap
是将 attrs 转换成 map 格式的一个方法。
function makeAttrsMap(attrs) {
const map = {};
for (let i = 0, l = attrs.length; i < l; i++) {
map[attrs[i].name] = attrs[i].value;
}
return map;
}
# parseEndTag
同样,我们在 parseHTML
中加入对尾标签的解析函数,为了匹配如“”。
const endTagMatch = html.match(endTag);
if (endTagMatch) {
advance(endTagMatch[0].length);
parseEndTag(endTagMatch[1]);
continue;
}
用 parseEndTag
来解析尾标签,它会从 stack 栈中取出最近的跟自己标签名一致的那个元素,将 currentParent
指向那个元素,并将该元素之前的元素都从 stack 中出栈。
这里可能有同学会问,难道解析的尾元素不应该对应 stack 栈的最上面的一个元素才对吗?
其实不然,比如说可能会存在自闭合的标签,如“
”,或者是写了“ <span>
”但是没有加上“< /span>”的情况,这时候就要找到 stack 中的第二个位置才能找到同名标签。
function parseEndTag(tagName) {
let pos;
for (pos = stack.length - 1; pos >= 0; pos --) {
if (stack[pos].lowerCaseTag === tagName.toLowerCase()) {
break;
}
}
if (pos >= 0) {
stack.length = pos;
currentParent = stack[pos];
}
}
# parseText
最后是解析文本,这个比较简单,只需要将文本取出,然后有两种情况,一种是普通的文本,直接构建一个节点 push
进当前 currentParent
的 children
中即可。还有一种情况是文本是如“”
这样的 Vue.js 的表达式,这时候我们需要用 parseText
来将表达式转化成代码。
text = html.substring(0, textEnd);
advance(textEnd);
let expression;
if ((expression = parseText(text))) {
currentParent.children.push({
type: 2,
text,
expression,
});
} else {
currentParent.children.push({
type: 3,
text,
});
}
continue;
我们会用到一个 parseText
函数。
function parseText(text) {
if (!defaultTagRE.test(text)) return;
const tokens = [];
let lastIndex = (defaultTagRE.lastIndex = 0);
let match, index;
while ((match = defaultTagRE.exec(text))) {
index = match.index;
if (index > lastIndex) {
tokens.push(JSON.stringify(text.slice(lastIndex, index)));
}
const exp = match[1].trim();
tokens.push(`_s(${exp})`);
lastIndex = index + match[0].length;
}
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)));
}
return tokens.join("+");
}
我们使用一个 tokens
数组来存放解析结果,通过 defaultTagRE
来循环匹配该文本,如果是普通文本直接 push
到 tokens
数组中去,如果是表达式 (),则转化成 “_s(${exp})” 的形式。
举个例子,如果我们有这样一个文本。
<div>hello,{{name}}.</div>
最终得到 tokens
tokens = ["hello,", _s(name), "."];
最终通过 join
返回表达式
"hello" + _s(name) + ".";
# processlf 与 processFor
最后介绍一下如何处理“v-if
”以及“v-for
”这样的 Vue.js 的表达式的,这里我们只简单介绍两个示例中用到的表达式解析。
我们只需要在解析头标签的内容中加入这两个表达式的解析函数即可,在这时“v-for
”之类指令已经在属性解析时存入了 attrsMap
中了。
if (html.match(startTagOpen)) {
const startTagMatch = parseStartTag();
const element = {
type: 1,
tag: startTagMatch.tagName,
attrsList: startTagMatch.attrs,
attrsMap: makeAttrsMap(startTagMatch.attrs),
parent: currentParent,
children: [],
};
processIf(element);
processFor(element);
if (!root) {
root = element;
}
if (currentParent) {
currentParent.children.push(element);
}
stack.push(element);
currentParent = element;
continue;
}
首先我们需要定义一个 getAndRemoveAttr
函数,用来从 el
的 attrsMap
属性或是 attrsList
属性中取出 name
对应值。
function getAndRemoveAttr(el, name) {
let val;
if ((val = el.attrsMap[name]) != null) {
const list = el.attrsList;
for (let i = 0, l = list.length; i < l; i++) {
if (list[i].name === name) {
list.splice(i, 1);
break;
}
}
}
return val;
}
比如说解析示例的 div 标签属性。
getAndRemoveAttr(el, "v-for");
可有得到“item in sz”。
有了这个函数这样我们就可以开始实现 processFor
与 processIf
了。
“v-for”会将指令解析成 for
属性以及 alias
属性,而“v-if”会将条件都存入 ifConditions
数组中。
function processFor(el) {
let exp;
if ((exp = getAndRemoveAttr(el, "v-for"))) {
const inMatch = exp.match(forAliasRE);
el.for = inMatch[2].trim();
el.alias = inMatch[1].trim();
}
}
function processIf(el) {
const exp = getAndRemoveAttr(el, "v-if");
if (exp) {
el.if = exp;
if (!el.ifConditions) {
el.ifConditions = [];
}
el.ifConditions.push({
exp: exp,
block: el,
});
}
}
到这里,我们已经把 parse
的过程介绍完了,接下来看一下 optimize
。
# optimize
optimize
主要作用就跟它的名字一样,用作【优化】。
这个涉及到后面要讲 patch
的过程,因为 patch
的过程实际上是将 VNode 节点进行一层一层的比对,然后将「差异」更新到视图上。那么一些静态节点是不会根据数据变化而产生变化的,这些节点我们没有比对的需求,是不是可以跳过这些静态节点的比对,从而节省一些性能呢?
那么我们就需要为静态的节点做上一些「标记」,在 patch
的时候我们就可以直接跳过这些被标记的节点的比对,从而达到「优化
」的目的。
经过 optimize
这层的处理,每个节点会加上 static
属性,用来标记是否是静态
的。
得到如下结果。
{
'attrsMap': {
':class': 'c',
'class': 'demo',
'v-if': 'isShow'
},
'classBinding': 'c',
'if': 'isShow',
'ifConditions': [
'exp': 'isShow'
],
'staticClass': 'demo',
'tag': 'div',
/* 静态标志 */
'static': false,
'children': [
{
'attrsMap': {
'v-for': "item in sz"
},
'static': false,
'alias': "item",
'for': 'sz',
'forProcessed': true,
'tag': 'span',
'children': [
{
'expression': '_s(item)',
'text': '{{item}}',
'static': false
}
]
}
]
}
我们用代码实现一下 optimize
函数。
# isStatic
首先实现一个 isStatic
函数,传入一个 node 判断该 node 是否是静态节点。判断的标准是当 type 为 2(表达式节点)则是非静态节点,当 type 为 3(文本节点)的时候则是静态节点,当然,如果存在 if
或者 for
这样的条件的时候(表达式节点),也是非静态节点。
function isStatic(node) {
if (node.type === 2) {
return false;
}
if (node.type === 3) {
return true;
}
return !node.if && !node.for;
}
# markStatic
markStatic
为所有的节点标记上 static
,遍历所有节点通过 isStatic
来判断当前节点是否是静态节点,此外,会遍历当前节点的所有子节点,如果子节点是非静态节点,那么当前节点也是非静态节点。
function markStatic(node) {
node.static = isStatic(node);
if (node.type === 1) {
for (let i = 0, l = node.children.length; i < l; i++) {
const child = node.children[i];
markStatic(child);
if (!child.static) {
node.static = false;
}
}
}
}
# markStaticRoots
接下来是 markStaticRoots
函数,用来标记 staticRoot(静态根)
。这个函数实现比较简单,简单来将就是如果当前节点是静态节点,同时满足该节点并不是只有一个文本节点左右子节点(作者认为这种情况的优化消耗会大于收益)时,标记 staticRoot
为 true,否则为 false。
function markStaticRoots(node) {
if (node.type === 1) {
if (
node.static &&
node.children.length &&
!(node.children.length === 1 && node.children[0].type === 3)
) {
node.staticRoot = true;
return;
} else {
node.staticRoot = false;
}
}
}
# optimize
有了以上的函数,就可以实现 optimize
了。
function optimize(rootAst) {
markStatic(rootAst);
markStaticRoots(rootAst);
}
# generate
generate
会将 AST 转化成 render funtion
字符串,最终得到 render
的字符串以及 staticRenderFns
字符串。
首先带大家感受一下真实的 Vue.js 编译得到的结果。
with (this) {
return isShow
? _c(
"div",
{
staticClass: "demo",
class: c,
},
_l(sz, function(item) {
return _c("span", [_v(_s(item))]);
})
)
: _e();
}
看到这里可能会纳闷了,这些 _c
,_l
到底是什么?其实他们是 Vue.js 对一些函数的简写,比如说 _c
对应的是 createElement
这个函数。没关系,我们把它用 VNode 的形式写出来就会明白了,这个对接上一章写的 VNode 函数。
首先是第一层 div 节点。
render () {
return isShow ? (new VNode('div', {
'staticClass': 'demo',
'class': c
}, [ /*这里还有子节点*/ ])) : createEmptyVNode();
}
然后我们在 children
中加上第二层 span 及其子文本节点。
/* 渲染v-for列表 */
function renderList (val, render) {
let ret = new Array(val.length);
for (i = 0, l = val.length; i < l; i++) {
ret[i] = render(val[i], i);
}
}
render () {
return isShow ? (new VNode('div', {
'staticClass': 'demo',
'class': c
},
/* begin */
renderList(sz, (item) => {
return new VNode('span', {}, [
createTextVNode(item);
]);
})
/* end */
)) : createEmptyVNode();
}
那我们如何来实现一个 generate
呢?
# genIf
首先实现一个处理 if
条件的 genIf
函数。
function genIf(el) {
el.ifProcessed = true;
if (!el.ifConditions.length) {
return "_e()";
}
return `(${el.ifConditions[0].exp})?${genElement(
el.ifConditions[0].block
)}: _e()`;
}
# genFor
然后是处理 for
循环的函数。
function genFor(el) {
el.forProcessed = true;
const exp = el.for;
const alias = el.alias;
const iterator1 = el.iterator1 ? `,${el.iterator1}` : "";
const iterator2 = el.iterator2 ? `,${el.iterator2}` : "";
return (
`_l((${exp}),` +
`function(${alias}${iterator1}${iterator2}){` +
`return ${genElement(el)}` +
"})"
);
}
# genText
处理文本节点的函数。
function genText(el) {
return `_v(${el.expression})`;
}
# genElement
接下来实现一下 genElement
,这是一个处理节点的函数,因为它依赖 genChildren
以及 genNode
,所以这三个函数放在一起讲。
genElement
会根据当前节点是否有 if
或者 for
标记然后判断是否要用 genIf
或者 genFor
处理,否则通过 genChildren
处理子节点,同时得到 staticClass、class
等属性。
genChildren
比较简单,遍历所有子节点,通过 genNode
处理后用“,”
隔开拼接成字符串。
genNode
则是根据 type
来判断该节点是用文本节点 genText
还是标签节点 genElement
来处理。
function genNode(el) {
if (el.type === 1) {
return genElement(el);
} else {
return genText(el);
}
}
function genChildren(el) {
const children = el.children;
if (children && children.length > 0) {
return `${children.map(genNode).join(",")}`;
}
}
function genElement(el) {
if (el.if && !el.ifProcessed) {
return genIf(el);
} else if (el.for && !el.forProcessed) {
return genFor(el);
} else {
const children = genChildren(el);
let code;
code = `_c('${el.tag},'{
staticClass: ${el.attrsMap && el.attrsMap[":class"]},
class: ${el.attrsMap && el.attrsMap["class"]},
}${children ? `,${children}` : ""})`;
return code;
}
}
# generate
最后我们使用上面的函数来实现 generate
,其实很简单,我们只需要将整个 AST
传入后判断是否为空,为空则返回一个 div
标签,否则通过 generate
来处理。
function generate(rootAst) {
const code = rootAst ? genElement(rootAst) : '_c("div")';
return {
render: `with(this){return ${code}}`,
};
}
function generate(rootAst) {
const code = rootAst ? genElement(rootAst) : '_c("div")';
return {
render: `with(this){return ${code}}`,
};
}
经历过这些过程以后,我们已经把 template 顺利转成了 render function 了,接下来我们将介绍 patch
的过程,来看一下具体 VNode 节点如何进行差异的比对。
注:本节代码参考 《template 模板是怎样通过 Compile 编译的》 (opens new window)。
# 14 数据状态更新时的差异 diff 及 patch 机制
# 数据更新视图
之前讲到,在对 model
进行操作的时候,会触发对应 Dep
中的 Watcher
对象。 Watcher
对象会调用对应的 update
来修改视图。最终是将新产生的 VNode 节点与老 VNode 进行一个 patch
的过程,比对得出 【差异】,最终将这些【差异】更新到视图上。
这一张就来介绍一下这个 patch
的过程,因为 patch
过程本身比较复杂,这一章的内容会比较多,但是不要害怕,我们逐块代码去看,一定可以理解。
# 跨平台
因为使用了 Virtual DOM 的原因,Vue.js 具有了跨平台的能力,Virtual DOM 终归只是一些 js 对象罢了,那么最终是如何调用不同平台的 API 的呢?
这就需要依赖一层适配层了,将不同平台的 API 封装在内,以同样的接口对外提供。
const nodeOps = {
setTextContent(text) {
if (platform === "weex") {
node.parentNode.setAttr("value", text);
} else if (platform === "web") {
node.textContent = text;
}
},
parentNode() {
//......
},
removeChild() {
//......
},
nextSibling() {
//......
},
insertBefore() {
//......
},
};
举个例子,现在我们有上述一个 nodeOps 对象做适配,根据 platform 区分不同平台来执行当前平台对应的 API,而对外则是提供了一致的接口,供 Virtual DOM 来调用。
# 一些 API
接下来我们来介绍其他的一些 API,这些 API 在下面 patch
的过程中会被用到,他们最终都会调用 nodeOps
中的相应函数来操作平台。
insert
用来在 parent
这个父节点下插入一个子节点,如果指定了 ref
则插入到 ref
这个子节点前面。
function insert(parent, elm, ref) {
if (parent) {
if (ref) {
if (ref.parentNode === parent) {
nodeOps.insertBefore(parent, elm, ref);
}
} else {
nodeOps.appendChild(parent, elm);
}
}
}
createElm
用来新建一个节点, tag
存在创建一个标签节点,否则创建一个文本节点。
function createElm (vnode, parentElm, refElm) {
if (vnode.tag) {
insert(parentElm, nodeOps.createElement(vnode.tag), refElm);
} else {
insert(parentElm, nodeOps.createTextNode(vnode.text), refElm);
}
}
addVnodes
用来批量调用 createElm
新建节点
function addVnodes (parentElm, refElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
createElm(vnodes[startIdx], parentElm, refElm);
}
}
removeNode
用来移除一个节点。
function removeNode (el) {
const parent = nodeOps.parentNode(el);
if (parent) {
nodeOps.removeChild(parent, el);
}
}
removeVnodes
会批量调用 removeNode
移除节点。
function removeVnodes(parentElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx];
if (ch) {
removeNode(ch.elm);
}
}
}
# patch
首先说一下 patch
的核心 diff
算法, 我们用 diff 算法
可以比对出两棵树的【差异】,我们来看一下,假设我们现在有如下两棵树,它们分别是新老 VNode
节点,这时候到了 patch
的过程,我们需要将他们进行比对。
diff 算法
是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有 O(n),是一种相当高效的算法,如下图。
这张图中的相同颜色的方块中的节点会进行比对,比对得到「差异」后将这些「差异」更新到视图上。因为只进行同层级的比对,所以十分高效。
patch
的过程相当复杂,我们先用简单的代码来看一下。
function patch (oldVnode, vnode, parentElm) {
if (!oldVnode) {
addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
} else if (!vnode) {
removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
} else {
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode);
} else {
removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
}
}
}
因为 patch
的主要功能是比对两个 VNode 节点,将「差异」更新到视图上,所以入参有新老两个 VNode 以及父节点的 element 。我们来逐步捋一下逻辑, addVnodes
、 removeVnodes
等函数后面会讲。
首先在 oldVnode
(老 VNode 节点)不存在的时候,相当于新的 VNode 替代原本没有的节点,所以直接用 addVnodes
将这些节点批量添加到 parentElm
上。
if (!oldVnode) {
addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
}
然后同理,在 vnode
(新 VNode 节点)不存在的时候,相当于要把老的节点删除,所以直接使用 removeVnodes
进行批量的节点删除即可。
else if (!vnode) {
removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
}
最后一种情况,当 oldVNode
与 vnode
都存在的时候,需要判断它们是否属于 sameVnode
(相同的节点)。如果是则进行 patchVnode
(比对 VNode )操作,否则删除老节点,增加新节点。
if (sameVnode(oldVNode, vnode)) {
patchVnode(oldVNode, vnode);
} else {
removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
}
# sameVnode
上面这些比较好理解,下面我们来看看什么情况下两个 VNode 会属于 sameVnode (相同的节点)呢?
function sameVnode () {
return (
a.key === b.key &&
a.tag === b.tag &&
a.isComment === b.isComment &&
!!a.data === !!b.data &&
sameInputType(a, b)
);
}
function sameInputType (a, b) {
if (a.tag !== "input") return true;
let i;
const typeA = (i = a.data) && (i = i.attrs) && i.type;
const typeB = (i == b.data) && (i = i.attrs) && i.type;
return typeA == typeB;
}
sameVnode
其实很简单,只有当 key
、 tag
、 isComment
(是否为注释节点)、data
同时定义(或不定义),同时满足当标签为 input 的时候 type 相同(某些浏览器不支持动态修改input 文本类型,所以他们被视为不同类型)即可。
# patchVnode
之前 patch
的过程还剩下 patchVnode
这个函数没有讲,这也是最复杂的一个,我们现在来看一下。因为这个函数是在符合 sameVnode
的条件下触发的,所以会进行「比对
」。
function patchVnode(oldVnode, vnode) {
if (oldVnode === vnode) {
return;
}
if (vnode.isStatic && oldVnode.isStatic && vnode.key === oldVnode.key) {
vnode.elm = oldVnode.elm;
vnode.componentInstance = oldVnode.componentInstance;
return;
}
const elm = (vnode.elm = oldVnode.elm);
const oldCh = oldVnode.children;
const ch = vnode.children;
if (vnode.text) {
nodeOps.setTextContent(elm, vnode.text);
} else {
if (oldCh && ch && oldCh !== ch) {
updateChildren(elm, oldCh, ch);
} else if (ch) {
if (oldVnode.text) nodeOps.setTextContent(elm, "");
addVnodes(elm, null, ch, 0, ch.length - 1);
} else if (oldCh) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else if (oldVnode.text) {
nodeOps.setTextContent(elm, "");
}
}
}
首先在新老 VNode 节点相同的情况下,就不需要做任何改变了,直接return掉。
if (oldVnode === vnode) {
return;
}
下面的这种情况也叫简单,在当新老 VNode 节点都是 isStatic
(静态的),并且 key
相同时,只要将 componentInstance
与 elm
从老 VNode 节点 "拿过来" 即可。这里的 isStatic
也就是前面提到过的 【编译】的时候会将静态节点标记出来,这样就可以跳过比对的过程。
if (vnode.isStatic && oldVnode.isStatic && vnode.key === oldVnode.key) {
vnode.elm = oldVnode.elm;
vnode.componentInstance = oldVnode.componentInstance;
}
接下来,当新 VNode 节点是文本节点的时候,直接用 setTextContent
来设置 text
,这里的 nodeOps 是一个适配层,根据不同平台提供不同的操作平台 DOM 的方法,实现跨平台。
if (vnode.text) {
nodeOps.setTextContent(elm, vnode.text);
}
当新 VNode 节点是非文本节点的时候,需要分几种情况。
oldCh
与ch
都存在且不相同时,使用updateChildren
函数来更新子节点,这个后面重点讲。- 如果只有
ch
存在的时候,如果老节点是文本节点则先将节点的文本清除,然后将ch
批量插入到节点 elm 下。 - 同理当只有
oldch
存在时,说明需要将老节点通过removeVnodes
全部清除。 - 最后一种情况是当只有老节点是文本节点的时候,清除其节点文本内容。
if (oldCh && ch && oldCh !== ch) {
updateChildren(elm, oldCh, ch);
} else if (ch) {
if (oldVnode.text) nodeOps.setTextContent(elm, "");
addVnodes(elm, null, ch, 0, ch.length - 1);
} else if (oldCh) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else if (oldVnode.text) {
nodeOps.setTextContent(elm, "");
}
# updateChildren
接下来就要讲一下 updateChildren
函数了。
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx, idxInOld, elmToMove, refElm;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (!oldStartVnode) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (!oldEndVnode) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode);
nodeOps.insertBefore(
parentElm,
oldStartVnode.elm,
nodeOps.nextSibling(oldEndVnode.elm)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode);
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
let elmToMove = oldCh[idxInOld];
if (!oldKeyToIdx)
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
idxInOld = newStartVnode.key ? oldKeyToIdx[newStartVnode.key] : null;
if (!idxInOld) {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
} else {
elmToMove = oldCh[idxInOld];
if (sameVnode(elmToMove, newStartVnode)) {
patchVnode(elmToMove, newStartVnode);
oldCh[idxInOld] = undefined;
nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
} else {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
}
}
}
}
if (oldStartIdx > oldEndIdx) {
refElm = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : null;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
看到代码那么多先不要着急,我们还是一点一点地讲解。
首先我们定义 oldStartIdx
、newStartIdx
、oldEndIdx
以及 newEndIdx
分别是新老两个 VNode
的两边的索引,同时 oldStartVnode
、newStartVnode
、oldEndVnode
以及 newEndVnode
分别指向这几个索引对应的 VNode
节点。
接下来是一个 while
循环,在这过程中,oldStartIdx
、newStartIdx
、oldEndIdx
以及 newEndIdx
会逐渐向中间靠拢。
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)
首先当 oldStartVnode
或者 oldEndVnode
不存在的时候,oldStartIdx
与 oldEndIdx
继续向中间靠拢,并更新对应的 oldStartVnode
与 oldEndVnode
的指向(注:下面讲到的 oldStartIdx
、newStartIdx
、oldEndIdx
以及 newEndIdx
移动都会伴随着 oldStartVnode
、newStartVnode
、oldEndVnode
以及 newEndVnode
的指向的变化,之后的部分只会讲 Idx
的移动)。
if (!oldStartVnode) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (!oldEndVnode) {
oldEndVnode = oldCh[--oldEndIdx];
}
接下来这一块,是将 oldStartIdx
、newStartIdx
、oldEndIdx
以及 newEndIdx
两两比对的过程,一共会出现 2*2=4 种情况。 【头头
】【尾尾
】【头尾
】【尾头
】
else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode);
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode);
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
首先是 oldStartVnode
与 newStartVnode
符合 sameVnode
时,说明老 VNode
节点的头部与新 VNode
节点的头部是相同的 VNode
节点,直接进行 patchVnode
,同时 oldStartIdx
与 newStartIdx
向后移动一位。
其次是 oldEndVnode
与 newEndVnode
符合 sameVnode
,也就是两个 VNode
的结尾是相同的 VNode
,同样进行 patchVnode
操作并将 oldEndVnode
与 newEndVnode
向前移动一位。
接下来是两种交叉的情况。
先是 oldStartVnode
与 newEndVnode
符合 sameVnode
的时候,也就是老 VNode
节点的头部与新 VNode
节点的尾部是同一节点的时候,将 oldStartVnode.elm
这个节点直接移动到 oldEndVnode.elm
这个节点的后面即可。然后 oldStartIdx
向后移动一位,newEndIdx
向前移动一位。
同理,oldEndVnode
与 newStartVnode
符合 sameVnode
时,也就是老 VNode
节点的尾部与新 VNode
节点的头部是同一节点的时候,将 oldEndVnode.elm
插入到 oldStartVnode.elm
前面。同样的,oldEndIdx
向前移动一位,newStartIdx
向后移动一位。
最后是当以上情况都不符合的时候,这种情况怎么处理呢?
else {
let elmToMove = oldCh[idxInOld];
if (!oldKeyToIdx) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
idxInOld = newStartVnode.key ? oldKeyToIdx[newStartVnode.key] : null;
if (!idxInOld) {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
} else {
elmToMove = oldCh[idxInOld];
if (sameVnode(elmToMove, newStartVnode)) {
patchVnode(elmToMove, newStartVnode);
oldCh[idxInOld] = undefined;
nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
} else {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
}
}
}
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
}
createKeyToOldIdx
的作用是产生 key
与 index
索引对应的一个 map 表。比如说:
[
{xx: xx, key: "key0"},
{xx: xx, key: "key1"},
{xx: xx, key: "key2"},
]
在经过 createKeyToOldIdx
转化以后会变成:
key0: 0,
key1: 1,
key2: 2
我们可以根据某一个 key
的值,快速地从 oldKeyToIdx(createKeyToOldIdx
的返回值)中获取相同 key
的节点的索引 idxInOld,然后找到相同的节点。
如果没有找到相同的节点,则通过 createElm
创建一个新节点,并将 newStartIdx
向后移动一位。
if (!idxInOld) {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
}
否则如果找到了节点,同时它符合 sameVnode
,则将这两个节点进行 patchVnode
,将该位置的老节点赋值 undefined
(之后如果还有新节点与该节点 key
相同可以检测出来提示已有重复的 key
),同时将 newStartVnode.elm
插入到 oldStartVnode.elm
的前面。同理,newStartIdx
往后移动一位。
else {
elmToMove = oldCh[idxInOld];
if (sameVnode(elmToMove, newStartVnode)) {
patchVnode(elmToMove, newStartVnode);
oldCh[idxInOld] = undefined;
nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
}
}
如果不符合sameVnode
,只能创建一个新节点插入到 parentElm
的子节点中,newStartIdx
往后移动一位。
else {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
}
最后一步就很容易啦,当 while
循环结束以后,如果 oldStartIdx > oldEndIdx
,说明老节点比对完了,但是新节点还有多的,需要将新节点插入到真实 DOM
中去,调用 addVnodes
将这些节点插入即可。
同理,如果满足 newStartIdx > newEndIdx
条件,说明新节点比对完了,老节点还有多,将这些无用的老节点通过 removeVnodes
批量删除即可。
if (oldStartIdx > oldEndIdx) {
refElm = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : null;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
到这里,比对的核心实现已经讲完了,这部分比较复杂,不过仔细地梳理一下比对的过程,相信一定能够理解得更加透彻的。
注:本节代码参考《数据状态更新时的差异 diff 及 patch 机制》 (opens new window)。
# 15 Vue批量异步更新策略及 nextTick 原理
# 为什么要异步更新
简单回顾一下,这里面其实就是一个“setter
-> Dep
-> Watcher
-> patch
-> 视图
”的过程。
假设我们有如下这么一种情况。
<template>
<div>
<div>{{number}}</div>
<div @click="handleClick">click</div>
</div>
</template>
export default {
data() {
return {
number: 0,
};
},
methods: {
handleClick() {
for (let i = 0; i < 1000; i++) {
this.number++;
}
},
},
};
当我们按下 click 按钮的时候,number
会被循环增加 1000 次。
那么按照之前的理解,每次 number
被 +1
的时候,都会触发 number
的 setter
方法,从而根据上面的流程一直跑下来最后修改真实 DOM。那么在这个过程中,DOM 会被更新 1000 次!太可怕了。
Vue.js 肯定不会以如此低效的方法来处理。Vue.js 在默认情况下,每次触发某个数据的 setter
方法后,对应的 Watcher
对象其实会被 push
进一个队列 queue
中,在下一个 tick
的时候将这个队列 queue
全部拿出来 run
( Watcher
对象的一个方法,用来触发 patch
操作) 一遍。
那么什么是下一个 tick 呢?
# nextTick
Vue.js 实现了一个 nextTick
函数,传入一个 cb
,这个 cb
会被存储到一个队列中,在下一个 tick 时触发队列中的所有 cb
事件。
因为目前浏览器平台并没有实现 nextTick
方法,所以 Vue.js 源码中分别用 Promise、setTimeout、setImmediate
等方式在 microtask(或是 task)
中创建一个事件,目的是在当前调用栈执行完毕以后(不一定立即)才会去执行这个事件。
笔者用 setTimeout
来模拟这个方法,当然,真实的源码中会更加复杂,笔者在小册中只讲原理,有兴趣了解源码中 nextTick
的具体实现的同学可以参考next-tick (opens new window)。
首先定义一个 callbacks
数组用来存储 nextTick
,在下一个 tick 处理这些回调函数之前,所有的 cb
都会被存在这个 callbacks
数组中。pending
是一个标记位,代表一个等待的状态。
setTimeout
会在 task 中创建一个事件 flushCallbacks
,flushCallbacks
则会在执行时将 callbacks
中的所有 cb
依次执行。
let callbacks = [];
let pending = false;
function nextTick(cb) {
callbacks.push(cb);
if (!pending) {
pending = true;
setTimeout(flushCallbacks, 0);
}
}
function flushCallbacks() {
pending = false;
const copies = callbacks.slice(0);
callbacks.length = 0;
for (let i = 0; i < copies.length; i++) {
copies[i]();
}
}
# 再写 Watcher
第一个例子中,当我们将 number
增加 1000 次时,先将对应的 Watcher
对象给 push
进一个队列 queue
中去,等下一个 tick 的时候再去执行,这样做是对的。但是有没有发现,另一个问题出现了?
因为 number
执行 ++ 操作以后对应的 Watcher
对象都是同一个,我们并不需要在下一个 tick 的时候执行 1000 个同样的 Watcher
对象去修改界面,而是只需要执行一个 Watcher
对象,使其将界面上的 0 变成 1000 即可。
那么,我们就需要执行一个过滤的操作,同一个的 Watcher
在同一个 tick 的时候应该只被执行一次,也就是说队列 queue
中不应该出现重复的 Watcher
对象。
那么我们给 Watcher
对象起个名字吧~用 id
来标记每一个 Watcher
对象,让他们看起来“不太一样”。
实现 update
方法,在修改数据后由 Dep
来调用, 而 run
方法才是真正的触发 patch
更新视图的方法。
let uid = 0;
class Watcher {
constructor () {
this.id = ++uid;
}
update () {
console.log("watch" + this.id + "update");
queueWatcher(this);
}
run () {
console.log("watch" + this.id + "视图更新");
}
}
# queueWatcher
不知道大家注意到了没有?笔者已经将 Watcher
的 update
中的实现改成了
queueWatcher(this);
将 Watcher
对象自身传递给 queueWatcher
方法。
我们来实现一下 queueWatcher
方法。
let has = {};
let queue = [];
let waiting = false;
function queueWatcher (watcher) {
const id = watcher.id;
if (has[id] == null) {
has[id] = true;
queue.push(watcher);
if (!waiting) {
waiting = true;
nextTick(flushSchedulerQueue);
}
}
}
我们使用一个叫做 has
的 map
,里面存放 id -> true ( false )
的形式,用来判断是否已经存在相同的 Watcher
对象 (这样比每次都去遍历 queue
效率上会高很多)。
如果目前队列 queue
中还没有这个 Watcher
对象,则该对象会被 push
进队列 queue
中去。
waiting
是一个标记位,标记是否已经向 nextTick
传递了 flushSchedulerQueue
方法,在下一个 tick
的时候执行 flushSchedulerQueue
方法来 flush
队列 queue
,执行它里面的所有 Watcher
对象的 run
方法。
# flushSchedulerQueue
function flushSchedulerQueue () {
let watcher, id;
for (index = 0; index < queue.length; index++) {
watcher = queue[index];
id = watcher.id;
has[id] = null;
watcher.run();
}
waiting = false;
}
# 举个例子
let watch1 = new Watcher();
let watch2 = new Watcher();
watch1.update();
watch1.update();
watch2.update();
假设没有批量异步更新策略的话,理论上应该执行 Watcher
对象的 run
,那么会打印。
watch1 update watch1视图更新啦~ watch1 update watch1视图更新啦~ watch2 update
watch2视图更新啦~
实际上则执行
watch1 update watch1 update watch2 update watch1视图更新啦~ watch2视图更新啦~
这就是异步更新策略的效果,相同的 Watcher
对象会在这个过程中被剔除,在下一个 tick 的时候去更新视图,从而达到对我们第一个例子的优化。
我们再回过头聊一下第一个例子, number
会被不停地进行 ++ 操作,不断地触发它对应的 Dep
中的 Watcher
对象的 update
方法。然后最终 queue
中因为对相同 id
的 Watcher
对象进行了筛选,从而 queue
中实际上只会存在一个 number
对应的 Watcher
对象。在下一个 tick
的时候(此时 number
已经变成了 1000),触发 Watcher
对象的 run
方法来更新视图,将视图上的 number
从 0 直接变成 1000。
到这里,批量异步更新策略及 nextTick 原理已经讲完了。
注:更多参考代码参考[方圆的博客](https://1996f.cn | blog.canuse.top)
# 16 Vuex 状态管理的工作原理
# 为什么要使用 Vuex
当我们使用 Vue.js 来开发一个单页应用时,经常会遇到一些组件间共享的数据或状态,或是需要通过 props 深层传递的一些数据。在应用规模较小的时候,我们会使用 props、事件等常用的父子组件的组件间通信方法,或者是通过 事件总线(Event Bus) (opens new window) 来进行任意两个组件的通信。但是当应用逐渐复杂后,问题就开始出现了,这样的通信方式会导致数据流异常地混乱。
这个时候,我们就需要用到我们的状态管理工具 Vuex 了。Vuex 是一个专门为 Vue.js 框架设计的、专门用来对于 Vue.js 应用进行状态管理的库。它借鉴了 Flux、redux 的基本思想,将状态抽离到全局,形成一个 Store。因为 Vuex 内部采用了 new Vue 来将 Store 内的数据进行「响应式化」,所以 Vuex 是一款利用 Vue 内部机制的库,与 Vue 高度契合,与 Vue 搭配使用显得更加简单高效,但缺点是不能与其他的框架(如 react)配合使用。
本节将简单介绍 Vuex 最核心的内部机制,起个抛砖引玉的作用,想了解更多细节可以参考阅读 Vuex 源码 (opens new window)。
# 安装
Vue.js 提供了一个 Vue.use
的方法来安装插件,内部会调用插件提供的 install
方法。
Vue.use(Vuex);
所以我们的插件需要提供一个 install
方法来安装。
let Vue;
export default install (_Vue) {
Vue.mixin({ beforeCreate: vuexInit });
Vue = _Vue;
}
我们采用 Vue.mixin
方法将 vuexInit
方法混淆进 beforeCreate
钩子中,并用 Vue
保存 Vue
对象。那么 vuexInit
究竟实现了什么呢?
我们知道,在使用 Vuex
的时候,我们需要将 store
传入到 Vue
实例中去。
/*将store放入Vue创建时的option中*/
new Vue({
el: "#app",
store,
});
但是我们却在每一个 vm 中都可以访问该 store
,这个就需要靠 vuexInit
了。
function vuexInit () {
const options = this.$options;
if (options.store) {
this.$store = options.store;
} else {
this.$store = options.parent.$store;
}
}
因为之前已经用 Vue.mixin
方法将 vuexInit
方法混淆进 beforeCreate
钩子中,所以每一个 vm 实例都会调用 vuexInit
方法。
如果是根节点( $options
中存在 store
说明是根节点),则直接将 options.store
赋值给 this.$store
。否则则说明不是根节点,从父节点的 $store
中获取。
通过这步的操作,我们已经可以在任意一个 vm 中通过 this.$store
来访问 Store
的实例啦~ (如:this.$store.state.xx
)
# Store
# 数据的响应式化
首先我们需要在 Store
的构造函数中对 state
进行 【响应式化】。
constructor () {
this._vm = new Vue({
data: {
$$state: this.state
}
})
}
熟悉【响应式
】的同学肯定知道,这个步骤后,state
会将需要的依赖收集到 Dep
中,在被修改时更新对应视图。我们来看一个小例子。
let globalData = {
d: "hello world",
};
new Vue({
data () {
return {
$$state: {
globalData
}
}
}
});
// modify
setTimeout(() => {
globalData.b = "hi ~";
}, 1000);
Vue.prototype.globalData = globalData;
任意模板中
<div>{{globalData.b}}</div>
上述代码在全局有一个 globalData
,它被传入一个 Vue
对象的 data
中,之后在任意 Vue
模板中对该变量进行展示,因为此时 globalData
已经在 Vue
的 prototype
上了所以直接通过 this.prototype
访问,也就是在模板中的 globalData.d``。此时,setTimeout
在 1s 之后将 globalData.d
进行修改,我们发现模板中的 globalData.d
发生了变化。其实上述部分就是 Vuex
依赖 Vue
核心实现数据的“响应式化
”。
讲完了 Vuex
最核心的通过 Vue
进行数据的「响应式化
」,接下来我们再来介绍两个 Store
的 API。
# commit
首先是 commit
方法,我们知道 commit
方法是用来触发 mutation
的。
commit (type, payload, _options) {
const entry = this._mutations[type];
entry.forEach(function commitIterator (handler) {
handler(payload);
});
}
从 _mutations
中取出对应的 mutation
,循环执行其中的每一个 mutation
。
# dispatch
dispatch
同样道理,用于触发action
,可以包含异步状态
。
dispatch (type, payload) {
const entry = this._actions[type];
return entry.length > 1
? Promise.all(entry.map(handler => handler(payload)))
: entry[0](payload);
}
同样的,取出 _actions
中的所有对应 action
,将其执行,如果有多个则用 Promise.all
进行包装。
# 最后
理解 Vuex
的核心在于理解其如何与 Vue
本身结合,如何利用 Vue
的 响应式机制
来实现核心 Store
的「响应式化
」。
Vuex
本身代码不多且设计优雅,非常值得一读,想阅读源码的同学请看Vuex 源码 (opens new window)。
注:本节代码参考 《Vuex 状态管理的工作原理》 (opens new window) 。
# 17 vue3保姆级教程
# 初学vue3
说来惭愧,vue3都发布了一年左右了,我才来学习vue3,看来我是真的弱鸡,现在的我是站在巨人的肩膀上来学习,刚出来vue3全是英文,看都看不懂,现在起码有中文了,结合了许多大佬的见解写出来的小白通俗易懂vue3理解,写的不好希望大家轻喷 ok,ok同志们,没有什么花里胡哨的开场白,今天我们来了解下vue3.
# 了解vue3创建方式
首先我们先创建vue3文件,当然这一步和vue2一样,我就不一一描述了哈,懂得都懂。
都创建好了吧,创建好了我们就来讲一讲 vite (opens new window)
第一我们要先了解vite (opens new window) 是什么,vite是尤雨溪团队开发的新一代的前端构建工具,意图取代 webpack
, 首先我们先来看一看vite有什么优点
- 无需打包,快速的冷服务器启动
- 即时热模块更换(HMR,热更新)
- 真正的按需编译
webpack是一开始是入口文件,然后分析路由,然后模块,最后进行打包,然后告诉你,服务器准备好了(默认8080)
然而vite是什么,它一开始是先告诉你服务器准备完成,然后等你发送HTTP请求,然后是入口文件,Dynamic import
(动态导入) code split point
(代码分割)
如何使用vite呢,大家可以去看 vite官网 (opens new window),也可以看我写的
// 要构建一个 vite + vue 项目,运行,使用 NPM
npm init @vitejs/app 项目名
// 使用 Yarn
yarn create @vitejs/app 项目名
你会觉得非常快速的创建项目,然而它并没有给你下载依赖,你还有进入文件然后 npm install (or yarn)
然后它的打开方式不是 serve
变成了 dev
Edit components/HelloWorld.vue to test hot module replacement.
编辑components/HelloWorld.vue 以测试热模块更换。(也就是热更新更快)
然而我们只是简单了解下,我们现在的重点是vue3,如果以后vite成为主流,我们也可以在回头看看。
现在还是以主流的方式创建并进行详解
# 分析vue3
# 基本了解
当我们创建完成vue3项目后,点击它的main.js,你会发现写法发生了改变
引入的不是vue构造函数,而是 createApp
工厂函数然而,创建实例对象其实就相当于vue2中的 vm.mount('#app')
就相当于 $mount('#app')
, 并且vue2的写法在vue3不能兼容
现在我们进入App组件,你会发现什么不一样的地方,他没有了根标签,在vue2的时候,我们都是在div根标签里面写东西,所以在vue3里面可以没有根标签
# 常用组合式API (重点!!!)
# setup
setup 函数是 Composition API (组合API) 的入口
在setup函数中定义的变量和方法最后都是需要return出去的,不然无法在模板中使用
<script>
export default {
name: 'App',
setup () {
let name = '流量';
let age = 18;
// methods
const say = () => {
console.log(`我叫${name}, 今年${age}岁`)
}
// return object
return {
name,
age,
say
}
}
}
</script>
当然这不是响应式的写法,然后你们可能会问,为什么没有用 this
, 我们要想一想之前为什么要用 this
, 还不是作用域的问题,然而这次我们都在 setup
里面,所以不会用到 this
, 而且这里兼容vue2的写法如:data, methods...
, 并且在 vue2 中可以读取到vue3里的配置但是vue3里面不能读取到vue2的配置,所以,vue3和vue2不要混用,如果有重名那么优先 setup
ps: 如果大家不喜欢 return
这样的写法的话,可以用vue3新语法糖 <script setup>
, <script setup>
就相当于在编译运行是把代码放到了 setup
函数中运行,然后把导出的变量定义到上下文中,并包含在返回的对象中。具体操作可以看掘金其他大佬。
script setup基本使用 (opens new window)
上手后才知道,vue3 的 script setup 语法糖是真的爽 (opens new window)
vue3 新语法糖 ——— setup script (opens new window)
......
setup还有几个注意点
它比
beforeCreate
和created
这两个生命周期还要快
, 就是说,setup 在 beforeCreate, created 前,它里面的this打印出来是 undefinedsetup
可以接受两个参数,第一个参数是props
, 也就是 组件传值,第二个参数是context
, 上下文对象,context
里面还有三个很重的东西attrs
,slots
,emit
, 它们就相当于vue2里面的this.$attrs
,this.$slots
,this.$emit
.
通过打印,你可以看到传值,但是会有警告,那是因为我传了两个值,却只接收了一个,要是两个都接收就不会出现警告了。
这个是因为vue3中要求我们用 emits去接收,接收后就不会警告了,但是也可以不理警告直接用
使用插槽时,不能使用 slot="xxx"
, 要使用 v-slot
, 不然会报错
// 父组件
<template>
<div class="home">
<HelloWorld wish="不掉发" wishes="变瘦" @carried="carried" >
<h3>实现插槽</h3>
<template v-slot:dome>
<h4>实现插槽2</h4>
</template>
</HelloWorld>
</div>
</template>
<script>
import HelloWorld from "./components/HelloWorld";
export default {
name: "Home",
components: {
HelloWorld
},
setup () {
const carried = (value) => {
alert(`牛蛙, 都实现了!!!${value}`)
}
return {
carried
}
}
}
</script>
// 子组件
子
<template>
<h1>HelloWorld</h1>
<h1>{{ wish }}</h1>
<button @click="dream" >点击实现</button>
<slot></slot>
<slot name="dome"></slot>
</template>
<script>
export default {
name: "HelloWorld",
props: ["wish",'wishes'],
emits:['carried'],
setup(props,context) {
console.log(props)
console.log(context.attrs)
function dream(){
context.emit('carried',666)
}
return{
dream
}
},
};
</script>
<style scoped></style>
# ref 与 reactive
# ref
上方我说到,我们写的不是响应式数据,我们写的只是字符串和数字,那怎么变成响应式数据呢,那就要引入 ref
, 但是如果我们直接在代码里面是修改不了的,不如打印一下 name
和 age
, 你就会发现 ref把他们变成了对象,并且还是 RefImpl
的实例对象
<template>
<div class="home">
<h1>姓名:{{name}}</h1>
<h1>年龄:{{age}}</h1>
<button @click="say" >修改</button>
</div>
</template>
<script>
import {ref} from 'vue'
export default {
name: 'Home',
setup(){
let name = ref('燕儿')
let age = ref(18)
console.log(name)
console.log(age)
//methods
const say = () => {
name='苒苒'
age=20
}
return {
name,
age,
say
}
}
}
</script>
所以,在修改的时候要.value去修改,里面还是走的get与set去修改页面
其实按道理的话,我们在页面上用的话应该要 name.value
显示的,但是因为vue3检测到你是ref对象,它就自动给你.value了
const say = () => {
name.value = 'xx';
age.value = 20;
}
那么要是我定义的ref是个对象呢,因为我们知道尽管ref后会变成 RefImpl
的实例对象,所以我们就用 xx.value.xxx
进行修改
<template>
<div class="home">
<h1>姓名:{{name}}</h1>
<h1>年龄:{{age}}</h1>
<h2>职业:{{job.occupation}}</h2>
<h2>薪资:{{job.salary}}</h2>
<button @click="say" >修改</button>
</div>
</template>
<script>
import {ref} from 'vue'
export default {
name: 'Home',
setup(){
let name = ref('燕儿')
let age = ref(18)
let job=ref({
occupation:'程序员',
salary:'10k'
})
console.log(name)
console.log(age)
//方法
function say(){
job.value.salary='12k'
}
return {
name,
age,
job,
say
}
}
}
</script>
但是我们打印job.value
,你就会发现,它不再是 RefImpl
实例对象,变成了 Proxy
实例对象,他只是 vue3 底层,把对象都变成了 Proxy
实例对象,对于基本数据类型就是按照 Object.defineProperty
里面的 get
和 set
进行数据劫持然后进行响应式,但是如果是对象类型的话,是用到的 Proxy
,但是 vue3 把它封装在新函数 reactive
里,就相当于,ref 中是对象,自动会调用 reactive
。
# reactive
reactive
只能定义对象类型的响应式数据,前面说到的ref
里是对象的话,会自动调用 reactive
,把Object转换为Proxy,那我们来打印一下,你会发现就直接变成了Proxy,之前为什么会.value呢,是因为要去获取值,然后通过reactive变成Proxy,但是现在是直接通过reactive变成Proxy,而且它是进行的一个深层次的响应式,也可以进行数组的响应式
<template>
<div class="home">
<h1>姓名:{{name}}</h1>
<h1>年龄:{{age}}</h1>
<h2>职业:{{job.occupation}}<br>薪资:{{job.salary}}</h2>
<h3>爱好:{{hobby[0]}},{{hobby[1]}},{{ hobby[2] }}</h3>
<button @click="say">修改</button>
</div>
</template>
<script>
import {ref,reactive} from 'vue'
export default {
name: 'Home',
setup(){
let name = ref('燕儿')
let age = ref(18)
let job=reactive({
occupation:'程序员',
salary:'10k'
})
let hobby=reactive(['刷剧','吃鸡','睡觉'])
console.log(name)
console.log(age)
//方法
function say(){
job.salary='12k'
hobby[0]='学习'
}
return {
name,
age,
job,
say,
hobby
}
}
}
</script>
有些人可能觉得,哎呀,我记不住,我就用ref,每次就.value可以了,香香香。他喵的,你正常点,要是一个页面就几个数据的话那还好,要是一堆数据,不得把你.value点的冒烟吗?,其实你可以按照之前vue2中data的形式来写,这样你就会觉得reactive香的一批了😁
<template>
<div class="home">
<h1>姓名:{{data.name}}</h1>
<h1>年龄:{{data.age}}</h1>
<h2>职业:{{data.job.occupation}}<br>薪资:{{data.job.salary}}</h2>
<h3>爱好:{{data.hobby[0]}},{{data.hobby[1]}},{{ data.hobby[2] }}</h3>
<button @click="say">修改</button>
</div>
</template>
<script>
import {reactive} from 'vue'
export default {
name: 'Home',
setup(){
let data=reactive({
name:'燕儿',
age:18,
job:{
occupation:'程序员',
salary:'10k'
},
hobby:['刷剧','吃鸡','睡觉']
})
//方法
function say(){
data.job.salary='12k'
data.hobby[0]='学习'
}
return {
data,
say,
}
}
}
</script>
怎么样,是不是直接暴露出去个data就好了,这样起码更能理解,不会让人摸不着头脑为什么要 .value
,是吧😊
# ref与reactive的区别
- ref用来定义:【基本数据类型】
- ref通过
Object.defineProperty()
的get
与set
来实现响应式(数据劫持) - ref定义的数据:操作数据需要
.value
,读取数据时模板中直接读取不需要.value
- reactive用来定义:【对象或数组类型数据】
- reactive通过使用
proxy
来实现响应式(数据劫持),并通过reflect
操作源代码内部的数据 - reactive定义的数据:操作数据与读取数据:均不需要
.value
当然,我之前就说过,ref
是可以定义对象或数组的, 它只是内部自动调用了 reactive
来转换
# vue3的响应式原理
说到vue3的响应式原理,那我们就不得不提一句vue2的响应式了,(狗都知道的一句)通过 Object.defineProperty
的get,set来进行数据劫持,修改,从而响应式,但是它有什么缺点呢😶
由于只有 get()
、set()
方式,所以只能捕获到属性读取和修改操作,当 新增、删除属性时,捕获不到,导致界面也不会更新。
直接通过下标修改数组,界面也不会自动更新。
ok,vue2就聊这么多,什么?你还想听vue2底层?那你就Alt+←,拜拜了您嘞。 对于vue3中的响应式,我们用到的Proxy,当然,我们在vue2里面知道,Proxy是什么,是代理,当然,并不是只用到了它,还有个Window上的内置对象Reflect(反射)
- 通过Proxy(代理):拦截对象中任意属性的变化,包括:属性值的读写、属性的添加/删除等。
- 通过Reflect(反射):对源对象的属性进行操作。
const p=new Proxy(data, {
// 读取属性时调用
get (target, propName) {
return Reflect.get(target, propName)
},
//修改属性或添加属性时调用
set (target, propName, value) {
return Reflect.set(target, propName, value)
},
//删除属性时调用
deleteProperty (target, propName) {
return Reflect.deleteProperty(target, propName)
}
})
# computed、watch与watchEffect
# computed
在vue3中,把 computed 变成为组合式 API, 那么就意味着你要去引入它,代码如下,一个简易的计算就完成了
<template>
<div class="home">
姓:<input type="text" v-model="names.familyName"><br>
名:<input type="text" v-model="names.lastName"><br>
姓名:{{fullName}}<br>
</div>
</template>
<script>
import {reactive,computed} from 'vue'
export default {
name: 'Home',
setup(){
let names=reactive({
familyName:'阿',
lastName:'斌'
})
fullName=computed(()=>{
return names.familyName+'.'+names.lastName
})
return {
names,
fullName
}
}
}
</script>
要是你去修改计算出来的东西,你知道会发生什么吗?警告的意思是计算出来的东西是一个只读属性。
那要是我们想要修改怎么办呢,那么就要用到 computed
的终结写法了
<template>
<div class="home">
姓:<input type="text" v-model="names.familyName"><br>
名:<input type="text" v-model="names.lastName"><br>
姓名:<input type="text" v-model="names.fullName"><br>
</div>
</template>
<script>
import {reactive,computed} from 'vue'
export default {
name: 'Home',
setup(){
let names=reactive({
familyName:'阿',
lastName:'斌'
})
names.fullName=computed({
get(){
return names.familyName+'.'+names.lastName
},
set(value){
let nameList=value.split('.')
names.familyName=nameList[0]
names.lastName=nameList[1]
}
})
return {
names
}
}
}
</script>
但是,yysy(有一说一),他喵的,我寻思也没有人会去改计算属性吧?如果有,就当我没说😷
# watch
你可能会想到 computed
都是组合式API,那么watch会不会也是 组合式API
呢?大胆点,它也是, 那么我们就来进行监视
<template>
<div class="home">
<h1>当前数字为:{{num}}</h1>
<button @click="num++">点击数字加一</button>
</div>
</template>
<script>
import {ref,watch} from 'vue'
export default {
name: 'Home',
setup(){
let num=ref('0')
watch(num,(newValue,oldValue)=>{
console.log(`当前数字增加了,${newValue},${oldValue}`)
})
return {
num
}
}
}
</script>
当然这是监听 ref
定义出来的单个响应式数据,要是监听多个数据应该怎么办呢?其实可以用多个 watch
去进行监听,当然这不是最好的方法,最好的方法其实是监视数组
watch([num,msg], (newValue, oldValue) => {
console.log(`当前改变了,${newValue}${oldValue}`)
})
既然我们监听的是数组,那么我们得到的 newValue 和 oldValue 也就是数组,那么数组中的第一个就是你监视的第一个参数。
ps.当然之前在vue2中 watch 不是有什么其他参数吗,vue3中也有,是写在最后的。
watch([num, msg], (newValue, oldValue) => {
console.log(`当前改变了,${newValue}${oldValue}`)
}, {immediate: true, deep: true})
之前我说过,我们现在监听的是监听ref定义出来数据,那么要是我们监听的是 reactive
<template>
<div class="home">
<h1>当前姓名:{{names.familyName}}</h1>
<h1>当前年龄:{{names.age}}</h1>
<h1>当前薪水:{{names.job.salary}}K</h1>
<button @click="names.familyName+='!'">点击加!</button>
<button @click="names.age++">点击加一</button>
<button @click="names.job.salary++">点击薪水加一</button>
</div>
</template>
<script>
import {reactive,watch} from 'vue'
export default {
name: 'Home',
setup(){
let names=reactive({
familyName: '鳌',
age:23,
job:{
salary:10
}
})
watch(names,(newValue,oldValue)=>{
console.log(`names改变了`,newValue,oldValue)
},{deep:false})
return {
names
}
}
}
</script>
但是你会发现一个问题,为什么newValue与oldValue一样呢,就很尴尬,都是新的数据,就算你使用ref来定义,还是没有办法监听到oldValue(他喵的,都给你说了ref定义的对象会自动调用reactive),所以在监视reactive定义的响应式数据时,oldValue无法正确获取,并且你会发现,它是强制开启深度监视(deep:true),并且无法关闭。
然而现在我们监视的是reactive定义的响应式数据的全部属性,是只监听其中的一个属性,那怎么办呢,可能大家会
watch(names.age,(newValue,oldValue)=>{
console.log(`names改变了`,newValue,oldValue)
})
来进行监视,但是,vue3会警告只能监听reactive定义的或者ref定义的,并且不能监听。
那么我们就必须这样写(不会还有人不知道return可以省略吧?不会吧?不会吧?不会那个人就是你吧?)
watch(()=>names.age,(newValue,oldValue)=>{
console.log('names改变了',newValue,oldValue)
})
那么要是我们监听的是多个属性,那怎么办呢?emmmm,你正常点,我上面都写了监听多个ref定义的响应式数据,你就不会举一反三吗?敲代码很累的好吧!!!他喵的,为了防止你们问多个reactive定义的一个属性,我就只能说和这个是一样的!!!能不能聪明点!!!
watch([() => names.age, () => names.familyName, (newValue, oldValue) => {
console.log(`names改变了,${newValue}${oldValue}`)
}])
ok,要是我们监听的是深度的属性那要怎么办呢?你会发现我要是只监听第一层是监听不到的,那么我们有两种写法
// 第一种
watch(() => names.job.salary, (newValue, oldValue) => {
console.log(`names改变了,${newValue}${oldValue}`)
})
// 第二种
watch(() => names.job, (newValue, oldValue) => {
console.log(`names改变了,${newValue}${oldValue}`)
}, {deep: true})
那么我们就可以这样理解,如果监视的是reactive定义的响应式数据的属性,并且这个属性是对象,那么我们可以开启深度监视
# watchEffect
watchEffect 是vue3的新函数,它是来和watch来抢饭碗的,它和watch是一样的功能,那它有什么优势呢?
- 自动默认开启了 immediate: true
- 用到了谁就监视谁
watchEffect(() => {
const one = num.value;
const two = person.age;
console.log(`watchEffect执行了`)
})
其实吧,watchEffect 有点像 computed,都是里面的值发生了改变就调用一次,但是呢, computed 要写返回值,而 watchEffect 不用写返回值。
# 生命周期
我们先来简单分析下,在vue2中,我们是先 new Vue(), 然后执行 beforeCreate 与 created 接着问你有没有 vm.$mount(el), 有, 才继续执行,但是在 vue3 中, 它是先全部准备好后然后再进行函数。
其实在vue3中生命周期没有多大的改变,只是改变了改变了销毁前,和销毁,让它更加语义化了 beforeDestroy 改名为 beforeUnmount, destroyed 改名为 unmounted
然后再 vue3 中, beforeCreate 与 created 并没有组合式 API 中, setup 就相当于这两个生命周期函数
在vue3中也可以按照之前的生命周期函数那样写,只是要记得有些函数名称发生了改变
在 setup 里面应该这样写
- beforeCreate ===> Not needed
- created ===> Not needed
- beforeMount ===> onBeforeMount
- mounted ===> onMounted
- beforeUpdate ===> onBeforeUpdate
- updated ===> onUpdated
- beforeUnmount ===> onBeforeUnmount
- unmounted ===> onUnmounted
# hooks函数
- Vue3 的 hooks 函数 相当于 vue2 的 mixin, 不同在于 hooks 是函数
- Vue3 的 hooks 函数,可以帮助我们提高代码的复用性,让我们能在不同的组件中都利用hooks函数
其实就是代码的复用,可以用到外部的数据,生命钩子函数...,具体怎么用直接看代码。
// 一般都是建立一个hooks文件夹,都写在里面
import { reactive, onMounted, onBeforeUnmount } from 'vue'
export default function () {
// 鼠标点击坐标
let point = reactive({
x: 0,
y: 0
})
// 实现鼠标点击获取坐标的方法
function savePoint(event) {
point.x = event.pageX;
point.y = event.pageY;
console.log(event.pageX, event.pageY);
}
// 实现鼠标点击获取坐标的方法的生命周期钩子
onMounted (() => {
window.addEventListener('click', savePoint);
})
onBeforeUnmmount (() => {
window.removeEventListener('click', savePoint)
})
return point
}
// 在其他地方调用
import useMousePosition from './hooks/useMousePosition'
let point = useMousePostion()
# toRef与toRefs
# toRef
toRef 翻译过来其实就是把什么变成 ref 类型的数据,可能大家会觉得没有什么用,毕竟我们之前定义时就已经定义成ref,但是你们想一想,我们在之前是怎么写的
<template>
<div class="home">
<h1>当前姓名:{{names.name}}</h1>
<h1>当前年龄:{{names.age}}</h1>
<h1>当前薪水:{{names.job.salary}}K</h1>
<button @click="names.name+='!'">点击加!</button>
<button @click="names.age++">点击加一</button>
<button @click="names.job.salary++">点击薪水加一</button>
</div>
</template>
<script>
import {reactive} from 'vue'
export default {
name: 'Home',
setup(){
let names=reactive({
name:'老谭',
age:23,
job:{
salary:10
}
})
return {
names
}
}
}
</script>
是不是一直都是用到代码 name.xx,可能你会说,那我就return的时候不这样写,改成这样
return {
name: names.name,
age: names.age,
salary: names.job.salary
}
但是你要是在页面进行操作时就不是响应式了,为什么呢?那是因为你现在暴露出去的是简简单单的字符串,字符串会有响适应吗?肯定没有呀,但是你要是用到了 toRef, 那就是把 name.xx 变为响应式,然后操作它时会自动的去修改name里面的数据
return {
name:toRef(names,'name'),
age:toRef(names,'age'),
salary:toRef(names.job,'salary')
}
# toRefs
聪明一点,toRefs 与 toRef 有什么不同,加了个s,toRef 是单个转化为响应式,那 toRefs就是多个转化为响应式咯,这样的话就减少代码,不然要是有成千上万个,那你不是要当憨憨闷写吗?(...是es6展开符操作),当然它只会结构一层,深层里的代码还是要老实的写。
<h1>当前姓名:{{name}}</h1>
<h1>当前薪水:{{job.salary}}K</h1>
return {
...toRefs(names)
}
# 其他组合式API(了解,基本上不常用)
# shallowReactive 与 shallowRef
shallowReactive 浅层次的响应式,它就是只把第一层的数据变为响应式,深层的数据不会变为响应式,shallowRef如果定义的是基本类型的数据,那么它和ref是一样的不会有什么改变,但是要是定义的是对象类型的数据,那么它就不会进行响应式,之前我们说过如果ref定义的是对象,那么它会自动调用reactive变为proxy,但是要是用到的是 shallowRef 那么就不会调用 reactive 去进行响应式。
【shallowReactive: 只处理对象最外层属性是响应式(浅响应式)】 【shallowRef:只处理基本数据类型的响应式,不进行对象的响应式处理】
let person = shallowReactive({
name:'大理段氏',
age:10,
job:{
salary:20
}
})
let x = shallowRef({
y:0
})
# readonly 与 shallowReadonly
readonly 是接收了一个响应式数据然后重新赋值,返回的数据就不允许修改(深层只读),shallowReadonly 却只是浅层只读(第一层只读,其余层可以进行修改)
names = readonly(names)
names = shallowReadonly(names)
# toRaw 与 markRaw
toRaw 其实就是将一个由 reactive 生成的 【响应式对象】转为【普通对象】,如果是ref定义的话,是没有效果的(包括ref定义的对象)【如果在后续操作中对数据进行了添加的话,添加的数据为响应式数据】,当然要是将数据进行markRaw操作后不会变为响应式,可能大家会谁,不就是和readonly一样吗,不一样,readonly只读不能修改,markRaw 是不转化为响应式,但是数据还是会发生改变。
<template>
<div class="home">
<h1>当前姓名:{{names.name}}</h1>
<h1>当前年龄:{{names.age}}</h1>
<h1>当前薪水:{{names.job.salary}}K</h1>
<h1 v-if="names.girlFriend">女朋友:{{names.girlFriend}}</h1>
<button @click="names.name+='!'">点击加!</button>
<button @click="addAges">点击加一</button>
<button @click="addSalary">点击薪水加一</button>
<button @click="add">添加女朋友</button>
<button @click="addAge">添加女朋友年龄</button>
</div>
</template>
<script>
import {reactive,toRaw,markRaw} from 'vue'
export default {
name: 'Home',
setup(){
let names=reactive({
name:'老伍',
age:23,
job:{
salary:10
}
})
function addAges(){
names.age++
console.log(names)
}
function addSalary(){
let fullName=toRaw(names)
fullName.job.salary++
console.log(fullName)
}
function add(){
let girlFriend={sex:'女',age:40}
names.girlFriend=markRaw(girlFriend)
}
function addAge(){
names.girlFriend.age++
console.log(names.girlFriend.age)
}
return {
names,
add,
addAge,
addAges,
addSalary
}
}
}
</script>
# customRef
【customRef创建一个自定义的ref,并对其依赖项跟踪和更新触发进行显示控制】
单纯觉得这个东西的作用只有防抖的作用(要是知道其他的用法可以告知一下我)
<template>
<input type="text" v-model="keyWord">
<h3>{{keyWord}}</h3>
</template>
<script>
import {customRef} from 'vue'
export default {
name: 'App',
setup() {
//自定义一个ref——名为:myRef
function myRef(value,times){
let time
return customRef((track,trigger)=>{
return {
get(){
console.log(`有人从myRef中读取数据了,我把${value}给他了`)
track() //通知Vue追踪value的变化(必须要有,并且必须要在return之前)
return value
},
set(newValue){
console.log(`有人把myRef中数据改为了:${newValue}`)
clearTimeout(time)
time = setTimeout(()=>{
value = newValue
trigger() //通知Vue去重新解析模板(必须要有)
},times)
},
}
})
}
let keyWord = myRef('HelloWorld',1000) //使用自定义的ref
return {keyWord}
}
}
</script>
给你们说个趣事吧,顺便你们讲一下防抖节流的区别,之前有人在面试的时候,说对项目进行了防抖节流处理,但我细问防抖与节流的区别却支支吾吾。
我是这样理解的:
【防抖】:在第一次触发事件时,不立即执行函数,而是给出一个时间段,如果短时间内大量触发同一事件,只会执行一次函数。(可以理解为 input框防抖动优化性能 )
【节流】:函数执行一次后,在某个时间段内暂时失效,过了这段时间后再重新激活,如果短时间内大量触发同一事件,那么在函数执行一次之后,该函数在指定的时间期限内不再生效,直至过了这段时间才重新生效。(可以理解为游戏技能冷却期 | 列表触底刷新)
# provide 与 inject
都知道组件传值吧,在vue2中,如果要在后代组件中使用父组件的数据,那么要一层一层的父子组件传值或者用到 vuex, 但是现在,无论组件层次结构有多深,父组件都可以作为其所有子组件的依赖提供者。这个特性有两个部分:父组件有一个 provide 选项来提供数据,子组件有一个 inject 选项来开始使用这些数据。
// 父
import { provide } from 'vue'
setup () {
let fullname = reactive({name: 'xx', salary: '15'})
provide('fullname', fullname); // 给自己的后代组件传递数据
return { ...toRefs(fullname) }
}
// 后代
import { inject } from 'vue'
setup () {
let fullname = inject('fullname')
return { fullname }
}
当然子组件也可以用,但是请记住,父子组件传参是有方法的!!!别瞎搞。
# 响应式判断
下面是vue3 给的一些判断方法
【isRef】:检查值是否为一个 ref 对象。
【isReactive】:检查对象是否是由 reactive 创建的响应式代理。
【isReadOnly】:检查对象是否是由 readonly 创建的只读代理。
【isProxy】:检查对象是否是由 reactive 或 readonly 创建的 proxy。
import { ref, reactive, readonly, isRef, isReactive, isReadonly, isProxy } from 'vue'
export default {
name: 'App',
setup () {
let num = ref(0)
let fullName = reactive({name: 'xx', price: '15'})
let fullNames = readonly(fullName)
console.log(isRef(num))
console.log(isReative(fullNames))
console.log(isReadonly(fullNames))
console.log(isProxy(num))
console.log(isProxy(fullName))
console.log(isProxy(fullNames))
return {}
}
}
# 有趣的组件
# Fragment
对我而言这个更像是一种概念,它的意思就相当于创建页面时,给了一个虚拟根标签 VNode, 因为我们知道在vue2里面,我们是有根标签这个概念的,但是到来vue3,它是自动给你创建个虚拟根标签 VNode (Fragment), 所以可以不要根标签, 好处就是 【减少标签层级,减小内存占用】
# Teleport
teleport 提供了一种有趣的方法,允许我们控制在DOM 中哪个父节点下渲染了HTML,而不必求助于全局状态或将其拆分两个组件。
其实就是可以不考虑你写在什么位置,你可以定义 teleport 在任意标签里进行定位等(常见操作为模态框),除了body外,还可以写css选择器(id,class)
//id定位
<teleport to="#app">
<div class="four">
<div class="five"></div>
</div>
</teleport>
//class定位
<teleport to=".one">
<div class="four">
<div class="five"></div>
</div>
</teleport>
//示例
<template>
<div class="one">
<h1>第一层</h1>
<div class="two">
<h1>第二层</h1>
<div class="three">
<h1>第三层</h1>
<teleport to="body">
<div class="four">
<div class="five"></div>
</div>
</teleport>
</div>
</div>
</div>
</template>
<script>
export default {
name:'App',
setup(){
return {}
}
}
</script>
<style lang="less">
.one{
width: 100%;
background-color: blue;
.two{
margin: 20px;
background-color: aqua;
.three{
margin: 20px;
background-color: aliceblue;
}
}
}
.four{
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.5);
.five{
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
width: 300px;
height: 300px;
left: 50%;
background-color:#f60;
}
}
</style>
# Suspense
大家都知道在渲染组件之前进行一些异步请求是很常见的事,suspense 组件提供了一个方案,允许将等待过程提升到组件树中处理,而不是在单个组件中。但是!!!! 在vue3中特别说明了,【Suspense 是一个试验性的新特性,其API可能随时会发生变动。特此声明,以便社区能够为当前的实现提供反馈】
# vue3其他改动
# router
可能大家会想到路由跳转的问题,可能大家会以为还是用 this.$router.push 来进行跳转,但是哦,在vue3中,这些东西是没有的,它是定义了一个vue-router然后引入的useRoute,useRouter 相当于vue2的 this.$route, this.$router, 然后其他之前vue2的操作都可以进行
import { useRouter, useRoute } from 'vue-router'
setup () {
const router = useRouter()
const route = useRoute()
function fn () {
this.$router.push('/about')
}
onMounted(() => {
console.log(route.query.code)
})
return { fn }
}
# 全局API的转移
2.x 全局 API(vue) | 3.x 实例 API(app) |
---|---|
Vue.config.xxx | app.config.xxx |
Vue.config.productionTip | 移除 |
Vue.component | app.component |
Vue.directive | app.directive |
Vue.mixin | app.mixin |
Vue.use | app.use |
Vue.prototype | app.config.globalProperties |
# 其他改变
【移除】: keyCode 作为 v-on 的修饰符, 同时也不再支持 config.keyCodes 【移除】: v-on.native 修饰符 【移除】: 过滤器(filter)
# 18 vue3.0 script setup基本使用
在单文件组件(SFC)中引入一个新的<script>
类型 setup
。 它向模板公开了所有的顶层绑定。
# 组件
组件直接引入即可使用,无需注册
引入的组件可以直接用作自定义组件标签名,类似于JSX中的工作方式
<template>
<div>
<Foo />
</div>
</template>
<!-- 在 script 标签上添加setup属性,以使用 script setup 语法 -->
<script setup>
import Foo from './components/Foo.vue'
</script>
# 属性和方法
属性和方法无需挂载到对象上再次返回
<template>
<div>
<Foo />
<h2 @click="increment">{{ count }}</h2>
</div>
</template>
<script setup>
import { ref } from 'vue'
import Foo from './components/Foo.vue'
const count = ref(0)
const increment = () => count.value++
</script>
上述案例会被编译为
// 导入的模板会被抽离到模块级别
import { ref } from 'vue'
import Foo from './components/Foo.vue'
export default {
setup () {
const count = ref(0)
const increment = () => count.value++
// 这是一个返回h函数的render函数
// 因为被编译到了setup函数中,所以可以直接访问顶层定义的属性和方法
return () => ([
h(Foo, null, ''),
h('h2', {
count,
onClick: increment
}, count)
])
}
}
# props 和 emits
子组件
<template>
<div>
<h2>{{ count }}</h2>
<button @click="$emit('increment')">+1</button>
<button @click="decrement">-1</button>
</div>
</template>
<script setup>
// 接收props, 并返回一个对象,可以在js中使用props来获取传入的数据
const props = defineProps({
count: {
type: Number,
default: 0
}
})
// 声明需要触发的事件,返回一个emit函数,作用和this.$emit 函数的作用域是一致的
const emit = defineEmits(['increment', 'decrement'])
const decrement = () => emit('decrement')
</script>
父组件
<template>
<div>
<Foo :count="count" @increment="increment" @decrement="decrement" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import Foo from './components/Foo.vue'
const count = ref(0)
const increment = () => count.value++
const decrement = () => count.value--
</script>
- defineProps 和 defineEmits 是编译器宏(compiler macros)
只能在 <script setup>
中使用。 它们不需要被导入,并且在处理 <script setup>
时被编译掉
传递给 defineProps 和 defineEmits 的选项将被从 setup 中提升到模块范围
因此,这些选项不能引用在 setup 作用域内声明的局部变量。这样做回导致一个编译错误。 然而,它可以引用导入的绑定,因为它们也在模块范围内
export default { props: { foo: String }, emits: ['change', 'delete'], // setup中定义的props和emits会被抽取到的模块作用域中 setup(props, { emit }) { // setup code } }
props 和 emits 也可以使用 TypeScript 语法来声明,方法是向 defineProps 或 defineEmits 传递一个字面类型参数
const props = defineProps<{
foo: string,
bar?: number
}>()
const emit = defineEmits<{
(e: 'change', id: number): void
(e: 'update', value: string): void
}>()
- 使用 ts 语法来声明 props 和 emits, 没有办法为 props 提供默认值
为了解决这个问题,提供了一个 withDefaults 编译器宏(compiler macros)
interface Props {
msg?: string
}
const props = withDefaults(defineProps<Props>(), {
msg: 'hello'
})
# slots 和 attrs
在 <script setup>
中使用slots 和 attrs 应该是比较少的
如果你确实需要它们,请分别使用 useSlots 和 useAttrs 帮助函数 (helpers)
useSlots 和 useAttrs 是实际的运行时函数,其返回值等价于 setupContext.slots 和 setupContext.attrs 它们也可以在 Composition API 函数中使用
<script setup>
import { useSlots, useAttrs } from 'vue'
const slots = useSlots()
const attrs = useAttrs()
</script>
# 顶层 await
顶层的 await 可以直接在 <script setup>
里面使用。由此产生的 setup() 函数 将自动添加 async
<script setup>
const post = await fetch('/api/post/111').then(res => res.json())
</script>
# 与普通 script 一起使用
<script setup>
语法提供了表达大多数现有 Options API 选项同等功能的能力,只有少数选项除外。
- name
- inheritAttrs
- 模块导出
如果你需要声明这些选项,请使用单独的普通 <script>
块,并使用导出默认值。
<script>
export default {
name: 'CustomName',
inheritAttrs: false,
}
</script>
<!-- <script setup>
// script setup logic
</script> -->
# 19 Vue3 的 script setup 语法糖
还记得刚体验script setup 语法糖的时候,编辑器提示我这是一个实验性的提案,要使用的话,需要固定 Vue 版本.
而在 6 月底, 该提案被正式定稿,在v3.1.3 的版本上,你可以继续使用但仍会有实验性提案的提示,在v3.2中,才会清楚提示并移除一些废弃的API.
# script setup 是个啥?
它是vue3的一个新语法糖,在 setup 函数中。所有 es 模块导出都被认为是暴露给上下文的值,并包含在 setup() 返回对象中。相对于之前的写法,使用后,语法也变得简单。
使用方式极其简单,仅需要在 script 标签加上 setup 关键字即可。示例:
<script setup></script>
# 组件自动注册
在 script setup 中,引入的组件可以直接使用,无需再通过 components 进行注册,并且无法指定当前组件的名字,它会自动以文件名为主,也就是不用再写 name 属性了。示例:
<template>
<Child />
</template>
<script setup>
import Child from './Child.vue'
</script>
如果需要定义类似 name 的属性,可以再加个平级的script标签,在里面实现即可。
# 组件核心 API 的使用
# 使用 props
通过 defineProps 指定当前 props 类型,获得上下文的props对象。示例:
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
title: String,
})
</script>
# 使用 emits
使用 defineEmit 定义当前组件含有的事件,并通过返回的上下文去执行emit。示例:
<script setup>
import { defineEmits } from 'vue'
const emit = defineEmits(['change', 'delete'])
</script>
# 获取 slots 和 attrs
可以通过 useContext 从上下文获取 slots 和 attrs。不过提案在正式通过后,废除了这个语法,被拆分成了 useAttrs 和 useSlots。示例:
// 旧
<script setup>
import { useContext } from 'vue'
const { slots, attrs } = useContext()
</script>
// 新
<script setup>
import { useAttrs, useSlots } from 'vue'
const attrs = useAttrs()
const slots = useSlots()
</script>
# defineExpose API
传统的写法,我们可以在父组件中,通过 ref
实例的方式去访问子组件的内容,但在 script setup
中, 该方法就不能用了,setup
相当于是一个闭包,除了内部的 template
模板,谁都不能访问内部的数据和方法。
如果需要对外暴露 setup
中的数据和方法,需要使用 defineExpose API
。 示例:
<script setup>
import { defineExpose } from 'vue'
const a = 1
const b = 2
defineExpose({
a
})
</script>
# 属性和方法无需返回,直接使用!
这可能是带来的较大便利之一,在以往的写法中,定义数据和方法,都需要在结尾 return 出去, 才能再模板中使用。 在 script setup 中,定义的属性和方法无需返回,可以直接使用,示例:
<template>
<div>
<p>My Name is {{name}}</p>
</div>
</template>
<script setup>
import { ref } from 'vue';
const name = ref('Sam')
</script>