Vue3 - 101 挑战

2023年03月08上次更新于 大约 1 个月前
编程

image

感谢Vue.js挑战 | Vue.js挑战系列文章,本文是笔者自己进行vue挑战的知识总结。推荐在阅读时配合Introduction | Vue.js“食用”!

PS:本文全文使用typescriptvue3 options api with setup syntax suger!

内置 API 挑战

DOM 传送门

vue 提供了Teleport组件来将组件内的一部分模板渲染到指定的DOM节点下面(即渲染到此组件之外)。

这个组件最常见的需求就是模态框,动态的模态框从整个应用的视角下来看,应该是某种场景下被触发而产生的,并且为了让 CSS 层级更为清晰或易于控制其样式免受父元素影响,最佳实践应该是将之渲染到 body 或某些特殊节点下会更好。

看示例:

<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>

通常,给to一个元素选择器即可,并且还支持disabled属性来控制某些场景下的特殊需求,例如在移动端将之视为普通的内部组件来使用,如此一来可以减少一些额外的逻辑判断代码。

最后,推荐为teleport设置一个固定的目标元素而不是使用body,因为在很多场景下动态地追加元素到body下可能会产生一些意想不到的变动,例如 SSR 服务端渲染的情况下,通常 body 就会包含一些服务端渲染的代码,从而让teleport难以确定激活的正确位置。

性能优化的指令

如果你有一个仅渲染一次的组件或元素,则可以为之添加v-once指令,从而让 vue 在更具数据更新重新渲染的时候跳过这些元素。

说人话就是,如果你需要在某个地方渲染一个响应式数据,却不希望其数据变更后重新渲染这个地方,则可以添加v-once指令。举例:在某个位置同时渲染初始值和动态初始值。

此外,在vue3.2还新增了一个v-memo指令:

<div v-memo="[valueA, valueB]">
  ...
</div>

在组件渲染时当且仅当valueAvalueB都不变时,其内部的渲染将被跳过。这个场景其实比较少,如果你有超过1000v-for循环,则可以使用这个机制进行渲染优化,否则这个渲染就没多大必要了,毕竟缓存条件对比也需要消耗资源。

CSS 属性

动态 CSS

如果你想使用一个响应式的变量去控制某个 DOM 的 CSS 属性,通常有两种简单的方式:

  1. 给节点添加style,其值使用变量来替换
  2. 在 style 模板选择器中使用v-bind(variable)来设置属性

代码示例:

<script setup>
import { ref } from 'vue';
const theme = ref('red');
const px = ref(40);

const colors = ['blue', 'yellow', 'red', 'green'];

setInterval(() => {
  theme.value = colors[Math.floor(Math.random() * 4)];
}, 1000);
</script>

<template>
  <p>hello</p>
</template>

<style scoped>
p {
  color: v-bind(theme);
  font-size: v-bind('"100px"');
  width: v-bind(`${px}px`);
}
</style>

v-bind函数内是一个表达式(需要用单引号包起来),theme还能自动解构,如果你需要设置一些单位则可以使用引号。

常规 CSS

vue组件中,局部样式和全局样式示例如下:

<template>
  <p>Hello Vue.js</p>
</template>

<style scoped>
p {
  font-size: 20px;
  color: red;
  text-align: center;
  line-height: 50px;
}

:deep(body) {
  width: 100vw;
  height: 100vh;
  background-color: burlywood;
}

:global(body) {
  width: 100vw;
  height: 100vh;
  background-color: burlywood;
}
  
:slotted(div) {
  color: blue;
}
</style>

vue文件最终将编译成jscss,在上面的例子中style加上了scoped标志会让vue-loader在编译的时候给html标签添加随机的data-v-自定义属性来配合选择器进行组件之间的样式隔离。

此外,还可以使用:global伪类来实现全局样式设置,使用:deep()伪类来影响更深处的子组件样式。由于默认样式不会影响插槽内的内容,因此可以使用:slotted()插槽选择器来设置传入插槽的样式。

标签结合 scoped 限制作用域时的效率相对类名或 id 来说会慢许多倍(来自官方文档的解释),此外组件还需要小心引用递归现象。

此外,使用v-html动态生成的内容不受scoped影响,此时可以使用:deep()选择器来设置样式,亦或使用单独的样式表。

CSS Module

