MeetinaXD
@admin

Vue 3学习笔记

本笔记与github项目同步。

Last edit on Jul 14, 2021.

By MeetinaXD.

Visit my blog: MeetinaXD' blog

难免有遗漏错误之处,如有疑惑请及时查阅官方文档。


项目起步

需要的环境:

  • @vue/cli
  • @vite
  • node
  • npm / yarn

开始之前

需要自行安装node环境(这个过程会安装好npm)

安装开发工具

vite
npm init vite

环境安装

sass
npm i node-sass sass-loader style-loader --save-dev

初始化项目中可能会遇到的问题

出错类似于这样

node_modules\esbuild\esbuild.exe ENOENT

events.js:292
      throw er; // Unhandled 'error' event
      ^

Error: spawn .\node_modules\esbuild\esbuild.exe ENOENT
    at Process.ChildProcess._handle.onexit (internal/child_process.js:269:19)
    at onErrorNT (internal/child_process.js:465:16)
    at processTicksAndRejections (internal/process/task_queues.js:80:21)
Emitted 'error' event on ChildProcess instance at:
    at Process.ChildProcess._handle.onexit (internal/child_process.js:275:12)
    at onErrorNT (internal/child_process.js:465:16)
    at processTicksAndRejections (internal/process/task_queues.js:80:21) {
  errno: -4058,
  code: 'ENOENT',
  syscall: 'spawn .\node_modules\\esbuild\\esbuild.exe',
  path: '.\node_modules\\esbuild\\esbuild.exe',
  spawnargs: [ '--service=0.12.15', '--ping' ]
}

可能是某个写脚本的开发人员疏忽了,在Windows上会出现。
使用node .\node_modules\esbuild\install.js来解决。

vue3的新增内容

  • 组合式API composition-api
  • 异步组件 Suspend
  • 瞬移标签 Teleport
  • 组件的触发事件选项emits
  • 组件支持多根节点(片段)
  • css变量支持绑定组件数据(style内的v-bind()
  • 语法糖script setup标签

详情见:值得注意的新特性 - vue官网迁移指南

vue2和vue3的差异

  • ⚠️ filters选项被废除
  • ⚠️ 事件的.native修饰符被废除
  • 全局函数set/$setdelete/$delete被废除
  • ⚠️ 父组件不再支持$on()方法接收事件,请使用事件传入
  • 新的v-model,不再需要v-bind.sync
  • ⚠️ 组件的emit事件都需要在emits事件中声明(如同props)
  • $attrs变为setup的第二个参数内的属性,且包含classstyle(style会是一个对象)
  • mixin已变成浅合并(只合并根级属性且以data为先)
  • data必须是一个函数
  • props选项内不能再访问上下文
  • 内置的一些transition效果名称被更改
  • ⚠️ 不再支持基于vue实例中$emit实现的eventBus,请使用mitt

详情见:非兼容的变更 - vue官网迁移指南

代码起步

文档示例使用Typescript开发语言进行开发。

♻️ 生命周期

在vue3的composition-api中,可以从vue引入并使用以下生命周期钩子:

  • setup
  • onBeforeMounted
  • onMounted
  • onBeforeUpdate
  • onUpdate (更新之后触发)
  • onBeforeUnmounted
  • onUnmounted
  • onActivated (仅keep-alive标签内的组件可用)
  • onDeactivated (仅keep-alive标签内的组件可用)
  • onErrorCaptured (捕获当前组件内产生的所有错误)

onErrorCaptured(callback(e: Error): Boolean)

⚠️ 在当前页面发生错误时,钩子执行回调函数并传入错误。
回调函数需要返回一个Boolean值,当值为true时,代表向上传递错误;当值为false时,不向上传递错误。

需要注意的是,在option中定义生命周期的用法仍然适用

详细用法见: Lifecycle Hooks

⌛ Suspend异步加载组件

使用异步组件时,显示加载态和加载完毕态

父组件

模版

<Suspense>
  <!-- 加载完毕后显示的东西 -->
  <template #default>
    <Async />
  </template>

  <!-- 正在加载显示的东西 -->
  <template #fallback>
    <h1>Loading...</h1>
  </template>
</Suspense>

代码

import Async form '@/component/AsyncLoad.vue'

export default {
  components :{
    Async
  },
  setup(){
    return { }
  }
}

子组件

模版

<template>
  <span>{{result}}</span>
</template>

代码

import { defineComponent } form 'vue'
export default defineComponent({
  setup: () => new Promise(async (resolve, reject) => {
    const data = await (axios.get('xxxx')).data

    // resolve其实就是setup的返回
    resolve({ result: data })
  })
})

当然也可以这样:

import { defineComponent } form 'vue'
export default defineComponent({
  setup: async () => {
    const data = await (axios.get('xxxx')).data

    // resolve其实就是setup的返回
    resolve({ result: data })
  })
})
以上示例将会在axios的get请求成功后,页面显示的Loading...变为请求返回的内容。

