CompositionAPI(三)setup顶层写法、h函数、jsx、自定义指令、Teleport、nexttick、编写vue插件

289 阅读16分钟

本文整理来自深入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 能够相对于浏览器窗口放置有一个条件,那就是不能有任何祖先元素设置了 transformperspective 或者 filter 样式属性。也就是说如果我们想要用 CSS transform 为祖先节点 <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插件、已安装插件不会重复安装;

  1. 先声明一个数组,用来存放安装过的插件,如果已安装就不重复安装;
  2. 然后判断plugin是不是对象,如果是对象就判断对象的install是不是一个方法,如果是就将app参数传入并执行install方法,完成插件的安装;
  3. 如果plugin是一个方法,就app参数直接执行;
  4. 最后将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);
},