使用scoped属性会在html的标签中生成用于样式隔离的data-v-自定义属性,如果真的不想要这些自定义属性,则可以考虑CSS Module方案,这里就不深入讨论了。

组件

Prop 验证

组件需要显式申明其接受的prop,如此一来便可以于透传的属性进行区分。

prop 需要注意的是推荐和社区保持一致,使用kebad-case语法,如此一来可以和 html 属性的书写形式保持一致。

prop可以传递不同类型的值,当传递的proptrue时,推荐直接写此属性即可,可以让代码稍微简洁一些。

可以使用一个变量绑定多个prop:

export default {
  data() {
    return {
      post: {
        id: 1,
        title: 'My Journey with Vue'
      }
    }
  }
}

// 传递
<BlogPost v-bind="post" />
// 等同于
<BlogPost :id="post.id" :title="post.title" />

单项数据流是vue组件使用的最佳实践,不要去改动对象或数组类型的prop,即使你可以做到。

通常,我们可以写prop类型检验来提高开发效率,如果你使用 JavaScript 开发 vue 程序,可以参考文档来编写类型检查:Props | Vue.js

而如果你像我一样使用TypeScript构建应用,则可以参考如下示例:

<script setup lang="ts">
  import { type PropType } from 'vue'
  withDefaults(defineProps<{
    title?: string
    likes: number,
    labels?: string[]
  }>(), {
    // here we have default values
    title: '---',
    labels: () => ['one', 'two']
  })
</script>

对于原始数据类型可以直接使用withDefaults函数的第二个参数传递值,而对象则在传值时使用返回其正确类型的函数。

函数式组件

有时候你可能会需要一个函数式的组件:

import { h } from "vue";

const DynamicHeading = (props, context) => {
  return h(`h${props.level}`, context.attrs, context.slots);
};

其用法如下:

<dynamic-heading :level="1">Hello</dynamic-heading>
<dynamic-heading :level="2">World</dynamic-heading>

函数式组件的传入参数:propscontext,其中context包括:attrs/slots/emit属性。其实现最终是返回一个 h()函数创建的vnode!

上述代码是一个动态标题的函数式组件,根据传入的props.level来决定渲染的是h1还是h2等,函数式组件在某些场景比传统组件更易用,甚至更易于组织代码。传统组件一个组件即一个文件,而函数式组件可以将多个功能性的组件放在同一个文件。

树组件

树组件的需求即得知其数据格式是树,对于树这种结构我们通常可以考虑递归的方法去遍历数据。

<script setup lang="ts">
interface TreeData {
  key: string;
  title: string;
  children: TreeData[];
}
defineProps<{ data: TreeData[] }>();
</script>

<template>
  <ul>
    <li v-for="{ key, title, children } in data" :key="key">
      <span>{{ title }}</span>
      <TreeComponent v-if="children" :data="children"></TreeComponent>
    </li>
  </ul>
</template>

上述代码文件为:TreeComponent.vue,在模板内部可以使用自身来实现递归。

可组合函数

vue composable function 指的是用 vue 的 composition API 来封装和复用具有逻辑状态的函数。

举个切换器的例子:

<script setup lang="ts">
import { ref } from 'vue';
/**
 * Implement a composable function that toggles the state
 * Make the function work correctly
 */
function useToggle(init: boolean) {
  const state = ref(init);
  const toggle = () => (state.value = !state.value);
  return [state, toggle];
}

const [state, toggle] = useToggle(false);
</script>

<template>
  <p>State: {{ state ? 'ON' : 'OFF' }}</p>
  <p @click="toggle">Toggle state</p>
</template>

编写一个useToggle函数来快速创建可复用的state和翻转其状态的函数,这个功能可以方便地在很多地方复用。并且因为这是一个纯函数,我们可以方便地扩展和测试。

再举个计数器的例子:

<script setup lang="ts">
import { ref } from 'vue';
interface UseCounterOptions {
  min?: number;
  max?: number;
}

/**
 * Implement the composable function
 * Make sure the function works correctly
 */
function useCounter(initialValue = 0, options: UseCounterOptions = {}) {
  const count = ref(initialValue);

  const inc = () => {
    if (count.value === options?.max) {
      return;
    }
    count.value += 1;
  };
  const dec = () => {
    if (count.value === options?.min) {
      return;
    }
    count.value -= 1;
  };
  const reset = () => (count.value = initialValue);
  return { count, inc, dec, reset };
}