⚠️ setup函数与Volar

在vue3的composition-api中,setup是一个先于组件渲染执行的函数。
换言之,setup先于onBeforeMounted执行。
一般情况下,setup函数形式如下:

<script lang="ts">
import { ref } from 'vue'
export default {
  name: 'something',
  setup(){
    const counter = ref(0)
    const addCounter = ():void => {
      counter.value++
    }
    return { counter, addCounter }
  }
}
</script>

上面的例子在setup函数中暴露了一个名为counter的响应式变量,以及一个使其数值加一的方法。
与vue2时代中常用的option-api一样,counter以及addCounter都能够在模版中直接使用。

Volar

Volar是vue3的一个语法糖,可以在html标签内直接定义setup函数。
要使用volar,需要在script标签内添加setup属性

<script lang="ts" setup>
import { ref } from 'vue'
const counter = ref(0)
const addCounter = ():void => {
  counter.value++
}
</script>

这个示例的效果与上面的示例一样。

不同之处

volar将暴露所有在标签内的对象,setup可根据需要自行控制。

⚠️ 需要注意的地方

如果需要在volar中定义props和emits,则需要引入并使用definePropsdefineEmits,其用法和以前在options-api中的一样

<script lang="ts" setup>
import { defineEmits, defineProps } from 'vue'
  defineEmits(['submit', 'update'])
  defineProps({
    name: {
      type: String,
      default: 'unknown'
    }
  })
}
</script>

⚠️ watchwatchEffect

需要自行引入

watch与watchEffectwatch类似,但不需要显式指定监测的变量,而且不需要等到数据变化后执行。在第一次数据赋值时,watchEffect也会被执行。

下面使用一个例子来分别说明watch以及watchEffect的区别。
假如我们需要根据一个id值来从api获取用户的数据,下面实现这个接口:

// @/api/user.ts
interface UserInfo {
  id: number
  name: string
  imageURI: string
}

const userlist: { [id: number]: UserInfo } = {
  1: { id: 1, name: 'Tenma', imageURI: 'img.com/img1' },
  2: { id: 2, name: 'Jeans', imageURI: 'img.com/img2' },
  3: { id: 3, name: 'Tom', imageURI: 'img.com/img3' },
}

const getUserInfo = (
  id: number
): Promise<UserInfo> => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(userlist[id])
    }, 1000)
  })
}

export default getUserInfo;

现在,接口已经实现, 下面来实现自动根据id的变化获取用户数据并输出。

在watch中实现

import { ref, watch } from 'vue'
import getUserInfo from '@/apis/user'
export default {
  setup(){
    const id = ref(1)
    const getInfo = async (id:number) => {
      const userinfo: UserInfo = await getUserInfo(nowId)
      console.log(userinfo)
    }
    // 初始化时获取用户数据
    getInfo(id)
    watch(id, nowId => getInfo(nowId))
    // 暴露方法
    return { id, getInfo }
  }
}

下面来看看使用watchEffect如何实现同样的效果

在watchEffect中实现

import { ref, watchEffect } from 'vue'
import getUserInfo from '@/apis/user'
export default {
  setup(){
    const id = ref(0)
    const getInfo = async (id:number) => {
      const userinfo: UserInfo = await getUserInfo(nowId)
      console.log(userinfo)
    }
    // 并不需要指定检测变量及手动在初始化时获取数据
    watchEffect(() => getInfo(id))
    // 暴露方法
    return { id, getInfo }
  }
}

现在,以上两个应该都工作正常,并能在初始加载及id变动时自动加载用户数据并输出。

⚠️ 进一步的问题

考虑实际情况,api的获取并不是在相同的时间内完成的,视乎网络环境而定。同时,id的变化也许会在api的获取中产生
让我们来改写api来贴合实际情况:

// time代表api获取所需要的时间
// 返回值为一个数组,第一个是包含用户信息的promise对象,第二个是cancel(reject)方法
const getUserInfo = (
  id: number,
  time: number = 1000
): [Promise<UserInfo>, () => void] => {
  let _cancel: (reason: any) => void
  const _promise = new Promise((resolve, reject) => {
    _cancel = reject
    setTimeout(() => {
      resolve(userlist[id])
    }, time)
  })

  // _cancel实际上是reject
  return [_promise, () => _cancel("abort")]
}

