• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

Element Plus 组件库相关技术揭秘12. v-model 的实现原理和Input 组件的核心实现

武飞扬头像
掘金
帮助242

前言

Input 组件功能最主要就是要实现对原生 input 表单 的状态进行监听,input 表单 通过用户输入操作状态发生了改变,需要更改我们程序中对应的状态变量,同样地我们程序中对应的状态变量发生了改变,也需要映射到 input 表单 上。这就是通过双向绑定操作使得我们的封装的 Input 组件变成一个受控组件。

我们通常会对一些表单组件进行 v-model 的封装,v-model 本质是一个语法糖,封装其实通过双向绑定操作把组件变成一个受控组件。

受控组件和非受控组件

非受控组件

非受控组件,顾名思义就是不受我们程序控制的组件。比如我们原生的 HTML input 表单

<input name="username" />

我们什么也不做,直接在上面输入。

我们可以看到用户的输入和显示是由原生 input 表单 自己实现,我们并没有进行操控,等于是 input 表单 自己维护着自己的状态。同样地我们封装一个组件,这个组件的状态变量不受外部的控制的,我们也可以说这是一个非受控组件。

那么我们要获取 input 标签中用户的输入怎么办呢?我们知道在 Vue 中是通过 v-model 来实现的,如果我们不使用 v-model 还能不能获取用户的输入呢?

<template>
    <input ref="input" :value="state" name="username" />
    <el-button type="primary" @click="onSubmit">提交</el-button>
    state 值:{{ state }}
</template>
<script setup>
import { ref } from 'vue'
// 定义 input 的 HMTL 引用
const input = ref()
// 编辑时的默认状态值
const state = ref('稀土掘金')
// 提交方法
const onSubmit = () => {
  console.log('提交的数据:')
  console.log('input表单值', input.value.value)
  console.log('state值', state.value)
}
</script>

我们通过上述代码不使用 v-model 指令也可以完成一个表单的基本功能,数据提交,编辑时的数据回显。但上述方案有一个问题,就是 input 表单 的状态并不受我们的程序控制。

我们可以看到 input 表单 的输入值已经更改了,但 state 的默认值并没有发生改变。我们想要 state 的值要随着 input 表单 的输入变化而变化,其实就是要把 input 表单 变成一个受控组件。

受控组件

熟悉 React 的开发者应该对 受控组件 的概念并不陌生。受控组件顾名思义就是受我们程序控制的组件。比如上述的例子中,input 表单 通过用户输入操作状态发生了改变,需要更改我们程序中对应的状态变量。其中一种方式我们可以通过监听 input 表单 的 change 事件,进行修改 defaultValue 的变量值。

<template>
    <input :value="state" name="username" @change="onChange" />
    <el-button type="primary" @click="onSubmit">提交</el-button>
    state 值:{{ state }}
</template>
<script setup>
import { ref } from 'vue'
// 编辑时的默认值
const state = ref('稀土掘金')
const onChange = (e) => {
  state.value = e.target.value
}
// 提交方法
const onSubmit = () => {
  console.log('提交的数据:')
  console.log('state值', state.value)
}
</script>

这个时候我们就可以看到 input 表单的输入输出都受到了我们程序的控制了。

上述实现受控组件的方式跟 React 中实现受控组件是一致的。

class Input extends React.Component {
  constructor (props) {
    super(props);
    this.state = {
      username: "稀土掘金"
    }
  }
  onChange (e) {
    this.setState({
      username: e.target.value
    })
  }
  render () {
    return <input name="username" value={this.state.username} onChange={(e) => this.onChange(e)} />
  }
}

通过监听 change 事件去更改对应的状态变量。具体就是 React 中是用一个 onChange 事件来监听输入内容的改变并调用 setState 方法更新对应的状态 this.state.username

本质都是实现对原生 input 表单 的状态进行监听,input 表单 通过用户输入操作状态发生了改变,需要更改我们程序中对应的状态变量,同样地我们程序中对应的状态变量发生了改变,也需要映射到 input 表单 上。

从受控组件的角度来讲,Input 组件是一个受控组件,我们对 Input 组件的实现,就是通过封装把 input 表单变成一个受控组件

什么是 MVVM

我们上述例子中,无论是 Vue 的例子,还是 React 的例子,其实是在手动进行双向数据绑定的操作。而双向绑定是 MVVM 的框架的特点之一,所以我们有必要了解一下什么是 MVVM 框架。