const { count, inc, dec, reset } = useCounter(0, { min: 0, max: 10 });
</script>

<template>
  <p>Count: {{ count }}</p>
  <button @click="inc">inc</button>
  <button @click="dec">dec</button>
  <button @click="reset">reset</button>
</template>

组合式 API - Composition API

生命周期钩子

这一题的问题在于收起子组件之后,全局的定时器任务没有被清除,因此再次挂载渲染子组件之后会再次执行一个新的定时任务,二者会影响到count值。

因此,问题的关键在于:我们需要在卸载子组件的时候清除定时器任务。

关于组件的生命周期,可以看看这个图示:

查看文档:Composition API: Lifecycle Hooks | Vue.js

因此,我们可以使用onUnmounted生命周期函数,传入一个回调函数,在这个函数中清理掉定时任务即可!

Ref - 你应该知道的知识

ref是响应式 API 最常见的知识之一,接受一个内部值,返回一个响应式的、可更改的 ref 对象,此对象只有一个指向其内部值的属性 .value。它可以在我们组件挂载之后获取一个特定DOM元素或子组件实例的直接引用。ref也是一个可以修改的响应式对象,并且可以追踪和触发相关的副作用。

TypeScript中,要为ref标注类型可以从vue导出类型Ref!

在处理ref的时候,可以多回顾以下的几个工具函数。这些函数能帮助你更好地处理ref

请注意:ref 将会对传入的数据进行深度响应式,修改其对象或数组中的所有属性都会触发更新。

  • isRef:检查某个值是否为Ref类型的值
  • unref:如果传入的参数是Ref类型,则返回其内部值,否则返回参数本身。如果某些场景下你得到的值可能是Ref,那么可以直接使用unref去取值而不必写判断逻辑。
  • toRef:基于传入的响应式对象的一个属性去创建一个Ref响应式对象,这个对象将会追踪传入的对象的值的变化来同步返回值。
  • toRefs:将传入的响应式对象转换为一个普通对象,这个对象的每个属性都指向源对象对应属性的ref(每个ref都使用toRef创建),如此一来在某些场合下解构将不会丢失响应性。

这里为toRefs写一个示例用于解决vue挑战的响应性丢失的问题:

<script setup lang="ts">
import { reactive, toRefs } from 'vue';

function useCount() {
  const state = reactive({
    count: 0,
  });

  function update(value: number) {
    state.count = value;
  }

  return {
    state: toRefs(state),
    update,
  };
}

// Ensure the destructured properties don't lose their reactivity
const {
  state: { count },
  update,
} = useCount();
</script>

<template>
  <div>
    <p>
      <span @click="update(count - 1)">-</span>
      {{ count }}
      <span @click="update(count + 1)">+</span>
    </p>
  </div>
</template>

如此一来useCount返回值解构之后,count依然是一个ref响应式对象,源属性改变时count将会更新。

Shallow Ref

Shallow Refref的浅层作用形式,只有修改.value的时候会触发更新机制,因此在创建大型数据结构的时候我们可以考虑使用浅层优化。

但是如果你真希望对浅层ref的属性进行修改后触发更新,也可以显式地调用triggerRef函数,传入此浅层ref即可。

Custom Ref

customRef()函数可以创建一个自定义的ref,并且显式声明对其依赖追踪和更新触发的控制方式。先看看其类型签名:

function customRef<T>(factory: CustomRefFactory<T>): Ref<T>

type CustomRefFactory<T> = (
  track: () => void,
  trigger: () => void
) => {
  get: () => T
  set: (value: T) => void
}

如果你需要创建一个响应式对象,并且对其进行更细粒度的控制(在读写的时候添加逻辑),那么使用customRef是个很好的方式,尤其是在创建composable function的时候。

这里我举两个代码示例:

示例 1:基于sessionStorage的请求缓存Ref

import { customRef } from 'vue'

function useCache(key, fn) {
  return customRef((track, trigger) => {
    let cache = sessionStorage.getItem(key)
    if (cache) {
      cache = JSON.parse(cache)
    } else {
      fn().then(data => {
        cache = data
        sessionStorage.setItem(key, JSON.stringify(cache))
        trigger()
      })
    }
    track()
    return {
      get() {
        track()
        return cache
      },
      set(value) {
        cache = value
        sessionStorage.setItem(key, JSON.stringify(cache))
        trigger()
      }
    }
  })
}