下面让我们尝试在watch中模拟这一情况

🎯 watch
// ...
const id = ref(0)
// 这个函数在获取id更大的用户信息时,更加缓慢
const getInfo = async (id:number) => {
  const [promise, cancel] = getUserInfo(nowId, nowId * 1000)
  const userinfo = await promise
  console.log(userinfo)
}
watch(id, nowId => getInfo(nowId))
// 模拟id被快速地切换了
id.value = 2
id.value = 1

以上例子模拟了用户快速切换id,而且由于id较大的用户所需的调用时间较长,较先变化的id的用户信息晚于较后变化的id的用户信息先得到。

因此,输出将是这样的:

{ id: 1, name: 'Tom', imageURI: 'img.com/img3' }
{ id: 2, name: 'Jeans', imageURI: 'img.com/img2' }

额外地,id的变化通常认为只有最后一个是有意义的,由此实际上我们只需要id为1的用户信息。

为了解决这些问题,我们可以使用watchEffect中传入的注册失效回调函数(onInvalidate)

🎯 watchEffect
// ...
const id = ref(0)
// 这个函数在获取id更大的用户信息时,更加缓慢
const getInfo = async (id: number, onInvalidate: Function) => {
  onInvalidate(() => {
    cancel && cancel()
  })
  const [promise, cancel] = getUserInfo(nowId, nowId * 1000)
  const userinfo = await promise
  console.log(userinfo)
}
watchEffect(onInvalidate => {
  getInfo(id, onInvalidate)
})
// 模拟id被快速地切换了
id.value = 2
id.value = 1

在id快速地由2切换为1时,onInvalidate将被触发,并在getInfo内执行Promise请求的reject方法,请求将会终止。输出将是这样的:

{ id: 1, name: 'Tom', imageURI: 'img.com/img3' }

🔖 额外部分

监控深处的属性

如果监听的数据在一个对象的深处,在vue2我们可以使用字符串的形式(如data.user.name.firstname)指明被watch的属性,但现在它已经被删除了。

可以通过一个函数返回值或是计算属性的方法来代替。

watch(() => data.user.name.firstname, newVal => {
  /* do something */
})

or

const firstname = computed(() => data.user.name.firstname)
watch(firstname, newVal => {
  /* do something */
})

如果你愿意,甚至可以为computed添加gettersetter:

const firstname = computed(() => {
  get: () => data.user.name.firstname
  set: val => data.user.name.firstname = val
})
watch监控值的变化

watch监控的值只能是以下几个值类型之一:getter/setter函数、ref对象、reactive对象或是这些值类型组成的数组。

A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.

其他关于watchwatchEffect的更改

对数组使用watch:Watch on Arrays

emits属性

在vue2的option-api中的props对象用于对传入的属性进行类型和默认值的约束。而vue3中的emits则是对调用emit()时传出数据的约束。

emits: {
  onUpdate(id: number, name: string){
    if (id < 0 || name.length === 0){
      console.error("id must over 0 or name cannot be empty")
      return false
    }
    return true
  }
},
setup(props, content){
  // ...
  const { emit } = content
  emit("onUpdate", 0, "Tom")
  emit("onUpdate", -1, "Tom") // 报错
}
vue3中emit已经不再使用this.$emit,而是在setup的第二个参数content中获取

全局方法挂载

在vue2中,可以通过以下方式挂载一个方法:

import axios from '@/plugins/axios'
Vue.prototype.$axios = axios

在vue3中,要实现类似的挂载需要使用一个新的属性globalProperties

import axios from '@/plugins/axios'
import { createApp } from 'vue';
const app = createApp({ /* ... */ });
app.config.globalProperties.$axios = axios

然后在setup内部使用$axios(当前App示例)

setup() {
  const {
    appContext: {
      config: {
        globalProperties: { $axios }
      }
    }
  } = getCurrentInstance()
}

propsattrs属性

假设我们编写了一个组件,并在外部使用它。

<my-component
  id="component"
  style="color: red"
  class="my-style"
  name="Tom"
  gender="man"
  :age="12"
  @myevent="e => e + 1"
/>

像这样,通常情况下我们会认为nameage是待传入的属性,那么我们应该在props选项中定义它。

🎯 props