MVVM 即 Model-View-ViewModel 模式。在 Vue 中我们可以理解 data 对应的是 Model 层,View 对应的是 template,ViewModel 则对应的是 Vue 框架。所以在 Vue2 中我们通常使用 vm 代表 Vue 实例对象。其实 Vue 并不是纯粹意义上的 MVVM 框架,MVVM 的核心是数据驱动视图,那么从这个角度来说现代三大框架 Angular、Vue、React 都是 MVVM 框架。

在 MVVM 框架下,View 和 Model 之间并没有直接联系,而是通过 ViewModel(桥梁)进行交互。ViewModel 通过双向绑定将 View 和 Model 层连接起来,这样当 Model 层发生了改变,View 层也发生改变,同样当 View 层发生改变,Model 层也发生改变。

在上述介绍 MVVM 的过程中我们提到了 ViewModel 的作用就是通过双向绑定将 View 和 Model 层连接起来,那么接下来我们就了解一下什么是双向绑定吧。

什么是双向数据绑定

我们在上述实现 Vue 受控组件的例子中,我们是通过手动实现双向绑定的。我们首先把 state 状态变量绑定到 input 表单 上,再手动监听 input 表单 的 change 事件,当 input 表单 的内容发生改变的时候,就触发 change 事件,然后我们在对应的 onChange 函数中更新对应的 state 中的数据。

<template>
    <input :value="state" name="username" @change="onChange" />
    state 值:{{ state }}
</template>
<script setup>
import { ref } from 'vue'
// 编辑时的默认值
const state = ref('稀土掘金')
const onChange = (e) => {
  state.value = e.target.value
}
</script>

同样地我们在上述实现 React 受控组件的例子中,我们也是手动监听 input 表单的 change 事件,当 input 表单的内容发生改变的时候,就触发 change 事件,然后我们在对应的 onChange 函数中更新对应的 state 中的数据。

class Input extends React.Component {
  constructor (props) {
    super(props);
    this.state = {
      username: "稀土掘金"
    }
  }
  onChange (e) {
    this.setState({
      username: e.target.value
    })
  }
  render () {
    return <input name="username" value={this.state.username} onChange={(e) => this.onChange(e)} />
  }
}

Vue 和 React 的不同就是修改数据的方式,在 Vue 中可以直接修改数据,因为 Vue 是通过对数据的监听实现数据响应式,从而当数据发生变化的时候知道更新视图;而 React 是不能直接修改数据,而是通过 setState 方法进行修改,然后通过 setState 方法去启动重新渲染视图。

有人说 React 没有双向绑定,Vue 有双向绑定,其实这个说法是不准确的,从上述例子中我们可以看到 Vue 和 React 的双向绑定的实现过程是一致的。但 Vue 有 v-model 指令,简化了我们手动进行双向绑定的过程。

接下来让我们来了解以下 v-model 的原理。

v-model 的原理

我们从各大组件库中可以知道,文本输入框不单单只是一个 input 表单 ,还包含了丰富的功能方便我们开箱即用。其中非常重要的功能就是封装了 v-model,我们可以直接使用 v-model 对输入框组件进行双向绑定。例如 Element Plus 中的 Input 输入框,就可以非常方便地使用以下方式使用。

<template>
  <el-input v-model="input" placeholder="Please input" />
</template>

<script lang="ts" setup>
import { ref } from 'vue'
const input = ref('')
</script>

那么是如何做到使用 v-model 指令就实现了双向数据绑定了呢?

接下来为了了解其中的原理,我们先不使用 v-model 来实现双向数据绑定,而是手动实现。我们先回顾一下上面手动实现 Vue 的受控组件的例子。

<template>
    <input :value="state" @change="onChange" />
</template>
<script setup>
import { ref } from 'vue'
const state = ref('稀土掘金')
const onChange = (e) => {
  state.value = e.target.value
}
</script>

其实就是两个动作,给 input 表单传递一个 value 值,而 value 属性是 input 表单原生属性,当 input 表单 type 值为 text 时,value 属性值是输入框中显示的初始值。再给 input 表单绑定一个 change 事件,当触发 change 事件的时候可以从事件对象参数中获取输入框的值,再把获取到的值赋值给 state 变量,在 Vue 中我们一般声明 state 为响应式数据,所以当 state 的值发生改变后,就会重新渲染依赖它的页面元素。