// 使用示例
const dataRef = useCache('data', () => {
  return fetch('https://api.example.com/data').then(res => res.json())
})

// 访问(读取)dataRef 时,如果缓存中有数据,则直接返回缓存的数据,否则进行异步请求,并将结果保存到缓存中
console.log(dataRef.value)

其中关键的代码是track()trigger(),二者功能如下:

  • track:调用后立即记录当前函数正在访问数据
  • trigger:调用后立即更新依赖此数据的组件函数

通常,我们应该在get()方法中调用track()函数,在set()方法中调用trigger()函数。

示例 2:封装自定义表单控件

import { customRef } from 'vue'

function useCustomInput(initialValue, validator) {
  return customRef((track, trigger) => {
    let value = initialValue
    let error = ''
    track()
    return {
      get() {
        track()
        return value
      },
      set(newValue) {
        if (validator(newValue)) {
          value = newValue
          error = ''
        } else {
          error = 'Invalid input'
        }
        trigger()
      },
      error: () => error
    }
  })
}

// 使用示例
const nameRef = useCustomInput('', value => value.length >= 3)
console.log(nameRef.value) // ''
console.log(nameRef.error()) // 'Invalid input'
nameRef.value = 'John'
console.log(nameRef.value) // 'John'
console.log(nameRef.error()) // ''

最后,再来看看vue挑战的自定义防抖ref的题目:

<script setup>
import { watch, customRef } from 'vue';

/**
 * Implement the function
 */
function useDebouncedRef(value, delay = 200) {
  return customRef((track, trigger) => {
    // 声明 timer 为 null 而不是 0 可以防止可能存在的其他任务 timer 自动分配为 0 导致的任务被不可预测地取消
    let timer = null; // 不能放在 return customRef 上面,否则多个 ref 实例将会访问同一个 timer
    return {
      set(v) {
        // 检查值是否有变化可以减少渲染 trigger
        if(v === value) return
        clearTimeout(timer);
        timer = setTimeout(() => {
          value = v;
          trigger();
        }, delay);
      },
      get() {
        track();
        return value;
      },
    };
  });
}
const text = useDebouncedRef('hello');

/**
 * Make sure the callback only gets triggered once when entered multiple times in a certain timeout
 */
watch(text, (value) => {
  console.log(value);
});
</script>

<template>
  <input v-model="text" />
</template>

Reactive

我们可以使用reactive()创建一个响应式代理,这个转换是深层的。举个例子:

const data = reactive({
    meta: {
        text: 'x'	
    }
})

修改data.meta.text的值将会触发更新,如果你使用watch函数侦听data,那么就会执行回调函数。

此外,如果data的某个属性是一个ref

const count = ref(1)
const obj = reactive({ count })

// ref 会被解包
console.log(obj.count === count.value) // true

// 会更新 `obj.count`
count.value++
console.log(count.value) // 2
console.log(obj.count) // 2

// 也会更新 `count` ref
obj.count++
console.log(obj.count) // 3
console.log(count.value) // 3

如果我们创建的reactive(obj)响应式代理数据较大,那么深层转换所消耗的性能则越多,并且我们只想要保留对顶层属性的响应性,那么可以使用shallowReactive()来替代以优化性能。

可写的计算属性

如果想要控制响应式对象的读写逻辑,可以考虑使用computed接口:

<script setup lang="ts">
import { ref, computed } from 'vue';

const count = ref(1);
const plusOne = computed({
  get() {
    return count.value + 1;
  },
  set(v) {
    count.value = v - 1;
  },
});

/**
 * 确保 `plusOne` 可以被写入。
 * 最终我们得到的结果应该是 `plusOne` 等于 3 和 `count` 等于 2。
*/

plusOne.value++;
</script>

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ plusOne }}</p>
  </div>
</template>

通常我们传给computed函数一个函数作为参数,此函数的返回值被转化成一个ref对象,但是如果需要对写进行额外的逻辑控制,则可以不传函数而传一个对象,这个对象如上所示具有setget属性,其函数签名如下:

// 只读
function computed<T>(
  getter: () => T,
  // 查看下方的 "计算属性调试" 链接
  debuggerOptions?: DebuggerOptions
): Readonly<Ref<Readonly<T>>>