{
  props: {
    name: {
      type: string,
      default: ""
    },
    age: {
      type: number,
      default: 0
    }
  }
}

注意到我们还传入了classstyle,以及额外的gender属性吗?
它在渲染后看上去是这样的:

<div id="component" class="my-style" style="color: red" gender="man">
  <!--  -->
</div>

这是因为在默认情况下props属性中已定义的属性不会在渲染后的节点中显示。而且,未声明的属性不能在props中使用。

props.gender //这是非法的

🎯 attrs

要使用未声明的属性,可以使用attrs
实际上,任何传入的props声明的属性都会出现在attrs中。

⚠️ 甚至,attrs中还包含传入的事件。

注意到我们在上面的组件中还传入了@myevent,让我们看看attrs中有什么东西。

{
  //
  setup(props, { attrs }){
    console.log(attrs)
  }
}

以上代码将输出

Proxy {id: "component", style: {color: "red"}, class: "mystyle", gender: "man", onMyevent: ƒ}
vue2行为:attrs的用法是this.$attrs

🔖 差异总结

  • props需要先声明才能使用,而attrs不需要
  • props不包含事件,而attrs包含 (⚠️ 但在props定义的事件依然会出现在props中而不是attrs中)
  • props允许传入非string类型的值,而attrs只能传入string(但style传入一个object)

🔖 其他需要注意的特性

  • props中定义的所有值,attrs中都不会出现(⚠️ 包括classstyleid)
  • attrs可以在传入的content参数中解构获取

inheritAttrs属性(禁用 Attribute 继承)

⚠️ 警告
设置该属性为false时,请确保样式仍然符合预期

该内容可参考:禁用 Attribute 继承

如果你不希望组件的根元素继承特性,你可以在组件的选项中设置 inheritAttrs: false(默认为true)
如果设置为false,这个时候就可以自由地决定attrs应该添加到哪个元素中。

<template>
  <div>
    <!-- 非根元素 -->
    <div v-bind="$attrs">
    </div>
  </div>
</template>

上述代码会被编译为

<!-- 现在属性不再被添加到根元素 -->
<div>
  <div class="my-style" style="color: red" gender="man">
    <!--  -->
  </div>
</div>

在模板中使用attrs的方法是$attrs

vue2行为:classstyle 不属于attrs,仍然会应用到组件的根元素

⚠️ slot插槽

这并不是vue3的新内容

关于slot插槽详细的介绍见:组件插槽 - vuejs.org

🎯 具名插槽

当需要使用多个插槽的时候,可以在slot内提供一个名为name的属性
如对于一个名为foo的组件:

<div>
  <header>
    <slot name="header" />
  </header>

  <main>
    <slot />
  </main>

  <footer>
    <slot name="footer" />
  </footer>
</div>

那么在父组件中使用时:

<div>
  <template v-slot:header>
    <h1>Here might be a page title</h1>
  </template>

  <p>A paragraph for the main content.</p>
  <p>And another one.</p>

  <template v-slot:footer>
    <p>Here's some contact info</p>
  </template>
</div>

同时,类似于v-bindv-onv-slot也有自己的缩写: #
于是,上述代码可以改写为:

<div>
  <template #header>
    <h1>Here might be a page title</h1>
  </template>

  <p>A paragraph for the main content.</p>
  <p>And another one.</p>

  <template #footer>
    <p>Here's some contact info</p>
  </template>
</div>

🎯 作用域插槽

考虑下面的情况,一个名为user-info的组件具有以下的内容:

<template>
  <div>
    <slot>{{ user.lastName }}</slot>
  </div>
</template>

它也许会拥有这样的数据:

{
  setup(){
    const data = reactive({
      user: {
        firstName: "Swift",
        lastName: "Taylor"
      }
    })

    return { ...toRefs(user) }
  }
}

在父组件使用的时候,我们可能需要用firstName替换掉默认的内容。但在使用时,我们并不能访问到子组件user-info中的数据。
因此,我们需要用到作用域插槽,来让父组件能够访问到组件内的数据。
让我们来分别改写父子组件。

子组件:

<template>
  <div>
    <!-- 这里的data指数据将在父组件中暴露的名称 -->
    <slot v-bind:data="user">{{ user.lastName }}</slot>
    <!-- 或者可以这么用 -->
    <slot :data="user">{{ user.lastName }}</slot>
  </div>
</template>

父组件:

<user-info>
  <!-- 从默认插槽中获得数据,并挂载到名为userProp的变量内 -->
  <template v-slot:default="userProp">
    {{ userProp.data.firstName }}
  </template>
  <!-- 使用缩写和解构语法 -->
  <template #="{ data }">
    {{ data.firstName }}
  </template>
</user-info>

如果进一步考虑独占默认插槽的缩写语法,父组件还可以写成:

<user-info #="{ data }">
    {{ data.firstName }}
</user-info>

这是一个很棒的特性,可以减少不必要的嵌套,详见独占默认插槽

上述实例将渲染出:

<div>
  Swift
</div>
🔖 更详细的解释

在子组件向父组件提供数据时,实际上的用法就如同向元素提供props一般。使用v-bind:暴露出去提供给父组件的数据名="子组件作用域内的数据名"类似的语法。
v-bind:mydata="user"或是:mydata="user"

而在父组件使用这些数据时,需要在template或者组件标签内(仅当只有一个插槽的时候可以这么做)使用v-slot:插槽的名称="接收数据到哪里(一个变量的名称)"或是#插槽的名称="接收数据到哪里"类似的语法。
v-slot:default="nameProp"或是#="nameProp"

⚠️ 从子组件内引入的数据将被包装在一个对象内放入到父组件所指定的变量名内。

如上述例子,子组件向父组件提供了mydata(实际上是user的数据),同时父组件将其放入到了nameProp内。

mydata会拥有的数据:

{
  firstName: "Swift",
  lastName: "Taylor"
}

nameProp会拥有的数据:

{
  mydata: {
    firstName: "Swift",
    lastName: "Taylor"
  }
}

这也是为什么可以使用解构语法的原因。

🎯 独占默认插槽

假如组件仅有一个未命名的默认插槽,那么引用组件数据时,可以将v-slot指令直接放在组件标签内,就像这样:

<user-info v-slot:default="{ data }">
<!-- 或者是 -->
<user-info #="{ data }">

⚠️ 只要出现多个插槽,请始终为所有的插槽使用完整的基于 <template>的语法

组件的多根节点(片段)

vue3中支持在组件template内放置多个根节点

<!-- Layout.vue -->
<template>
  <header>...</header>
  <main v-bind="$attrs">...</main>
  <footer>...</footer>
</template>

你也许已经留意到了,main标签被绑定了$attrs

⚠️ 在使用多个根节点时,组件不具有自动 attribute 回退行为。请始终显式地定义attribute应该绑定到哪个根节点内,如果未显式绑定 $attrs,将发出运行时警告。

✨ 项目配置

@绝对目录的配置

@绝对目录在vue2似乎是默认已经配置好的,而在vue3中并不能直接使用,需要自行进行一些配置。

🎯 在tsconfig.json中配置

// 未定义baseUrl时不允许使用paths
"baseUrl": "./",
"paths": {
  "@/*": ["src/*"]
}

⭐ 关于tsconfig.json的设置,还有更多的玩法。如为特定模块或组件设置别名。
见:路径设置 - typescript

🎯 在vite.config.ts中配置

// 如果报错:找不到模块 “path” 或其相对应的类型声明,则需要安装一下类型提示
// npm install @types/node --save-dev
import path from "path"
export default defineConfig({
  /* ... */
  base: "./",
  resolve: {
    alias: {
      // map '@' to './src'
      "@": path.resolve(__dirname, "./src")
    }
  }
})

这里参考了:如何进行vue的@别名设置

🎯 配合插件使用

使用编辑器vscode

虽然上面完成了对@绝对目录的定义,但在类似于vscode的编辑器中编写时,输入'@'并不会出现目录的语法提示。

为了产生语法提示,可以安装插件Path Intellisense

  • 在设置中找到扩展 - Path Intellisense
  • 找到Mappings自定义项,点击在settings.json中编辑
  • 加入以下配置
"path-intellisense.mappings": {
  "@": "${workspaceRoot}/src"
}

proxy本地代理

本地代理需要在vite.config.ts中设置。
相关配置字段在server.proxy中,使用了http-proxy模块。

{
  // 字符串简写写法
  '/foo': 'http://localhost:4567/foo',
  // 选项写法
  '/api': {
    target: 'http://jsonplaceholder.typicode.com',
    changeOrigin: true,
    rewrite: (path) => path.replace(/^\/api/, '')
  },
  // 正则表达式写法
  '^/fallback/.*': {
    target: 'http://jsonplaceholder.typicode.com',
    changeOrigin: true,
    rewrite: (path) => path.replace(/^\/fallback/, '')
  },
  // 使用 proxy 实例
  '/api': {
    target: 'http://jsonplaceholder.typicode.com',
    changeOrigin: true,
    configure: (proxy, options) => {
      // proxy 是 'http-proxy' 的实例
    },
  }
}
⚠️ https代理
如果你需要代理到https服务器,需要提供目标地址的证书才能继续。
详见:Using Https