那么我们现在要封装一个自己的 Input 输入框组件,我们希望也可以实现以下调用。

<my-input :value="state" @change="onChange" />

那么 value 就是一个 props 的参数,@change 就是就绑定一个监听函数。那么在 Vue3 的 script setup 中我们就可以进行相关的定义。

const props = defineProps({
  value: {
    type: String,
    default: '',
  },
})

const emit = defineEmits(['change'])

不管是 React 还是 Vue 都是单向数据流,也就是自顶而下的,从父组件到子组件单向流动。因为单向数据流向保证了高效、可预测的变化检测。值得注意的是 Vue 只针对 props 的第一层做了只读监控,如果是引用类型,仍然可以对引用类型内部的数据进行修改,但这是不被推荐的数据处理方式。当然一些父子组件非常紧密耦合的场景下,可以允许修改 props 内部的值,因为这样可以减少很多复杂度和工作量。

综上所述,我们需要声明子组件本地数据变量。

<template>
    <input :value="state" @change="onChange" />
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
  value: {
    type: String,
    default: '',
  },
})
const emit = defineEmits(['change'])
// 声明子组件本地的数据变量
const state = ref('')
const onChange = (e) => {
  state.value = e.target.value
}
</script>

那么我们需要把父组件传递过来的 props 中的 value 进行初始化赋值给子组件本地的数据变量 state。很明显我们需要使用 watch hooks 函数进行监听 props 中的 value 值的变化然后进行子组件本地数据变量 state 的赋值。

watch(
  () => props.value,
  (newVal) => {
    state.value = newVal
  },
  { immediate: true }
)

同时在 change 事件的回调函数中,进行发射父组件的 change 监听事件。

const changeHandle = (e: any) => {
  state.value = e.target.value
  // 发射父组件的 change 监听事件
  emit('change', state.value)
}

所以整个子组件的完整代码如下:

<template>
  <input :value="state" type="text" @change="changeHandle" />
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
const props = defineProps({
  value: {
    type: String,
    default: '',
  },
})
const emit = defineEmits(['change'])
// 声明子组件本地的数据变量
const state = ref('')
watch(
  () => props.value,
  (newVal) => {
    state.value = newVal
  },
  { immediate: true }
)
const changeHandle = (e: any) => {
  state.value = e.target.value
  // 发射父组件的 change 监听事件
  emit('change', state.value)
}
</script>

在父组件中我们只需要进行以下调用即可:

<template>
state 值:{{ state }}
<my-input :value="state" @change="changeHandle" />
</template>
<script setup>
import { ref } from 'vue'
import MyInput from './my-input.vue'
// 默认值
const state = ref('稀土掘金')
const changeHandle = (value) => {
  state.value = value
}   
</script>

渲染结果如下:

我们看到我们成功手动实现了跟 v-model 一样效果的功能,其实我们还可以进一步优化父组件的调用。

<template>
state 值:{{ state }}
<my-input :value="state" @change="(value) => (state = value)" />
</template>
<script setup>
import { ref } from 'vue'
import MyInput from './my-input.vue'
// 默认值
const state = ref('稀土掘金')  
</script>

我们把监听函数直接写在模板中,这样我们就可以很清晰地看到 v-model 在做的事情了。

在 Vue2 中大部分情况下, v-model="state" 就等于是 :value="state"  加上 @input="($event) => (state = $event)"

对于一些原生元素,则会有不同的实现。

比如: select 表单元素。

<select v-model="state"></select>

对应的是

<select :value="state" @change="($event) => (state = $event)></select>

select 表单元素监听的则是 change 事件。

在 Vue3 中则有所修改,因为在 Vue2 中监听的 input 事件,本身是原生 input 表单 的一个原生事件,如果我们占用了这个原生事件,当我们再想去监听 input 表单 的原生 input 事件时就变得困难了。同样的 value 属性值也是原生 input 表单 的一个原生属性,如果占用了,再想给原生表单的 value 属性传值则也变得困难了。

在 Vue3 中 value 变成了 modelValueinput 事件变成了 onUpdate:modelValue

也就是在 Vue3 中, v-model="state" 就等于是 :modelValue="state"  加上 @onUpdate:modelValue="($event) => (state = $event)"

值得注意的是 Vue3 中对任何表单元素的都是统一的,都是上述的方式,但会进行不同的标记,然后在 v-model 指令内部再根据不同的标记进行不同的处理。

