本文整理来自深入Vue3+TypeScript技术栈-coderwhy大神新课,只作为个人笔记记录使用,请大家多支持王红元老师。
setup顶层写法
添加setup标记之后:
- 属性和方法不用返回,可以直接使用
- 组件也可以直接使用,不用注册
- 使用defineProps指定类型,使用defineEmit定义要发出的事件
示例代码:
App.vue:
<template>
<div>
<h2>当前计数: {{counter}}</h2>
<button @click="increment">+1</button>
<!-- 3. 组件也可以直接使用,不用注册 -->
<hello-world message="呵呵呵" @increment="getCounter"></hello-world>
</div>
</template>
<script setup>
// 1. 添加setup标记
import { ref } from 'vue';
import HelloWorld from './HelloWorld.vue';
//2. 属性和方法不用返回,可以直接使用
const counter = ref(0);
const increment = () => counter.value++;
// 父组件接受子组件发出的事件
const getCounter = (payload) => {
console.log(payload);
}
</script>
<style scoped>
</style>
HelloWorld.vue:
<template>
<div>
<h2>Hello World</h2>
<h2>{{message}}</h2>
<button @click="emitEvent">发射事件</button>
</div>
</template>
<script setup>
import { defineProps, defineEmit } from 'vue';
// 没有setup标记的时候,props我们也是需要指定类型的
// 有setup标记的时候我们使用defineProps指定类型
const props = defineProps({
message: {
type: String,
default: "哈哈哈"
}
})
// 使用defineEmit定义要发出的事件
const emit = defineEmit(["increment", "decrement"]);
// 发出事件
const emitEvent = () => {
emit('increment', "100000")
}
</script>
<style scoped>
</style>
认识h函数
Vue推荐在绝大数情况下使用模板来创建你的HTML,然而一些特殊的场景,你真的需要JavaScript的完全编程的能力,这个时候你可以使用渲染函数,它比模板更接近编译器。
前面我们讲解过VNode和VDOM的改变,Vue在生成真实的DOM之前,会将我们的节点转换成VNode,而VNode组合在一起形成一颗树结构,就是虚拟DOM(VDOM)。事实上,我们之前编写的 template 中的HTML最终也是使用渲染函数生成对应的VNode,那么,如果你想充分的利用JavaScript的编程能力,我们可以自己来编写 createVNode 函数,生成对应的VNode。
那么我们应该怎么来做呢?使用 h() 函数: h() 函数是一个用于创建 vnode 的一个函数,其实更准确的命名是createVNode() 函数,但是为了简便在Vue中将之简化为 h() 函数。
可能你会懵逼了,render()函数、h()函数,createVNode()函数什么关系? render()函数是我们在组件中编写的一个选项,它要求我们返回一个VNode对象,我们利用h()函数来返回一个VNode对象,而h()函数更准确的命名是createVNode()函数。
h()函数如何使用呢?
h()函数如何使用呢?它接受三个参数:
注意:如果没有props,最好将null作为第二个参数传入,将children作为第三个参数传入,以防产生歧义。
h函数的基本使用
h函数可以在两个地方使用:
- render函数选项中;
- setup函数选项中(setup本身需要是一个函数类型,函数再返回h函数创建的VNode);
h函数计数器案例
<script>
import { h } from 'vue';
export default {
data() {
return {
counter: 0
}
},
render() {
return h("div", {class: "app"}, [
h("h2", null, `当前计数: ${this.counter}`),
h("button", {
onClick: () => this.counter++
}, "+1"),
h("button", {
onClick: () => this.counter--
}, "-1"),
])
}
}
</script>
<style scoped>
</style>
如果使用setup,代码如下:
<script>
import { ref, h } from 'vue';
export default {
setup() {
const counter = ref(0);
// 这里setup替代里data
return {
counter
}
},
render () {
return h("div", {class: "app"}, [
h("h2", null, `当前计数: ${counter.value}`),
h("button", {
onClick: () => counter.value++
}, "+1"),
h("button", {
onClick: () => counter.value--
}, "-1"),
])
}
// setup不但可以替代data还可以替代render函数,所以下面这样写也可以
setup() {
const counter = ref(0);
return () => {
return h("div", {class: "app"}, [
h("h2", null, `当前计数: ${counter.value}`),
h("button", {
onClick: () => counter.value++
}, "+1"),
h("button", {
onClick: () => counter.value--
}, "-1"),
])
}
}
}
</script>
<style scoped>
</style>
h函数中组件和插槽的使用
前面我们使用h函数第一个参数传的是元素标签的名字,其实还可以传组件。
App.vue组件代码:
// 有了render函数就不用template了
<script>
import { h } from 'vue';
import HelloWorld from './HelloWorld.vue';
export default {
render() {
return h("div", null, [
h(HelloWorld, null, { // 使用子组件
default: props => h("span", null, `app传入到HelloWorld中的内容: ${props.name}`)
// 给子组件传递默认插槽
// props.name使用作用域插槽对子组件的数据进行加工
})
])
}
}
</script>
<style scoped>
</style>
HelloWorld.vue组件代码:
// 有了render函数就不用template了
<script>
import { h } from "vue";
export default {
render() {
// HelloWorld组件也是通过render函数创建出来的
return h("div", null, [
h("h2", null, "Hello World"),
// 使用传进来的默认插槽
// 通过{name: "coderwhy"}给父组件传参数,就是作用域插槽
this.$slots.default ? this.$slots.default({name: "coderwhy"}): h("span", null, "我是HelloWorld的插槽默认值")
])
}
}
</script>
<style lang="scss" scoped>
</style>
jsx的babel配置
上面的代码显然难以阅读,如果我们又想充分利用JavaScript的编程能力又不想写上面那些代码,我们可以使用jsx,jsx和React很像。
如果我们希望在项目中使用jsx,那么我们需要添加对jsx的支持。jsx我们通常会通过Babel来进行转换(React编写的jsx就是通过babel转换的),对于早期的脚手架默认不支持jsx,我们只需要在Babel中配置对应的插件即可。
安装Babel支持Vue的jsx插件:
npm install @vue/babel-plugin-jsx -D
在babel.config.js配置文件中配置插件:
但是现在的脚手架默认已经支持jsx,我们直接写jsx代码就行了。
jsx计数器案例
App.vue:
<script>
import HelloWorld from './HelloWorld.vue';
export default {
data() {
return {
counter: 0
}
},
render() {
const increment = () => this.counter++;
const decrement = () => this.counter--;
return (
<div>
<h2>当前计数: {this.counter}</h2>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
// 还可以使用组件
<HelloWorld>
{{default: props => <button> 我是按钮 </button>}}
</HelloWorld>
</div>
)
}
}
</script>
<style lang="scss" scoped>
</style>
HellooWorld.vue
<script>
export default {
render() {
return (
<div>
<h2>HelloWorld</h2>
{this.$slots.default ? this.$slots.default(): <span>哈哈哈</span>}
</div>
)
}
}
</script>
<style scoped>
</style>
这种jsx代码本质上也是通过Babel转成h函数的。
认识自定义指令
在Vue的模板语法中我们学习过各种各样的指令:v-show、v-for、v-model等等,除了使用这些指令之外,Vue也允许我们来自定义自己的指令。
通常在某些情况下,你需要对DOM元素进行底层操作,这个时候就会用到自定义指令。(注意,在Vue中,代码的复用和抽取主要还是通过组件)。
自定义指令分为两种:
自定义局部指令:组件中通过 directives 选项,只能在当前组件中使用;
自定义全局指令:app的 directive 方法,可以在任意组件中被使用;
比如我们来做一个非常简单的案例:当某个元素挂载完成后可以自定获取焦点。
方式一:如果我们使用默认的实现方式;
方式二:自定义一个 v-focus 的局部指令;
方式三:自定义一个 v-focus 的全局指令;
先复习一下vue2的自定义指令
全局自定义指令:
// 注册一个全局自定义指令 `v-focus`
Vue.directive('focus', {
// 当被绑定的元素插入到 DOM 中时……
inserted: function (el) {
// 聚焦元素
el.focus()
}
})
如果想注册局部指令,组件中也接受一个 directives 的选项:
directives: {
focus: {
// 指令的定义
inserted: function (el) {
el.focus()
}
}
}
然后你可以在模板中任何元素上使用新的 v-focus 指令了,如下:
<input v-focus>
一个指令定义对象可以提供如下几个钩子函数 (均为可选):
bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。inserted:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。update:所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新 (详细的钩子函数参数见下)。componentUpdated:指令所在组件的 VNode 及其子 VNode 全部更新后调用。unbind:只调用一次,指令与元素解绑时调用。
vue3的自定义指令
我们已经介绍了两种在 Vue 中重用代码的方式:组件和组合式函数(其实就是前面的CompositionAPI初体验的代码)。组件是主要的构建模块,而组合式函数则侧重于有状态的逻辑。另一方面,自定义指令主要是为了重用涉及普通元素的底层 DOM 访问的逻辑。
一个自定义指令由一个包含类似组件生命周期钩子的对象来定义。钩子函数会接收到指令所绑定元素作为其参数。下面是一个自定义指令的例子,当一个 input 元素被 Vue 插入到 DOM 中后,它会被自动聚焦:
实现方式一:聚焦的默认实现
实现方式二:局部自定义指令
自定义一个 v-focus 的局部指令,这个自定义指令实现非常简单,我们只需要在组件选项中使用 directives 即可,它是一个对象,在对象中编写我们自定义指令的名称(注意:这里不需要加v-),自定义指令有一个生命周期,是在组件挂载后调用 mounted,我们可以在其中完成操作。
<template>
<div>
<input type="text" v-focus>
</div>
</template>
<script>
export default {
// 局部指令
directives: {
focus: {
// 参数一:当前el元素
// 参数二:绑定的一个对象,传入的参数和修饰符就在这里面
// 参数三:虚拟节点
// 参数四:上一个虚拟节点
mounted(el, bindings, vnode, preVnode) {
//console.log("focus mounted");
el.focus();
}
}
}
}
</script>
<style scoped>
</style>
方式三:自定义全局指令
自定义一个全局的v-focus指令可以让我们在任何地方直接使用。
import { createApp } from 'vue'
const app = createApp(App);
app.directive("focus", {
mounted(el, bindings, vnode, preVnode) {
//console.log("focus mounted");
el.focus();
}
})
app.mount('#app');
vue3自定义指令的生命周期、参数
一个指令定义的对象,Vue提供了如下几个钩子函数:
const myDirective = {
// 在绑定元素的 attribute 前
// 或事件监听器应用前调用
created(el, binding, vnode, prevVnode) {
// 下面会介绍各个参数的细节
},
// 在元素被插入到 DOM 前调用
beforeMount(el, binding, vnode, prevVnode) {},
// 在绑定元素的父组件
// 及他自己的所有子节点都挂载完成后调用
mounted(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件更新前调用
beforeUpdate(el, binding, vnode, prevVnode) {},
// 在绑定元素的父组件
// 及他自己的所有子节点都更新后调用
updated(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件卸载前调用
beforeUnmount(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件卸载后调用
unmounted(el, binding, vnode, prevVnode) {}
}
每个钩子函数都有四个参数:el, bindings, vnode, preVnode。 第二个参数bindings里面就有我们的参数和修饰符和value。
bindings里面的具体信息如下:
<template>
<div>
<button v-if="counter < 2" v-why:info.aaaa.bbbb="'coderwhy'" @click="increment">当前计数: {{counter}}</button>
</div>
</template>
<script>
import { ref } from "vue";
export default {
// 局部指令
directives: {
why: {
created(el, bindings, vnode, preVnode) {
console.log("why created", el, bindings, vnode, preVnode);
// 传递过来的参数,就是info
console.log(bindings.arg);
// 传递过来的值,就是coderwhy
console.log(bindings.value);
// 获取修饰符,modifiers是个对象 {aaa: true, bbb: true}
console.log(bindings.modifiers);
},
}
},
setup() {
const counter = ref(0);
const increment = () => counter.value++;
return {
counter,
increment
}
}
}
</script>
<style scoped>
</style>
自定义指令练习:时间戳的显示需求
在开发中,大多数情况下从服务器获取到的都是时间戳,我们需要将时间戳转换成具体格式化的时间来展示。在Vue2中我们可以通过过滤器来完成,Vue3中删除了过滤器,在Vue3中我们可以通过计算属性(computed)或者自定义一个方法(methods)来完成,其实我们还可以通过一个自定义的指令来完成。
我们来实现一个可以自动对时间格式化的指令v-format-time,这里我封装了一个函数,在首页中我们只需要调用这个函数并且传入app即可。
我们创建directives文件夹,format-time.js是每个指令的具体代码,index.js是注册每个指令,这样我们在main.js中直接导入,然后传入app即可,文件目录如下:
format-time.js代码如下:
// 需要通过npm install dayjs
import dayjs from 'dayjs';
export default function(app) {
app.directive("format-time", {
// 1. 先设置时间格式参数
created(el, bindings) {
// 设置默认的时间格式
// 这里我们把值设置到bindings上,目的就是为了在bindings里面能取到
// 如果我们不设置到bindings上就没法传给mounted了
bindings.formatString = "YYYY-MM-DD HH:mm:ss";
//如果自定义指令传递了时间格式参数,就使用传递的参数
if (bindings.value) {
bindings.formatString = bindings.value;
}
},
// 2. 再转换
mounted(el, bindings) {
//获取显示的文字
const textContent = el.textContent;
//转成int
let timestamp = parseInt(textContent);
//如果10位就是秒,转成毫秒
if (textContent.length === 10) {
timestamp = timestamp * 1000
}
//format后面的参数使用传递的参数
el.textContent = dayjs(timestamp).format(bindings.formatString);
}
})
}
index.js文件代码如下:
import registerFormatTime from './format-time';
export default function registerDirectives(app) {
registerFormatTime(app);
}
main.js文件代码如下:
import { createApp } from 'vue'
import App from './03_自定义指令/App.vue'
import registerDirectives from './directives'
const app = createApp(App);
registerDirectives(app);
app.mount('#app');
这样我们就将main.js里面注册全局组件的代码抽出来了,下次如果需要注册新的全局组件,只需在index.js里面添加一行代码即可。
Teleport基本用法
<Teleport> 是一个内置组件,它可以将一个组件内部的一部分模板“传送”到该组件的 DOM 结构外层的位置去。
有时我们可能会遇到这样的场景:一个组件模板的一部分在逻辑上从属于该组件,但从整个应用视图的角度来看,它在 DOM 中应该被渲染在整个 Vue 应用外部的其他地方。
这类场景最常见的例子就是全屏的Dialog框。理想情况下,我们希望触发模态框的按钮和模态框本身是在同一个组件中,因为它们都与组件的开关状态有关。但这意味着该模态框将与按钮一起渲染在应用 DOM 结构里很深的地方。这会导致该模态框的 CSS 布局代码很难写。
试想下面这样的 HTML 结构:
<div class="outer">
<h3>Tooltips with Vue 3 Teleport</h3>
<div>
<MyModal />
</div>
</div>
接下来我们来看看 <MyModal> 的实现:
<script>
export default {
data() {
return {
open: false
}
}
}
</script>
<template>
<button @click="open = true">Open Modal</button>
<div v-if="open" class="modal">
<p>Hello from the modal!</p>
<button @click="open = false">Close</button>
</div>
</template>
<style scoped>
.modal {
position: fixed;
z-index: 999;
top: 20%;
left: 50%;
width: 300px;
margin-left: -150px;
}
</style>
这个组件中有一个 <button> 按钮来触发打开模态框,和一个 class 名为 .modal 的 <div>,它包含了模态框的内容和一个用来关闭的按钮。
当在初始 HTML 结构中使用这个组件时,会有一些潜在的问题:
position: fixed能够相对于浏览器窗口放置有一个条件,那就是不能有任何祖先元素设置了transform、perspective或者filter样式属性。也就是说如果我们想要用 CSStransform为祖先节点<div class="outer">设置动画,就会不小心破坏模态框的布局!- 这个模态框的
z-index受限于它的容器元素。如果有其他元素与<div class="outer">重叠并有更高的z-index,则它会覆盖住我们的模态框。
<Teleport> 提供了一个更简单的方式来解决此类问题,让我们不需要再顾虑 DOM 结构的问题。让我们用 <Teleport> 改写一下 <MyModal>:
<button @click="open = true">Open Modal</button>
<Teleport to="body">
<div v-if="open" class="modal">
<p>Hello from the modal!</p>
<button @click="open = false">Close</button>
</div>
</Teleport>
<Teleport> 接收一个 to prop 来指定传送的目标。to 的值可以是一个 CSS 选择器字符串,也可以是一个 DOM 元素对象。这段代码的作用就是告诉 Vue“把以下模板片段传送到 body 标签下”。
你可以点击这个按钮,然后通过浏览器的开发者工具,在 <body> 标签下就会找到模态框元素。
可以发现app和h2是平级的,都在body下面。
搭配组件使用
<Teleport> 只改变了渲染的 DOM 结构,它不会影响组件间的逻辑关系。也就是说,如果 <Teleport> 包含了一个组件,那么该组件始终和这个使用了 <teleport> 的组件保持逻辑上的父子关系。传入的 props 和触发的事件也会照常工作。
这也意味着来自父组件的注入也会按预期工作,子组件将在 Vue Devtools 中嵌套在父级组件下面,而不是放在实际内容移动到的地方。
禁用 Teleport
在某些场景下可能需要视情况禁用 <Teleport>。举例来说,我们想要在桌面端将一个组件当做浮层来渲染,但在移动端则当作行内组件。我们可以通过对 <Teleport> 动态地传入一个 disabled prop 来处理这两种不同情况。
<Teleport :disabled="isMobile">
...
</Teleport>
这里的 isMobile 状态可以根据 CSS media query 的不同结果动态地更新。
多个 Teleport 共享目标
一个可重用的模态框组件可能同时存在多个实例。对于此类场景,多个 <Teleport> 组件可以将其内容挂载在同一个目标元素上,而顺序就是简单的顺次追加。
比如下面这样的用例:
<Teleport to="#modals">
<div>A</div>
</Teleport>
<Teleport to="#modals">
<div>B</div>
</Teleport>
渲染的结果为:
<div id="modals">
<div>A</div>
<div>B</div>
</div>
nexttick
官方解释:将回调推迟到下一个 DOM 更新周期之后执行。在更改了一些数据以等待 DOM 更新后立即使用它。字面意思就是等DOM更新之后做一些操作。
比如我们有下面的需求:点击一个按钮,我们会修改在h2中显示的message,message被修改后,获取h2的高度。
实现上面的案例我们有三种方式:
方式一:在点击按钮后立即获取到h2的高度(错误的做法);
方式二:在updated生命周期函数中获取h2的高度(但是其他数据更新,也会执行该操作);
方式三:使用nexttick函数;
<template>
<div>
<h2>{{counter}}</h2>
<button @click="increment">+1</button>
<h2 class="title" ref="titleRef">{{message}}</h2>
<button @click="addMessageContent">添加内容</button>
</div>
</template>
<script>
import { ref, onUpdated, nextTick } from "vue";
export default {
setup() {
const message = ref("")
const titleRef = ref(null)
const counter = ref(0)
const addMessageContent = () => {
message.value += "哈哈哈哈哈哈哈哈哈哈"
// 方式三:nextTick就相当于将我们要执行的操作延迟到DOM更新完之后再执行
nextTick(() => {
// 再打印高度
console.log(titleRef.value.offsetHeight)
})
}
const increment = () => {
for (let i = 0; i < 100; i++) {
counter.value++
}
}
// 方式二:在updated生命周期函数中获取h2的高度(但是其他数据更新,也会执行该操作)
// 比如点击+1的操作,onUpdated也会回调
onUpdated(() => {
console.log(titleRef.value.offsetHeight)
})
return {
message,
counter,
increment,
titleRef,
addMessageContent
}
}
}
</script>
<style scoped>
.title {
width: 120px;
}
</style>
nexttick原理
nextTick就是在下次dom更新之后执行回调,本质是对事件循环一种应用。
比如我们点击按钮,修改了文本,它是个宏任务,这时候微任务队列里面没有任务,所以宏任务会先执行,宏任务执行完,数据修改了,就会触发vue的各种任务队列,vue内部维护了很多队列,比如watch回调函数的队列preQuene,组件更新的队列jobQuene,生命周期回调队列postQuene,在同一个tick中,它们都会被加入到浏览器事件循环的微任务队列中,如果遇到nextTick,就会把nextTick放到微任务队列的最后面,这样在下一个tick的时候,执行微任务队列的任务,由于队列的先进先出,所以肯定是dom先更新,nextTick后执行,这样我们就能拿到更新之后的dom了。
编写Vue插件
Vue.use的原理其实不复杂,它的功能主要就是两点:安装Vue插件、已安装插件不会重复安装;
- 先声明一个数组,用来存放安装过的插件,如果已安装就不重复安装;
- 然后判断
plugin是不是对象,如果是对象就判断对象的install是不是一个方法,如果是就将app参数传入并执行install方法,完成插件的安装; - 如果
plugin是一个方法,就app参数直接执行; - 最后将
plugin推入上述声明的数组中,表示插件已经安装;
插件可以完成的功能没有限制,比如下面的几种都是可以的:
- 添加全局方法或者 property,通过把它们添加到 config.globalProperties 上实现;
- 添加全局资源:指令/过滤器/过渡等;
- 通过全局 mixin 来添加一些组件选项;
- 一个库,提供自己的 API,同时提供上面提到的一个或多个功能;
简单使用如下:
plugins_object.js文件:
export default {
install(app) {
// 全局属性上添加一个属性
app.config.globalProperties.$name = "coderwhy"
}
}
plugins_function.js文件:
export default function(app) {
console.log(app);
}
main.js文件里面安装插件:
import { createApp } from 'vue'
import App from './03_自定义指令/App.vue'
import pluginObject from './plugins/plugins_object'
import pluginFunction from './plugins/plugins_function'
const app = createApp(App);
app.use(pluginObject);
app.use(pluginFunction);
app.mount('#app');
安装之后再App.vue里面就可以使用插件添加的全局属性了。
setup() {
// setup中拿到全局属性
const instance = getCurrentInstance();
console.log(instance.appContext.config.globalProperties.$name);
},
// optionalAPI里面拿到全局属性
mounted() {
console.log(this.$name);
},