如果对正则表达式的使用不太明白,可以参考:nginx正则表达式
配置来源:Server Proxy - Vite 官方文档

✨ TypeScript笔记

交叉类型和联合类型

语法

  • 交叉类型: Type1 & Type2
  • 联合类型: Type1 | Type2

交叉类型

交叉类型取所有类型的并集,拥有所有类型的所有属性,下面使用一个官方的例子解释。

function extend<T, U>(first: T, second: U): T & U {
  // result 是要返回结果,类型断言为 T & U
  let result = {} as T & U
  for(let id in first){
    // 不能将类型“T”分配给类型“T & U”,所以需使用断言
    result[id] = first[id] as any
  }
  for(let id in second){
    if(!result.hasOwnProperty(id)){
      // 不能将类型“U”分配给类型“T & U”,同样需要断言
      result[id] = second[id] as any
    }
  }

  // 返回结果,类型是 T & U
  return result
}

class Person{
  constructor(public name: string) {}
}

interface Loggable {
  log(): void
}

class ConsoleLogger implements Loggable {
  log(){
    console.log("do log")
  }
}

// 使用 extend 方法合并两个类的实例,返回的是交叉类型,所以可以访问 name 和 log()
let tom = extend(new Person('Tom'), new ConsoleLogger())
let n = tom.name //Tom
tom.log() // 输出do log

例子中的结果可以看到,交叉类型取的是并集,拥有两个类型成员的所有属性。

联合类型

交叉类型取所有类型的交集,只能选择多个属性中的其中一个,下面还是使用一个官方的例子解释。

function PadLeft(value: string, padding: any){
    // 如果是 number 类型,则在 value 前填充对用空格
    if(typeof padding === 'number'){
        return Array(padding + 1).join(' ') + value
    }
    // 如果是 string 类型,则直接拼接 value 和 padding
    if(typeof padding === 'string'){
        return padding + value
    }

    // 如果不是 string 和 number,抛出错误
    throw new Error(`Expected string or number, got '${padding}'.`)
}

PadLeft("hello world", '   ') // '   hello world'
PadLeft("hello world", 4)     // '    hello world'

看上去一切都好,但如果传入了一个既不是string也不是number类型的数据时。但错误并不是在编写时抛出的,而是运行时报错的。

padLeft("Hello world", true); // 编译阶段通过,运行时报错

要在编译阶段发现问题,这时可以使用联合类型来替代 any类型。

function PadLeft(value: string, padding: string | number){
    // ...
}

PadLeft("hello world", true) // 编辑器报错,true不能赋给类型string | number的参数。

⚠️ 需要注意: 联合类型只能访问所有类型中共有的属性。

function getLength(value: string | number) :number{
  return value.length // 属性'length'不存在于'number'类型
}

索引类型

索引类型规定了类型中未定义的成员的属性名类型属性类型
举个例子:

interface Person {
  name: String;
  age: Number;
  phone: String;
}

const p: Person = {
  name: '9527',
  age: 18,
  phone: '18000000000'
}

如果这个时候,我们需要添加成员到Person中

p.info = 'xxxxx' //Property 'info' does not exist on type 'People'.

由于Person中没有info成员,因此不能通过编译。
此时可以使用索引签名(Index Signature)

interface Person {
  name: string;
  age: number;
  status: boolean; // <-- error! boolean not assignable to string | number
  [key: string]: string | number;
}

此时,你应该可以获得一个可以自由添加成员的Person类型对象,但编译器仍会给出一个报错,原因在status: boolean
⚠️ 因为,索引签名定义了所有属性及其返回值类型,因此使用时请确保所有的值类型与索引签名相同。

外文回答
If you add an index signature, it must not conflict with any other properties.
The index signature [k: string]: string | number means "if you read a property from Person with any key of type string, you will get a value of type string | number." The name property is compatible, because the key "name" is a string, and the value type string is assignable to string | number. The age property is compatible, because the key "age" is a number, and the value type number is assignable to string | number. But status is in error. The key "status" is a boolean, but it violates the index signature; boolean is not assignable to string | number.
You cannot use index signatures like the above to say "well, the property at key "status" is a boolean but every other string-keyed property has a value of type string | number. It would be nice to have a way to say that, (see microsoft/TypeScript#17687 for a request for this) but index signatures don't work that way.