我们把 <my-input v-model="state" /> 进行编译之后再看,则会看得更清楚。

import { resolveComponent as _resolveComponent, openBlock as _openBlock, createBlock as _createBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_my_input = _resolveComponent("my-input")

  return (_openBlock(), _createBlock(_component_my_input, {
     modelValue: _ctx.state,
     "onUpdate:modelValue": $event => ((_ctx.state) = $event)
  }, null, 8 /* PROPS */, ["modelValue", "onUpdate:modelValue"]))
}

所以我们想要只使用 v-model 就实现 my-input 组件的双向数据绑定,我们只需进行以下修改即可。

<template>
  <input :value="state" type="text" @change="changeHandle" />
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'

const props = defineProps({
-  value: {
   modelValue: {
    type: String,
    default: '',
  },
})

- const emit = defineEmits(['change'])
  const emit = defineEmits(['onUpdate:modelValue'])

const state = ref('')

watch(
-  () => props.value,
   () => props.modelValue,
  (newVal) => {
    state.value = newVal
  },
  { immediate: true }
)

const changeHandle = (e: any) => {
  state.value = e.target.value
-  emit('change', state.value)
   emit('onUpdate:modelValue', state.value)
}
</script>

这样我们就可以在父组件使用 v-model 实现数据的双向绑定了。

<my-input v-model="state" />

小结

从受控组件的角度来讲,v-model 的本质就是把一个组件变成一个受控组件。从双向数据绑定的角度来说 v-model 的本质就是在实现双向数据绑定。而 React 没有相关的指令让用户简化双向数据的绑定,需要用户手动进行双向数据绑定。

什么是数据响应式

很多文章会把双向绑定的原理直接说成是数据响应式的原理,但它们是不一样的。

双向数据绑定简单来说就是把状态变量绑定到页面的表单元素上把状态变量的数据渲染出来,并且监听表单元素的变化(一般指用户的操作变化),把表单元素变化后的值重新设置给绑定该表单元素的状态变量,状态变量发生了改变,又会重新进行渲染。

值得注意的是,一般只是表单元素才进行双向数据绑定,普通元素(div、span、p)只进行单向数据绑定

状态变量发生改变,重新进行渲染,这个机制不同的框架,实现原理就不一样了。在 Vue 中是通过对数据的进行监听劫持实现,也就是数据响应式。而数据响应式简单来说就是数据变化后会自动重新运行依赖该数据的函数。在 Vue 中我们用来描述 UI 视图的模板都会被编译成一个渲染函数。关于响应式原理网上已经太多的文章了,这里就不再进行赘述了。

Input 组件的实现

基础架构代码搭建

我们根据前面的流程先搭建 Input 组件的基本框架代码。

目录结构如下:

├── packages
│   ├── components
│   │   ├── input
│   │   │   ├── __tests__        # 测试目录
│   │   │   ├── src              # 组件入口目录
│   │   │   │   ├── input.ts     # 组件属性与 TS 类型
│   │   │   │   └── input.vue    # 组件模板内容
│   │   │   ├── style            # 组件样式目录
│   │   │   └── index.ts         # 组件入口文件
│   │   └── package.json

input/src/input.ts 的基础代码:

import { isString } from '@vue/shared'
import { UPDATE_MODEL_EVENT } from '@cobyte-ui/constants'
import type { ExtractPropTypes, PropType } from 'vue'
import type Input from './input.vue'

// 定义 props
export const inputProps = {
  modelValue: {
    type: [String, Number, Object] as PropType<string | number | object>,
    default: '',
  },
  type: {
    type: String,
    default: 'text',
  },
} as const

// Props 类型
export type InputProps = ExtractPropTypes<typeof inputProps>

export const inputEmits = {
  [UPDATE_MODEL_EVENT]: (value: string) => isString(value),
}

export type InputEmits = typeof inputEmits

export type InputInstance = InstanceType<typeof Input>

上述代码中的 UPDATE_MODEL_EVENT 常量配置,而类似这样的配置整个组件库还有很多,所以我们需要把它进行统一配置管理,所以我们建立一个配置复合 npm 包 @cobyte-ui/constants

目录结构如下:

├── packages
│   ├── constants
│   │   ├── event.ts         # 事件配置目录
│   │   ├── index.ts         # 入口文件
│   │   └── package.json

package.json 文件内容:

{
  "name": "@cobyte-ui/constants",
  "private": true,
  "license": "MIT",
  "main": "index.ts"
}

event.ts 文件内容:

export const UPDATE_MODEL_EVENT = 'update:modelValue'

我们目前先配置一个事件的常量。

index.ts 文件内容:

export * from './event'

写完配置包的内容后,我们需要在项目的根目录下对其进行安装。

pnpm install @cobyte-ui/constants -w

这样我们就可以在项目中通过 npm 包引入形式进行导入配置文件了。例如在上述 input.ts 文件中,我们是通过如下方式进行引入的。

import { UPDATE_MODEL_EVENT } from '@cobyte-ui/constants'

接着我们补上 input.vue 的基础代码:

<template>
  <div :class="nsInput.b()">
    <input :type="type" />
  </div>
</template>

<script lang="ts" setup>
import { useNamespace } from '@cobyte-ui/hooks'
import { inputEmits, inputProps } from './input'
defineOptions({
  name: 'ElInput',
})
defineProps(inputProps)
defineEmits(inputEmits)

const nsInput = useNamespace('input')
</script>

再补上 input/index.ts 的入口代码:

import { withInstall } from '@cobyte-ui/utils'
import Input from './src/input.vue'

// 通过 withInstall 方法给 Input 添加了一个 install 方法
const ElInput = withInstall(Input)
// 可以通过 app.use 来使用,也可以通过 import 方式单独使用
export default ElInput
export * from './src/input'

play/main.ts 中引入 Input 组件了。

  import ElInput from '@cobyte-ui/components/input'
- const components = [ElIcon, ElButton, ElButtonGroup]
  const components = [ElIcon, ElButton, ElButtonGroup, ElInput]

接着我们在 play/src/App.vue 测试 Input 组件。

<template>
	<el-input />
<template>

渲染结果:

这样我们的 Input 组件的基础代码就搭建起来了。

Input 组件的 v-model 封装

我们在上述例子中已经实现过 v-model 的封装了,但在 Element Plus 中对 input 表单 的 v-model 封装又提供了另一种方案。

我们在上述例子是通过 watch 函数监听 props.modelValue 的属性值来赋值给一个本地变量的。而在 Element Plus 中的 Input 组件中,则是通过 computed 函数进行缓存计算得到本地。

const nativeInputValue = computed(() =>
  !props.modelValue ? '' : String(props.modelValue)
)
浅响应式

我们在第一个例子中是通过 ref 来创建 input 表单 的引用的。ref 可以实现对基础数据类型进行响应式监听,但如果传入的是一个引用类型,则底层还是调用 reactive 进行响应式监听,而 reactive 中又会对引用类型内部属性值为引用类型的进行递归调用 reactive 进行监听。因为本质是 Proxy 只能监听到当前对象下属性的变化,当属性为引用数据类型时,属性的属性他是监听不到的,所以需要递归监听。

如果我们的数据不需要全部进行响应式监听,当我们使用 ref 或者 reactive 进行创建代理对象的时候,就会产生额外的性能消耗。比如我们上面讲到的 input 表单 的引用,我们是不需要对整个数据进行响应式监听的,并且原生的 HTML DOM 元素的对象是拥有非常多属性的。

对于这种场景,Vue3 给我们提供了非递归监听的 API:shallowReactiveshallowRef,只监听第一层数据的变化。这个就是浅响应式。

所以在 Element Plus 中使用 shallowRef 创建 input 表单的引用。

const input = shallowRef<HTMLInputElement>()
// 进行缓存计算,因为后续还需要和 textarea 一起
const _ref = computed(() => input.value)
如何初始化 input 表单的值

Input 组件的初始值是通过 props.modelValue 传递的,我们已经由此而设置了一个本地计算变量 nativeInputValue。所以我们可以专门设置一个函数来设置 input 表单 的值,因为后续还有很多地方需要设置 input 表单 的值。

const setNativeInputValue = () => {
  const input = _ref.value
  if (!input || input.value === nativeInputValue.value) return
  input.value = nativeInputValue.value
}

使用 onMounted 函数进行初始值设置。

onMounted(() => {
  setNativeInputValue()
})
为什么使用 onMounted 函数进行初始化呢?

我们在上述受控组件的例子中,我们是通过给 input 表单 的 props 绑定 value 属性进行赋值。 其实本质就是通过 input 表单 的引用的 value 属性给 input 表单 设置值。我们可以创建的时候给 props 的 value 设置值,然后在创建了 input 的表单实例后,再通过 input 表单的实例引用根据 props 上的设置进行属性赋值。

而在 Element Plus 中我们不再通过 props 绑定 value 属性进行赋值,因为这种方案会占用 value 属性。而是通过 input 的 ref 进行赋值,本质也是通过 input 表单的实例引用进行赋值,但赋值的时机则变了。

我们在代码的 setup 方法中进行获取 input 表单的实例引用,在 setup 方法中是获取不到,因为 setup 方法执行的时候,组件还没进行渲染,所以我们要在组件渲染完成之后才进行初始化赋值,所以便需要使用 onMounted 函数进行初始化。

而通过模板进行 props 属性绑定操作赋值,则相当于在模板中进行了一个属性标记绑定,当模板被解析成 render 函数之后,在渲染的时候,会根据绑定的属性进行读取组件 setup 方法中返回的数据。这样便不用通过 onMounted 函数进行初始化了。

使用 input 事件监听 input 表单值

我们在上述的例子中是通过 change 事件进行监听 input 表单的值,change 事件需要在 input 表单失去焦点的时候才会触发;而 Element Plus 中是通过 input 事件进行监听 input 表单的值,input 事件可以实现对 input 表单 实时监控,只要 input 输入框值发生改变就会触发。

const handleInput = async (event: Event) => {
  const { value } = event.target as TargetElement
  // 发射 v-model 的 update:modelValue 监听事件
  emit(UPDATE_MODEL_EVENT, value)
  await nextTick()
  // 等待 DOM 更新后设置 input 表单的值
  setNativeInputValue()
}

template 更新为

<template>
  <div :class="nsInput.b()">
-    <input :type="type" />
     <input ref="input" :type="type" @input="handleInput" />
  </div>
</template>

接着我们在 play/src/App.vue 中进行测试。

<template>
  <el-input v-model="state" />
</template>

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

const state = ref('Cobyte')
watch(
  () => state.value,
  (newVal) => {
    console.log('state值:', newVal)
  }
)
</script>

测试结果:

我们可以看到现通过 v-model 实现对我们的变量进行双向绑定了。

为什么要使用 nextTick 进行更新设置

我们先看看如果不使用 nextTick 进行更新会发生什么。

可以看到,我们先输入 6,state值中已经更新6了,但 Input 框中并没有更新,这是因为我们是根据 props.modelValue 的值去设置真实 DOM 中的 input 表单中的值,我们在监听 input 事件变化后立即去更新真实 DOM 中的 input 表单的值,这时 props.modelValue 的值还没有更新,还是旧值,所以就会出现上面动态图中的情况。

那么为什么 props.modelValue 的值这时还没有更新呢?我们已经通过 emit 发射 update:modelValue 监听事件去更新父组件的 state 变量了,按道理这时也会更新 props.modelValue 的值 才对。这个就要涉及到 Vue 的更新原理了。当我们去更新父组件的响应式变量时,就会触发父组件的更新,但这个时候父组件的更新函数会被放到异步任务队列中去了。所以如果我们没有使用 nextTick 的话,直接在当前运行任务中获取 props 的值则还是更新前的值。

所以我们需要使用 nextTick 进行更新,nextTick 的本质也是开启一个异步任务。在前面的组件更新函数率先被放入异步任务队列,后面的 nextTick 则在组件更新函数的后面,所以这样也保证了使用了 nextTick 后能获取到最新的 props 数据和最新的 DOM 实例。

最本质是因为 Vue 组件更新是新老虚拟 DOM 的对比更新,要拿到新的虚拟DOM 则需要重新执行组件的 render 函数,而组件未执行更新函数之前虚拟DOM 中的数据还是旧的数据。此外 v-model 只修改 Props 第一层的数据。

const oldVnode = {Input, {modelValue: 'Cobyte'}}
const newVnode = {Input, {modelValue: 'Cobyte6'}}

从数据结构上来看更直观一点,关于虚拟DOM 的数据结构,我在《1. Vue3 组件库的设计和实现原理》一文中也有说明。oldVnode 是初始化的时候生成的,modelValue 的值是读取的初始化的值:Cobyte。在组件未重新执行渲染函数之前,modelValue 的值都还是 Cobyte,所以在不使用 nextTick 去开启异步任务进行赋值的话,所赋的值都是 oldVnode 中的值,只有通过 nextTick 开启异步任务进行赋值的话,等待组件重新执行更新函数生成新的虚拟 DOM 之后再去赋值才会获得最新的值。

compositionstart 和 compositionend 事件

我们通过上文已经实现了对 Input 组件的 v-model 封装,而对 input 表单的状态值的变化我们是通过 input 事件来进行监听的。该事件是当 input 表单的 value 值的发生变化时就会触发 input 事件。当该事件对中文输入法存在不友好现象。

我们从上图可以看到当我们使用中文输入法的时候,我们输入一个字母,我们的 state 值就被更改一次。这不是我们所希望看到的结果,我们希望是等我们输入中文字之后再去修改 state 的值。

这个时候利用 compositionstart 和 compositionend 事件就可以知道中文输入什么时候开始和结束,和这两个事件一起的还有一个 compositionupdate 事件。那么这三个事件有什么作用的呢?

compositionstart

  • 当用户使用拼音输入法开始输入汉字拼音时,这个事件就会被触发。

compositionupdate

  • 拼音输入法,输入中触发

compositionend

  • 拼音输入法,输入结束触发

简单来说就是使用中文输入法在打拼音字母时,就会首先触发 compositionstart 事件,然后每打一个拼音字母,就会触发 compositionupdate 事件,最后将拼好的中文填入 input 表单中时触发 compositionend 事件。

所以我们只需要设置一个全局变量 isComposing 然后监听这几个事件,在触发 compositionstart 事件时,把 isComposing 设置为 true,然后在 input 事件监听函数中判断如果 变量 isComposing 为 true 则不进行 input 表单值设置,在触发 compositionend 事件时再把 isComposing 设置为 false,这时才会在 input 事件的监听函数中就会去设置 input 表单的值。

关键代码如下:

template 中的代码

<input
   ref="input"
   :type="type"
    @compositionstart="handleCompositionStart"
    @compositionupdate="handleCompositionUpdate"
    @compositionend="handleCompositionEnd"
   @input="handleInput"
/>

script 中的关键代码

  const isComposing = ref(false)
const handleInput = async (event: Event) => {
  const { value } = event.target as TargetElement
   if (isComposing.value) return
  emit(UPDATE_MODEL_EVENT, value)
  await nextTick()
  setNativeInputValue()
}

  const handleCompositionStart = (event: CompositionEvent) => {
   emit('compositionstart', event)
   isComposing.value = true
  }

  const handleCompositionUpdate = (event: CompositionEvent) => {
   emit('compositionupdate', event)
  }

  const handleCompositionEnd = (event: CompositionEvent) => {
   emit('compositionend', event)
   if (isComposing.value) {
     isComposing.value = false
     handleInput(event)
   }
  }

我们将相关事件也 emit 出去,让用户也可以进行自由监听原生的事件。

input.ts 中关键代码

export const inputEmits = {
  [UPDATE_MODEL_EVENT]: (value: string) => isString(value),
   compositionstart: (evt: CompositionEvent) => evt instanceof CompositionEvent,
   compositionupdate: (evt: CompositionEvent) => evt instanceof CompositionEvent,
   compositionend: (evt: CompositionEvent) => evt instanceof CompositionEvent,
}

最后的结果如下:

这个时候我们就发现我们输入中文完毕之后 state 值才发生变化。

总结

至此,我们将 Input 组件中比较核心的部分进行了详细的讲解,至于其他功能是细枝末节,这里就不作深入探讨了。

欢迎关注本专栏,了解更多 Element Plus 组件库知识

本专栏文章:

1. Vue3 组件库的设计和实现原理

2. 组件库工程化实战之 Monorepo 架构搭建

3. ESLint 核心原理剖析

4. ESLint 技术原理与实战及代码规范自动化详解

5. 从终端命令解析器说起谈谈 npm 包管理工具的运行原理

6. CSS 架构模式之 BEM 在组件库中的实践

7. 组件实现的基本流程及 Icon 组件的实现

8. 为什么组件库或插件需要定义 peerDependencies

9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

10. CSS 系统颜色和暗黑模式的关系及意义

11. 深入理解组件库中SCSS和CSS变量的架构应用和实践

12. v-model 的实现原理及Input 组件的核心实现

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tangbeihf
系列文章
更多 icon
同类精品
更多 icon
继续加载