// 可写的
function computed<T>(
  options: {
    get: () => T
    set: (value: T) => void
  },
  debuggerOptions?: DebuggerOptions
): Ref<T>

当我们用TypeScript进行开发时,既可以显式地指名T的类型,也可以通过自动类型推断机制让ref自动推断出归属的类型。

watch everything

首先,vue支持让开发者监听某个或某些响应式数据源亦或是一个函数的返回值,从而在数据源变化之后调用回调函数。

先来看看watch函数的类型定义:

// 侦听单个来源
function watch<T>(
  source: WatchSource<T>,
  callback: WatchCallback<T>,
  options?: WatchOptions
): StopHandle

// 侦听多个来源
function watch<T>(
  sources: WatchSource<T>[],
  callback: WatchCallback<T[]>,
  options?: WatchOptions
): StopHandle

type WatchCallback<T> = (
  value: T,
  oldValue: T,
  onCleanup: (cleanupFn: () => void) => void
) => void

type WatchSource<T> =
  | Ref<T> // ref
  | (() => T) // getter
  | T extends object
  ? T
  : never // 响应式对象

interface WatchOptions extends WatchEffectOptions {
  immediate?: boolean // 默认:false
  deep?: boolean // 默认:false
  flush?: 'pre' | 'post' | 'sync' // 默认:'pre'
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void
}

可以看到watch source可以是以下几种类型:

  • 一个响应式对象
  • function
  • 多个观察对象的数组(任意元素改变都将触发回调函数)

回调函数的参数基于你的监听类型而定,举个例子如果仅仅监听单个数据源则回调函数将被传入valueoldValue,如果监听的是数组则相应的新值和旧值也是数组。

如果需要处理新旧的值,则可以考虑对回调函数定义添加合理的参数类型。

watch除了监听对象和回调函数,还可以添加options来细粒度控制整个代码逻辑。

我们可以为options设置以下的键值:

  • immediate:这是一个布尔值,顾名思义,在创建侦听器的时候立即调用回调函数,其默认值是false
  • deep:这也是一个布尔值,如果数据源是一个具有深层键值对的对象或数组,那么整个侦听将会消耗更多性能去检查每一个深层的值的变化,谨慎使用此设置,越深的对象监听的开销越大。
  • flush: 控制回调函数的执行时机,默认为pre,这意味着在你的回调函数中如果去访问DOM,那么得到的值将会是vue更新之前(组件渲染前)的值,配合pre的字面意思我们也比较容易记下这一点。
    • 'pre'
    • 'post' 意味着在侦听回调中访问DOM将得到vue更新之后的值
    • 'sync' 仅当回调函数中包含复杂的计算DOM 更新发送请求等操作时可以设置此选项,从而让回调函数直接在数据源变化后同步执行。(是的,watch 底层实现通过reactive函数创建一个响应式对象,并且为settergetter添加了依赖和触发回调函数的逻辑,默认情况下回调函数最终会再次封装成一个异步调度任务SchedulerJob对象)添加到更新队列中,也就是说默认情况下回调并不是同步任务

在回调函数中,我们必须小心不能去做可能导致监听对象变化的操作,这样可能会导致回调循环。

最后,我们或许需要停止侦听器,每个侦听器函数都会返回一个停止侦听的函数,利用这个函数即可停止侦听。

除了watch,我们可能会在其他人代码里看到watchEffect!这算是watch的一个语法糖。

先来看看其类型定义:

function watchEffect(
  effect: (onCleanup: OnCleanup) => void,
  options?: WatchEffectOptions
): StopHandle

type OnCleanup = (cleanupFn: () => void) => void

interface WatchEffectOptions {
  flush?: 'pre' | 'post' | 'sync' // 默认:'pre'
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void
}

type StopHandle = () => void

所谓watchEffect,顾名思义“侦听副作用”。其接受一个函数作为参数,并且立即运行这个函数,同时响应式地追踪其依赖,此函数内部的响应式对象即其依赖,依赖更改则会让函数立即执行。

所谓副作用,可以理解为函数或表达式执行时,除了返回一个值以外,还对函数外部的状态产生了影响,比如修改了全局变量,发出了网络请求,更新了DOM等。