将字面量联合类型用作索引

索引签名可以通过使用映射类型,将字面量联合类型中的成员用作索引名。

An index signature can require that index strings be members of a union of literal strings by using Mapped Types
映射类型

使用keyof关键字,我们可以创建一个所谓的映射类型,它将原始类型的所有属性映射到一个新的类型。

In combination with keyof we can use it to create a so called mapped type, which re-maps all properties of the original type.
type Index = 'a' | 'b' | 'c'
type FromIndex = { [k in Index]?: number }

const good: FromIndex = {b:1, c:2}

// Error:
// Type '{ b: number; c: number; d: number; }' is not assignable to type 'FromIndex'.
// 对象字面量只能使用定义属性,'d'并不在范围内
const bad: FromIndex = {b:1, c:2, d:3};

甚至,你可以编写一个工具函数来实现这个功能。

type TypeFromIndex<K extends string, T> = { [key in K]?: T }

type Index = 'name' | 'gender' | 'phone'
const FromIndex: TypeFromIndex<Index, string> = {
  name: 'Tom',
  gender: 'female'
  // phone: '18000000000'
}

其他值得关注的问题

有一种十分罕见的用法,是同时声明stringnumber索引签名。

interface ArrStr {
  [key: string]: string | number; // Must accommodate all members
  [index: number]: string; // Can be a subset of string indexer
  // Just an example member
  length: number;
}

这里也提到了一点:索引签名对于string索引的限制比number索引的限制严格得多,这是由于js对象并没有真正意义上的number索引导致的。如:obj[123]等价于obj['123'].
实际上,对象内的key只有stringsymbol两种类型。
看一个Javascript的例子:

let obj = { message:'Hello' }
let foo = {};

foo[obj] = 'World';

// Here is where you actually stored it!
console.log(foo["[object Object]"]); // World

v8引擎中,对象的key在传入时会调用默认的toString方法,因此也解释了在对象内number就是string的原因。

✨ 这里给出了很好的解释:Multiple indexer in Indexable types in TypeScript

keyof, 索引类型查询操作符

⚠️ 需要明确一点,keyof不考虑值类型。

keyof操作符具有两个作用:映射对象类型为它的成员名称所组成的联合类型映射具有索引签名的类型为它的索引签名的类型
这听起来很拗口,需要先了解对象类型索引类型

对象类型使用
type Person = {
  name: string;
  age: number;
};
type P = keyof Person;

P将得到"name" | "age"。这是由Person这个对象类型所具有的成员名称所组成的联合类型。
也就是会得到由所有key组成的联合类型。

外文回答: For any type T, keyof T is the union of known, public property names of T.
索引类型使用
type A = {
  name: string;
  [p: number]: unknown;
};
type P = keyof A

P将得到"name" | number
在这里,key由'name'以及[p: number]组成,换言之,由'name'以及number组成。(注意'name'在这里是有引号的,是一个字符串)
但,还有一个例子:

type A = {
  name: string;
  [p: string]: unknown;
};
type P = keyof A

P将得到string | number
在这里,key由'name'以及[p: string]组成,换言之,由'name'以及string组成。而'name'属于string类型,因此实际上只存在string

⚠️ 为什么是string | number
This is because JavaScript object keys are always coerced to a string, so obj[0] is always the same as obj["0"].
详见:Keyof Type Operator
⚠️ extends keyofin keyof的区别
  • extends keyof用于约束参数类型,见索引访问操作符
  • in keyof用于定义索引签名
    这是一个使用in keyof的例子。

    interface Person {
    age: number;
    name: string;
    }
    
    type Optional<T> = {
    [K in keyof T]?: T[K]
    };
    
    const person: Optional<Person> = {
    name: "Tobias"
    // 注意这里我没有写'age'属性,这是故意的。
    };

    keyof其他用法可以见映射类型
    该内容参考自:In TypeScript, what do “extends keyof” and “in keyof” mean?

T[K], 索引访问操作符

这是一种使用类型语法来反映表达式语法的方式。
这有些抽象,我们使用一个例子来说明。
例如编写一个获取对象成员的方法:

type Person = {
  name: string;
  age: number;
}

const p: Person = {
  name: '9527',
  age: 18
}

// 获取单个成员
function getProperty<T, K extends keyof T>(o: T, key: K): T[K] {
    return o[key]; // o[name] is of type T[K]
}

