组件 v-model 的封装实现原理及 Input 组件的核心实现(上)

前言

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

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

受控组件和非受控组件

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

复制代码
<input name="username" />

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

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

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

复制代码
<!doctype html>

<body>
  <div id="app">
    <input ref="input" :value="state" name="username" />
    <button type="primary" @click="onSubmit">提交</button>
    state 值:{{ state }}
  </div>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

  <script>
    const { createApp, ref } = Vue
    const App = {
      setup() {
        // 定义 input 的 HMTL 引用
        const input = ref()
        // 编辑时的默认状态值
        const state = ref('贾公子')
        // 提交方法
        const onSubmit = () => {
          console.log('提交的数据:')
          console.log('input表单值', input.value.value)
          console.log('state值', state.value)
        }
        return {
          input,
          state,
          onSubmit
        }
      }
    }
    const app = Vue.createApp(App)

    app.mount("#app")
  </script>
</body>

</html>

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

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

受控组件

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

复制代码
<!doctype html>

<body>
  <div id="app">
    <input ref="input" :value="state" name="username" @change="onChange" />
    <button type="primary" @click="onSubmit">提交</button>
    state 值:{{ state }}
  </div>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

  <script>
    const { createApp, ref } = Vue
    const App = {
      setup() {
        // 定义 input 的 HMTL 引用
        const input = ref()
        // 编辑时的默认状态值
        const state = ref('贾公子')
        // 提交方法
        const onSubmit = () => {
          console.log('提交的数据:')
          console.log('input表单值', input.value.value)
          console.log('state值', state.value)
        }
        const onChange = (e) => {
          state.value = e.target.value
        }

        return {
          input,
          state,
          onSubmit,
          onChange
        }
      }
    }
    const app = Vue.createApp(App)

    app.mount("#app")
  </script>
</body>

</html>

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

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

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

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

什么是 MVVM

我们上述例子中,其实是在手动进行双向数据绑定的操作。而双向绑定是 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 中的数据。

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

复制代码
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)} />
  }
}

有人说 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"]))
}

// Check the console for the AST

所以我们想要只使用 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 没有相关的指令让用户简化双向数据的绑定,需要用户手动进行双向数据绑定。

相关推荐
weixin199701080161 小时前
亚马逊商品详情页前端性能优化实战
前端·性能优化
全栈前端老曹2 小时前
【Redis】 监控与慢查询日志 —— slowlog、INFO 命令、RedisInsight 可视化监控
前端·数据库·redis·缓存·全栈·数据库监控·slowlog
扶苏10022 小时前
Vue 3 的组合式 API(Composition API)优势
前端·javascript·vue.js
木子欢儿2 小时前
debian 13 安装配置ftp 创建用户admin可以访问 /mnt/Data/
linux·运维·服务器·数据库·debian
万少2 小时前
这可能是程序员离用AI赚钱最容易的一个机会了
前端·ai编程
范什么特西2 小时前
狂神---死锁
java·前端·javascript
weixin199701080162 小时前
易贝(eBay)商品详情页前端性能优化实战
前端·性能优化
用户600071819102 小时前
【翻译】Rolldown 工作原理解析:符号关联、CJS/ESM 模块解析与导出分析
前端
想睡好2 小时前
标签的ref属性
前端·javascript·html