相对了watch来说,watchEffect更加灵活,其自动追踪依赖的数据既是优点也是缺点,笔者认为watch通过显式地指名观察的对象能让代码逻辑更加完整,并且可读性更高,也有利于团队开发协作,自动追踪依赖则需要团队成员和开发者更细致地了解整个侦听的逻辑和数据,这有时会让人感到疲惫。

每个人的观点都不一样,不必强求~

watchEffectoptions支持onTrackonTrigger,这两个特性主要用于调试和优化响应式数据,仅当你真的需要做这件事时才方便用到它们。

依赖注入

为了降低多层组件之间利用props传递数据的复杂度,官方提供了依赖注入机制。看看官方文档的这张图:

Provide/inject 模式

只需要在上层组件利用provide函数提供数据,即可在后代组件里使用inject函数来获取数据。

利用这个API,我们可以在两个方向上提供provide:

  1. 在应用层 Provide
  2. 在组件层 Provide

首先看看在应用层provide:

import { createApp } from 'vue'

const app = createApp({})

app.provide(/* 注入名 */ 'message', /* 值 */ 'hello!')

通常在写插件的时候比较常用,因为定义插件的时候使用app来提供数据比较方便。

其次,看看最为常用的在组件处provide:

<script setup>
import { provide } from 'vue'

provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
</script>

注入的名字可以是字符串或symbol,推荐使用一个全局文件来保存所有symbol,并且在provideinject的时候导入使用。

注入的值可以是响应式的对象,注入内部解构也不会自动解包为内部的值,使用响应式数据的时候,如果需要在子组件中修改数据值,官方推荐提供一个修改值的方法来修改数据。

inject的时候值可能是undefined,这种情况下如果使用TypeScript并且可以确保使用的时候一定存在数据则可以使用as声明值的类型,亦或是inject提供第二个参数作为默认值,默认值可以是一个值也可以是一个函数的返回值,甚至是一个函数。如果注入一个函数,则需要添加第三个参数来说明这是一个函数。

由于笔者日常使用TypeScript进行开发,因此在这里记录一下使用TypeScript标注类型的关键点。

首先,推荐使用一个文件来统一管理providekey

const myKey: InjectKey<string> = Symbol();
provide(myKey, 'Yo')
const data = inject(myKey) // TypeScript 能推导出 data 的数据类型为 string

使用InjectKey<T>来声明此符号对应注入的数据值,后续inject的时候即可自动推导出类型。

最后,我们再来看一个示例:使用依赖注入机制创建一个TypeScript完备的简单状态管理store:

import { provide, inject, reactive } from 'vue'

interface State {
  count: number
}

interface Actions {
  increment: () => void
  decrement: () => void
}

const createState = (): State => {
  return reactive({
    count: 0
  })
}

const createActions = (state: State): Actions => {
  const increment = () => {
    state.count++
  }

  const decrement = () => {
    state.count--
  }

  return {
    increment,
    decrement
  }
}

const stateSymbol = Symbol()
const actionsSymbol = Symbol()

export const provideStore = () => {
  const state = createState()
  const actions = createActions(state)

  provide<State>(stateSymbol, state)
  provide<Actions>(actionsSymbol, actions)
}

export const useStore = () => {
  const state = inject<State>(stateSymbol)
  const actions = inject<Actions>(actionsSymbol)

  if (!state || !actions) {
    throw new Error('Store is not provided')
  }

  return {
    state,
    actions
  }
}

从上面的示例中可以看到我们定义了StateActions接口分别定义状态和操作,然后定义了createStatecreateActions函数来创建状态和改变状态的操作,在使用时只需要在上层组件定义处调用provideStore函数即可完成初始化状态和改变状态的操作的数据注入。

我们再定义一个useStore函数来辅助获取注入的数据。

<template>
  <div>
    <p>Count: {{ state.count }}</p>
    <button @click="actions.increment">+</button>
    <button @click="actions.decrement">-</button>
  </div>
</template>

<script lang="ts" setup>
import { defineComponent } from 'vue'
import { useStore } from '@/store'
const { state, actions } = useStore()
</script>

从这个示例衍生出去,我们可以创建良好且TypeScript完备的依赖注入数据管理模式。

effectScope

effectScope()函数可以创建一个effect作用域,并且捕获其中所创建的响应式副作用(计算属性和侦听器),并且返回一个可以将副作用函数标记为stopped=truestop函数。