// 获取多个成员
function pick<T, K extends keyof T>(o: T, keys: K[]):T[K][]{
  return keys.map(key => o[key])
}

console.log(getProperty(p, 'name')) // 9527
console.log(pick(p, ['name', 'age'])) // 9527 18
console.log(pick(p, ['name', 'age', 'dog'])) // Error: Argument of type '"dog"' is not assignable to parameter of type 'keyof Person'.

其中,K extends keyof T使得K继承了T的所有成员,在这里是nameage
这里出现的T[K],是函数的返回类型。函数需要返回对象内的任一成员的值,因此T[K]涵盖了所有在对象中出现的值类型。实际上,它代表了p[key],也就是Person[key]
例如,p['key']具有类型Person['name']
因此,使用这种方式,不仅可以约束了返回类型只能是对象内出现的值类型。同时,也约束了成员的名称"K"必须出现在Person内。

infer类型推断

解释

作用是在类型表达式中,通过extends来声明一个不确定/待推断的变量类型。

通过ReturnType理解infer

参考Typescript内置的工具类型ReturnType,可以尝试写一些代码:

const add = (x:number, y:number) => x + y
type fn = typeof add
type t1 = ReturnType<typeof add> // type t = number
type t2 = ReturnType<fn> // type t = number
  • ReturnType<T> - 根据函数返回值获取类型

    /**
     * Obtain the return type of a function type
     */
    type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any

    infer 的作用是让 TypeScript 自己推断,并将推断的结果存储到一个类型变量中,infer 只能用于 extends 语句中。

再来看 ReturnType 的实现:如果 T 满足约束条件(...args: any) => any即等号左边)。 并且能够赋值给 (...args: any) => infer R,则返回类型为 R三目表达式),否则为 any类型。

继续看几个例子:

type T0 = ReturnType<() => string>        // string
type T1 = ReturnType<(s: string) => void> // void
type T2 = ReturnType<<T>() => T>          // unknown
代码解释:

分别可以得到 type T0 = stringtype T1 = voidtype T2 = unknown,只要满足约束条件 (...args: any) => any,TypeScript 推断出函数的返回值,并借助 infer 关键字将其储存在类型变量 R 中,那么最终得到返回类型 R

通过Parameters理解infer

这也是Typescript的内置工具类型

type T0 = Parameters<() => string>;  // []
type T1 = Parameters<(s: string) => void>;  // [string]
type T2 = Parameters<(<T>(arg: T) => T)>;  // [unknown]
  • Parameters<T>根据函数参数获取类型

    /**
     * Obtain the return type of a function arguments
     */
    type Parameters<T> = T extends (...args: infer R) => any ? R : any;
    代码解释:

    如果泛型参数T能够赋值给(...args: infer R) => any请忽略infer R),那么TypeScript 推断出函数的返回值,并借助 infer 关键字将其储存在类型变量 R 中,那么最终得到返回类型 R

使用infer: 实现元组转联合类型

借助 infer 可以实现元组转联合类型,如:[string, number] -> string | number

type Flatten<T> = T extends Array<infer U> ? U : never

type T0 = [string, number]
type T1 = Flatten<T0> // string | number
代码解释:

第 1 行,如果泛型参数 T 满足约束条件 Array,那么就返回这个类型变量 U。
第 3 行,元组类型在一定条件下,是可以赋值给数组类型,满足条件:

type TypeTuple = [string, number]
type TypeArray = Array<string | number>

type B0 = TypeTuple extends TypeArray ? true : false // true

第 4 行,就可以得到 type T1 = string | number
简而言之,上述的infer U实现了类型推断,并将其储存到变量U中,变为string | number,与上面的解释相似。

该内容参考自:TypeScript infer 关键字

⚠️ infer属于高级类型用法
其他用法解释及相关题目见:【typescript】infer的理解与使用

其他姿势 知识点

还没来得及归类,但是值得一看。

下午9:17 · 2021年11月25日
672
0
2
发表留言

笔记与分享
vue3 学习笔记
Vue 3学习笔记本笔记与github项目同步。Last edit on Jul 14, 2021.By MeetinaXD.Visit m...
扫描右侧二维码继续阅读
November 25, 2021
My Codes
blogger
meetinaxd
喜欢猪🐗
mylesson 作者
alovajs core dev
本质是一条野猪
你知道吗?

每吃一只卤🐽,就会有一条野猪失去它的鼻子
上方可以切换日夜模式

统计
文章:28 篇
分类:4 个
评论:5 条
运行时长:4年283天
by yoniu.
My Codes