简而言之,我们可以使用如下代码:

const scope = effectScope()

scope.run(() => {
  const doubled = computed(() => counter.value * 2)

  watch(doubled, () => console.log(doubled.value))

  watchEffect(() => console.log('Count: ', doubled.value))
})

// 处理掉当前作用域内的所有 effect
scope.stop()

以上代码依然可以让计算属性和侦听器按预期运行,但是我们从此得到了一个可以控制副作用集体终止的控制器:scope.stop()函数!

调用此函数之后,此作用域内部的副作用函数都将被标记为stopped: true并且被跳过。

切记:scope.stop()仅仅是标记内部的副作用函数不必再执行,已经执行的副作用函数还是无法停止的。

修饰符

修饰符是为了方便开发者处理一些通用逻辑而提供的一种接口,我们可以在指令后面使用修饰符特殊后缀,即可增强或改变指令的功能。

vue3修饰符有以下几种:

  • 事件修饰符
  • 按键修饰符
  • 鼠标按键修饰符
  • 系统按键修饰符
  • 表单输入绑定修饰符
  • 自定义修饰符

了解每一种修饰符都会对我们开发过程中遇到的指令处理有帮助!

当我们开发的时候遇到上述相关功能的时候,推荐先思考自己是否能利用这些修饰符来优化或减少重复的逻辑代码。

首先,我们从事件修饰符开始,事件修饰符有:

<!-- 单击事件将停止传递 -->
<a @click.stop="doThis"></a>

<!-- 提交事件将不再重新加载页面 -->
<form @submit.prevent="onSubmit"></form>

<!-- 修饰语可以使用链式书写 -->
<a @click.stop.prevent="doThat"></a>

<!-- 也可以只有修饰符 -->
<form @submit.prevent></form>

<!-- 仅当 event.target 是元素本身时才会触发事件处理器 -->
<!-- 例如:事件处理器不来自子元素 -->
<div @click.self="doThat">...</div>

<!-- 添加事件监听器时,使用 `capture` 捕获模式 -->
<!-- 例如:指向内部元素的事件,在被内部元素处理前,先被外部处理 -->
<div @click.capture="doThis">...</div>

<!-- 点击事件最多被触发一次 -->
<a @click.once="doThis"></a>

<!-- 滚动事件的默认行为 (scrolling) 将立即发生而非等待 `onScroll` 完成 -->
<!-- 以防其中包含 `event.preventDefault()` -->
<div @scroll.passive="onScroll">...</div>

链式书写事件修饰符需要注意顺序,因为相关代码编译后的事件是顺序生成的,例如@click.prevent.self@click.self.prevent的点击事件阻止范围不同。

请勿同时使用 .passive.prevent,因为 .passive 已经向浏览器表明了你不想阻止事件的默认行为。如果你这么做了,则 .prevent 会被忽略,并且浏览器会抛出警告。

其次,按键修饰符示例:

<template>
  <div>
    <!-- 仅在 `key` 为 `Enter` 时调用 `submit` -->
    <input @keyup.enter="submit" />

    <!-- 使用中划线的形式,支持所有按键事件暴露的按键民,不用自己处理案件判断,非常方便 -->
    <input @keyup.page-down="onPageDown" />
    <input @keydown.tab="nextInput" />
    <input @keydown.delete="deleteCharacter" />
    <input @keydown.backspace="deleteCharacter" />
    <input @keydown.esc="cancelForm" />
    <input @keydown.space="toggleCheckbox" />
    <input @keydown.up="moveUp" />
    <input @keydown.down="moveDown" />
    <input @keydown.left="moveLeft" />
    <input @keydown.right="moveRight" />
    <!-- 支持组合键 -->
    <input @keydown.ctrl.enter="submitForm" />
    <input @keydown.alt.tab="nextInput" />
    <input @keydown.shift.delete="deleteCharacter" />
    <input @keydown.meta.esc="cancelForm" />
    
    <!-- 按键限制: 当按下 Ctrl 时,即使同时按下 Alt 或 Shift 也会触发 -->
    <button @click.ctrl="onClick">A</button>

    <!-- 仅当按下 Ctrl 且未按任何其他键时才会触发 -->
    <button @click.ctrl.exact="onCtrlClick">A</button>

    <!-- 仅当没有按下任何系统按键时触发 -->
    <button @click.exact="onClick">A</button>
    
    <!-- 鼠标点击事件 -->
    <button @click.left="handleLeftClick">左键点击</button>
    <button @click.right="handleRightClick">右键点击</button>
    <button @click.middle="handleMiddleClick">中键点击</button>
  </div>
</template>

接着,看看表单输入绑定修饰符:

<!-- 在 "change" 事件后同步更新而不是 "input" -->
<input v-model.lazy="msg" />
<!-- 自动用parseFloat转为数字,失败则返回原始值 -->
<input v-model.number="age" />
<!-- 去除值左右两边空格 -->
<input v-model.trim="msg" />

directives - 指令

指令本质上是一个对象,注册到app实例后在实例上全局可用,注册到组件内时,则组件实例可用。

官方文档如是说:

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) {}
}

自定义的指令是一个对象,其内部规定以上 key表示的是指令调用的时间段,每个都是可选的周期函数。

来举例说学习一些用法,首先一个例子:自动将v-model绑定的输入字符串首字母转为大写。

<template>
  <div>
    <input v-model.capitalize="message" />
    <p>Message: {{ message }}</p>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';

// 定义capitalize修饰符
const capitalize = (value: string): string => {
  if (!value) return '';
  return value.charAt(0).toUpperCase() + value.slice(1);
};

export default defineComponent({
  name: 'MyComponent',
  data() {
    return {
      message: '',
    };
  },
  directives: {
    capitalize: {
      beforeUpdate(el, binding) {
        // 将输入值转为大写字母
        const capitalizedValue = capitalize(binding.value);
        // 更新输入框的值
        el.value = capitalizedValue;
        // 更新绑定的值
        binding.value = capitalizedValue;
      },
    },
  },
});
</script>

如上所述,利用beforeUpdate函数即可在更新之前操作DOM的值和绑定的值。

再来一个示例:在元素上添加点击外部区域的事件!

<template>
  <div>
    <button v-click-outside="handleClickOutside">Click Me</button>
  </div>
</template>

<script>
// 自定义指令:点击元素外部区域触发事件
const clickOutside = {
  // 指令绑定到元素时调用
  mounted(el, binding) {
    // 点击元素外部区域触发事件
    const handleClickOutside = (event) => {
      if (!el.contains(event.target)) {
        binding.value();
      }
    };
    document.addEventListener('click', handleClickOutside);
    // 在元素销毁时移除事件监听
    el._clickOutsideHandler = handleClickOutside;
  },
  // 指令从元素上解绑时调用
  unmounted(el) {
    document.removeEventListener('click', el._clickOutsideHandler);
    delete el._clickOutsideHandler;
  },
};

export default {
  name: 'MyComponent',
  directives: {
    clickOutside,
  },
  methods: {
    handleClickOutside() {
      console.log('Clicked Outside');
    },
  },
};
</script>

el.contains 是一个DOM API中的方法,用于检查一个元素是否包含另一个元素,返回一个布尔值。

上述监听函数会检查点击的对象是否是绑定对象的子元素,不是则视为点击了外部区域。

再来一个图片懒加载的全局自定义指令例子:

const lazyloadDirective = {
  created(el) {
    el.classList.add('img-loading')
  },
  mounted(el) {
    const img = new Image();
    img.src = el.dataset.src;
    img.onload = function () {
      el.src = el.dataset.src;
      el.classList.remove('img-loading');
    };
  },
};

const app = Vue.createApp({
  // ...
});

app.directive('lazyload', lazyloadDirective);

在图片DOM 创建的时候添加一个class:img-loading

.img-loading {
  position: relative;
}

.img-loading::before {
  content: "";
  display: block;
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: #f5f5f5; /* 占位符背景色 */
  border-radius: 4px; /* 占位符圆角 */
}

如此一来,在浏览器请求图片数据完成之前都会展示一个简单的骨架样式。

nextTick()

nextTick(callback)是一个全局方法,其可以解决vue中改变响应式状态时DOM不会立即同步更新的问题,调用这个异步函数即可等待DOM更新,你可以传入回调函数,也可以使用await语法糖来等待DOM更新再添加更多逻辑。

not-by-ainot-by-ai
文章推荐
avatar
Sass 浅解
2024-08-08 updated.

Friends

Jimmy老胡SubmaraBruce SongScarsu