![[為你自己寫 Vue Component] AtomicTextField](https://ithelp.ithome.com.tw/upload/images/20240925/20120484amVnkZsaZh.png)
<input> 作為表單控制元素,是網頁開發中最常見的元素之一。<input> 的不同 type 設定會影響顯示的 UI,以及它所代表的含義與功能。
在這裡,我們將使用先前建立的 <AtomicFormField> 作為基礎,來實作 <AtomicTextField>。
如同開頭所提到的,在網頁中不同 type 的 <input> 不論在 UI 或功能上都會不同。<input> 可以是文字輸入框、日期選擇器、顏色選擇器,也可以是檔案上傳或按鈕等。因此在這裡選用 <AtomicTextField> 更強調了這是一個專為「文字輸入框」設計的元件。

在開始實作前,我們先研究各個 UI Library 的 TextField / Input 元件是如何設計的。
Element Plus

<template>
  <ElInput 
    v-model="input"
    style="width: 640px"
  >
    <template #prefix>
      prefix
    </template>
    <template #suffix>
      suffix
    </template>
    <template #prepend>
      prepend
    </template>
    <template #append>
      append
    </template>
  </ElInput>
</template>
Element Plus 的 <ElInput> 提供了大部分 <input> 重要的功能設定,也提供了 prefix-icon 與 suffix-icon 這兩個 props,讓開發人員可以在 <ElInput> 的前後放入 Icon 元件。另外,<ElInput> 還提供了 prefix、suffix、prepend、append 等 slot 來讓開發人員依照需求調整元件的外觀。
但在使用 <ElInput> 時有一些限制必須注意:
文件表示 <ElInput> 為受控元件,必須與 Vue 的資料綁定才能正常操作。
文件表示 <ElInput> 不支援修飾符(modifiers),但實際使用後目前僅不支援 v-model.lazy 功能。
除了 v-model.number 之外,當 type 設定為 number 時,Vue 也會將資料自動轉換成數字,但 <ElInput> 不支援這種使用方式。
<input type="number" v-model="value" />
上面的範例會將 value 轉換成數字。
<ElInput type="number" v-model="value" />
上面的範例不會將 value 轉換成數字。
Vuetify

<template>
  <VTextField
    label="Label"
    prepend-icon="mdi-account"
    prepend-inner-icon="mdi-account-box-outline"
    append-icon="mdi-text"
    append-inner-icon="mdi-close"
  >
    <template #prepend> <VICon icon="$vuetify" /></template>
    <template #append> <VICon icon="$vuetify" /></template>
  </VTextField>
</template>
Vuetify 的 <VTextField> 提供了 label、prepend-icon、prepend-inner-icon、append-icon、append-inner-icon 等 props,讓開發人員可以在 <VTextField> 的前後放入 Icon 名稱。此外,還提供了 prepend、prepend-inner、append 與 append-inner 等 slot,讓開發人員依照需求調整元件的外觀。
在使用 <VTextField> 時也有一些限制必須注意:
<VTextField> 不支援 lazy 修飾符(modifiers)。<VTextField> 不支援在 type 設定為 number 時自動將資料轉換成數字,這點與 Element Plus 相同。Nuxt UI

<template>
  <UInput
    v-model="value"
    type="text"
    leadingIcon="i-heroicons-magnifying-glass-20-solid"
    trailingIcon="i-heroicons-light-bulb"
  />
</template>
Nuxt UI 的 <UInput> 提供了 leadingIcon 與 trailingIcon 這兩個 props,讓開發人員可以在 <UInput> 的前後放入 Icon 名稱。此外,還提供了 leading 與 trailing slot,讓開發人員依照需求調整元件的外觀。
與 Element Plus 和 Vuetify 一樣,每個對應的 icon props 都有對應的 slot,但不同的是,在 Element Plus 和 Vuetify 中 slot 與 props 是同時存在的,而 Nuxt UI 中 slot 的權重大於 props,也就是說如果兩者都有設定,則只會顯示 slot 的內容。
另外,Nuxt UI 的 <UInput> 的 v-model 沒有前面兩個 UI Library 上的使用限制,所有的修飾符:trim、number 跟 lazy 都完全支援。不過對於非拉丁語系,如繁體中文,在選字時的處理不夠完整。
Nuxt UI 對於非拉丁語系選字時的處理沒有判斷是否正在選字:

Vue 原生在 <input> 上的 v-model 有支援判斷是否正在選字:

關於非拉丁語系的判斷的問題在這裡列出的三個 UI Library 僅僅只有 Element Plus 有特別處理,另外除了上面列出的功能外,Element Plus 與 Vuetify 都提供字數統計功能,這對於需要限制使用者輸入字數的場景非常有用。
綜合以上並結合自身經驗,我們統整出 <AtomicTextField> 的功能:
v-model 來綁定輸入的值。modelModifiers,如果 v-model 使用了修飾符,則會收集在這個 props 裡面,如 trim、number 與 lazy 修飾符。type 設定,可接受的設定有:text、password、email、number、tel、url、search。showCount,開啟顯示目前輸入的字數,但只對輸入為字串時有效。<input> 的各種設定,如:placeholder、disabled、readonly、required、maxlength、minlength。append 與 prepend props 與 slots,讓開發人員可以在 <input> 前後加入需要的 UI。<AtomicFormField> 的 props 與 slots。使用結構如下:
<template>
  <AtomicTextField
    v-model="input"
    type="text"
    placeholder="請輸入標題"
    maxlength="10"
    showCount
  />
</template>
首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:
| 屬性 | 型別 | 預設值 | 說明 | 
|---|---|---|---|
| modelValue | string,number | 雙向綁定的值 | |
| modelModifiers | Record<'trim' | 'number' | 'lazy', boolean> | {} | v-model的修飾符 | 
| type | text,password,email,number,tel,url,search | text | <input>的type設定 | 
| placeholder | string | <input>的placeholder設定 | |
| disabled | boolean | false | <input>的disabled設定 | 
| readonly | boolean | false | <input>的readonly設定 | 
| required | boolean | false | <input>的required設定 | 
| maxlength | number | <input>的maxlength設定 | |
| minlength | number | <input>的minlength設定 | |
| showCount | boolean | false | 顯示目前輸入的字數 | 
| append | string,Component | 在 <input>後加入的文字 | |
| prepend | string,Component | 在 <input>前加入的文字 | 
同時我們支援 <AtomicFormField> 的所有 props 與 slots,這裡就不一一列出。
interface AtomicTextFieldProps {
  modelValue?: string | number;
  modelModifiers?: Record<'trim' | 'number' | 'lazy', boolean>;
  type?: 'text' | 'password' | 'email' | 'tel' | 'number';
  placeholder?: string;
  label?: string;
  labelPlacement?: 'top' | 'left';
  labelWidth?: string | number;
  hideLabel?: boolean;
  prepend?: string | Component
  append?: string | Component
  name?: string;
  maxlength?: InputHTMLAttributes['maxlength']
  minlength?: InputHTMLAttributes['minlength']
  showCount?: boolean;
  message?: string;
  error?: boolean;
  required?: boolean;
  disabled?: boolean;
  readonly?: boolean;
}
interface AtomicTextFieldEmits {
  (event: 'update:modelValue', value: string | number | undefined): void;
}
const props = withDefaults(defineProps<AtomicTextFieldProps>(), {
  modelValue: undefined,
  modelModifiers: () => ({}),
  type: 'text',
  placeholder: undefined,
  label: undefined,
  labelPlacement: 'left',
  labelWidth: 'fit-content',
  prepend: undefined,
  append: undefined,
  name: undefined,
  maxlength: undefined,
  minlength: undefined,
  message: undefined,
});
const emit = defineEmits<AtomicTextFieldEmits>();
為了讓雙向綁定處理起來更加方便,我們先用 computed 來處理 modelValue 的資料。
const modelValueLocal = ref(props.modelValue ?? '');
const modelValueWritable = computed({
  get() {
    return props.modelValue ?? modelValueLocal.value;
  },
  set(value) {
    emit('update:modelValue', value);
    modelValueLocal.value = value;
  },
});
這裡加上了一個 modelValueLocal 來同步 modelValue 的資料,主要是為了讓開發人員沒有在 <AtomicTextField> 上使用 v-model 時,元件會退而求其次使用內部資料,確保的功能也能正常運作。這也是我們前幾篇有提過的:非受控元件。
接著我們來解決 v-model 雙向綁定與 modelModifiers 設定問題,因為現在的 <input> 被包在 <AtomicTextField> 中,如果我們需要讓所有的 modelModifiers 都能正常運作,看起來我們得自己手動實現 modelModifiers 裡面的每一個修飾符。
我們要處理的有:
v-model.trim:如果字串前後有空白,則會自動去除。v-model.number:如果字串可以轉換成數字,則會自動轉換。v-model.lazy:在 <input> 上的雙向綁定原本是 input 事件,但 v-model.lazy 會改成 change 事件。幸運的是,Vue 3 的 emit 內部已經至少實現了 number 與 trim 這兩個修飾符,這表示 v-model.number 與 v-model.trim 這兩個用法不需要額外處理,但 v-model.lazy 我們還是需要自己實現。
這可能會是個略大的工程,為了實現 lazy 功能,我們不能在 <AtomicTextField> 內部的 <input> 上使用 v-model,我們得像 Vue 底層在處理 v-model 一樣,監聽 input 與 change 事件來實作這個功能。
<input
  class="atomic-text-field__input"
  :value="modelValue"
  @input="onInput"
  @change="onChange"
>
接著我們處理遇到 v-model.lazy 的情境。
const onInput = (event: Event) => {
  if (props.modelModifiers.lazy) return;
  const value = (event.target as HTMLInputElement).value;
  emit('update:modelValue', value);
};
const onChange = (event: Event) => {
  if (!props.modelModifiers.lazy) return;
  const value = (event.target as HTMLInputElement).value;
  emit('update:modelValue', value);
};
這樣我們的 <AtomicTextField> 就同時支援了 v-model 的 trim、number 與 lazy 修飾符。
接著我們要處理當 type 設定為 number 時,modelValue 需要轉換成數字的功能。我們採用比較簡單的做法,使用 event 物件中的 valueAsNumber 來轉換成數字。
const onInput = (event: Event) => {
  if (props.modelModifiers.lazy) return;
  const number = props.type === 'number';
  const value = number 
    ? (event.target as HTMLInputElement).valueAsNumber
    : (event.target as HTMLInputElement).value;
  emit('update:modelValue', value);
};
const onChange = (event: Event) => {
  if (!props.modelModifiers.lazy) return;
  const number = props.type === 'number';
  const value = number 
    ? (event.target as HTMLInputElement).valueAsNumber
    : (event.target as HTMLInputElement).value;
  emit('update:modelValue', value);
};
這樣一來,當 <AtomicTextField> 的 type 設定為 number 時,modelValue 就會在輸入的過程中自動轉換成數字。
接著我們還有一個問題需要解決,就是當使用者的語系不是拉丁語系時,如繁體中文,在確定輸入前不應該觸發 input 事件更新 modelValue。
要解決這個問題,我們可以使用 Composition 事件,當使用者正在拼字、選字時,compositionstart 事件會觸發,當使用者確定輸入時,compositionend 事件會觸發。所以當 compositionstart 觸發時,我們開啟一個 composing 狀態,當 compositionend 觸發時,我們關閉這個狀態,而在 composing 狀態下的 input 事件不會更新 modelValue。
let composing = false;
const onCompositionstart = () => {
  composing = true;
};
const onCompositionend = () => {
  if (!composing) return;
  composing = false;
  event.target.dispatchEvent(new Event('input'));
};
const onInput = (event: Event) => {
  if (props.modelModifiers.lazy) return;
  if (composing) return;
  const number = props.type === 'number';
  const value = number 
    ? (event.target as HTMLInputElement).valueAsNumber
    : (event.target as HTMLInputElement).value;
  emit('update:modelValue', value);
};
這樣在繁體中文輸入,拼音、選字的過程中,modelValue 就不會更新,只有在確定輸入時才會更新。
compositionend事件會發生在input事件之後,因此我們需要手動觸發input事件,這樣才能順利更新modelValue。
在這裡,我們使用了 compositionstart 與 compositionend 事件。如果只需支援現代瀏覽器,我們可以在 input 事件上使用 isComposing 來判斷是否正在選字。
const onInput = (event: Event) => {
  if (props.modelModifiers.lazy) return;
  if ((event as InputEvent).isComposing) return
  const number = props.type === 'number';
  const value = number 
    ? (event.target as HTMLInputElement).valueAsNumber
    : (event.target as HTMLInputElement).value;
  emit('update:modelValue', value);
};
到這裡,我們的 modelValue 不僅支援了 trim、number、lazy 修飾符,在 type 為 number 時也會轉換為數字,並且支援了非拉丁語系的選字功能。
接著我們來處理 showCount 功能,這個功能主要是在 <input> 下方顯示目前輸入的字數,這對於需要限制使用者輸入字數的場景非常有用。
計算字數的方式非常簡單,我們只要在 modelModifiers.number 為 false 並且 type 不等於 number 時計算字數即可。
const stringCount = computed(() => {
  if (props.modelModifiers.number || props.type === 'number') return 0;
  return `${props.modelValue}`.length;
});
這樣的算法有效又簡單,但遇到生僻字或 Emoji 時,可能會有問題。
'𡘙'.length; // length 為 2
'👩👩👧👧'.length; // length 為 11
我們肉眼看到的是一個字,但使用 length 計算的長度卻是 11。如果產品要求這應該視為一個字,我們可以使用 Intl.Segmenter 來幫助我們。
const segmenter = new Intl.Segmenter();
const stringCount = computed(() => {
  if (props.modelModifiers.number || props.type === 'number') return 0;
  const string = props.modelValue;
  if (string === '') return 0;
  let length = 0;
  for (const _ of segmenter.segment(props.modelValue)) { 
    length++;
  }
  return length;
});
不過,Intl.Segmenter 在 Firefox 125.0.1(2024 年 4 月 16 日發布)之前的版本不適用。如果需要支援度較高的作法,我們可以使用像是 lodash.toarray、char-regex 或 string-length@5.0.1 來實現這個功能。
我們使用 string-length 來實現這個功能。
import stringLength from 'string-length';
const stringCount = computed(() => {
  if (props.modelModifiers.number || props.type === 'number') return 0;
  return stringLength(props.modelValue);
});
這樣一來,如果需求是要計算視覺上看到的字數,我們就可以使用這些方法來完成。
最後,我們來處理 prepend 與 append 的 props 和 slots。slots 的在模板中我們可以這樣處理,以 prepend 為例:
<span
  v-if="prepend || $slots.prepend"
  class="atomic-text-field__prepend"
>
  <slot name="prepend">
    <template v-if="isString(prepend)">
      {{ prepend }}
    </template>
    <template v-else>
      <component :is="prepend" />
    </template>
  </slot>
</span>
主要的功能都已經完成,我們接著將這些功能整合到 <AtomicTextField> 的模板中。
<template>
  <AtomicFormField
    class="atomic-text-field"
    v-bind="fieldProps"
  >
    <template
      v-if="$slots.label"
      #label
    >
      <slot name="label" />
    </template>
    <template #default="{ id, describedby }">
      <div class="atomic-text-field__container">
        <span
          v-if="prepend || $slots.prepend"
          class="atomic-text-field__prepend"
        >
          <slot name="prepend">
            <template v-if="isString(prepend)">
              {{ prepend }}
            </template>
            <template v-else>
              <component :is="prepend" />
            </template>
          </slot>
        </span>
        <input
          :id="id"
          :aria-describedby="describedby"
          class="atomic-text-field__input"
          :disabled="disabled"
          :maxlength="maxlength"
          :minlength="minlength"
          :name="name"
          :placeholder="placeholder"
          :readonly="readonly"
          :type="type"
          :value="modelValue"
          @change="onChange"
          @compositionend="onCompositionend"
          @compositionstart="onCompositionstart"
          @input="onInput"
        />
        <span
          v-if="append || $slots.append"
          class="atomic-text-field__append"
        >
          <slot name="append">
            <template v-if="isString(append)">
              {{ append }}
            </template>
            <template v-else>
              <component :is="append" />
            </template>
          </slot>
        </span>
      </div>
    </template>
    <template
      v-if="message || $slots.message || shouldShowCount"
      #message
    >
      <span class="atomic-text-field__message">
        <slot name="message">
          {{ message }}
        </slot>
      </span>
      <span v-if="shouldShowCount">
        {{ stringCount }}/{{ maxlength }}
      </span>
    </template>
  </AtomicFormField>
</template>
最後,依照專案需求加上 CSS 後,我們就完成了 <AtomicTextField> 元件。
處理 CSS 時有一個小技巧,我們的 <AtomicTextField> 使用了 <AtomicFormFiled> 作為包裝元件,在 <AtomicFormFiled> 中我們有設定了一些 CSS 變數,在這裡我們可以使用這些 CSS 變數來設定 <AtomicTextField> 的樣式。
.atomic-text-field {
  &__container {
    display: flex;
    align-items: center;
    overflow: hidden;
    width: 100%;
    height: 36px;
    height: var(--field-height);
    border-style: solid;
    border-width: 1px;
    border-color: var(--field-color);
    border-radius: 6px;
    column-gap: 6px;
  }
}
像這樣,我們的 height 與 border-color 就可以使用 <AtomicFormField> 的 CSS 變數來設定。如果未來需要調整不同的高度或顏色,可以直接在 <AtomicFormFiled> 中調整,這樣不論是現在的 <AtomicTextField> 或是未來的 <AtomicTextarea> 或 <AtomicSelect> 都可以一併調整完成。
前面我們花了一些篇幅來處理 v-model 的修飾符,除了讓元件更好用之外,也要對齊 Vue 對 <input v-model> 的處理。
這裡我們先對標一個小小的觀念,v-model 是一個語法糖,這是很常見的說法,但這並不完全正確。
在元件上,這確實是語法糖:
<CustomComponent v-model="value">
上面的寫法等同於
<CustomComponent
  :modelValue="value"
  @update:modelValue="value = $event"
>
但如果是 <input v-model="value"> 卻完全是另外一回事。
它接近但不等於下列這種寫法:
<input
  :value="value"
  @input="value = $event.target.value"
>
如果要完整模擬 <input v-model="value"> 的行為,就得像前面那樣,處理 trim、number、lazy 這些修飾符,考慮 type 為 number 的情境,還得處理 compositionstart 與 compositionend 事件,這樣才能完全對齊 Vue 的行為。
更正確地說,在 <input v-model="value"> 這個用法中的 v-model 是個「指令」(Directive),而不是語法糖。我們可以直接從 Vue SFC Playground 中看到,<input v-model="value"> 會被轉換成下列程式碼:
import { vModelText as _vModelText, withDirectives as _withDirectives, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
function render(_ctx, _cache, $props, $setup, $data, $options) {
  return _withDirectives((_openBlock(), _createElementBlock("input", {
    "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => (($setup.value) = $event))
  }, null, 512 /* NEED_PATCH */)), [
    [_vModelText, $setup.value]
  ])
}
這一大坨程式碼看起來有點可怕,但冷靜下來仔細觀察,我們可以抓到幾個重點。withDirectives 在我們實作 <AtomicPopover> 中出現過,這就是指令在 render function 中的寫法,而 vModelText 就是處理 v-model 的指令物件。
我們再看看有修飾符的 v-model:
<input v-model.number.lazy="value">
會被轉換成:
import { vModelText as _vModelText, withDirectives as _withDirectives, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
function render(_ctx, _cache, $props, $setup, $data, $options) {
  return _withDirectives((_openBlock(), _createElementBlock("input", {
    "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => (($setup.value) = $event))
  }, null, 512 /* NEED_PATCH */)), [
    [
      _vModelText,
      $setup.value,
      void 0,
      {
        number: true,
        lazy: true
      }
    ]
  ])
}
既然這是一個 Vue 的指令,那我們是不是可以依樣畫葫蘆,直接使用 Vue 的 vModelText 來幫助我們實現這個功能呢?
import {
  createElementBlock,
  defineComponent,
  vModelText,
  withDirectives,
} from 'vue';
const modelValueLocal = ref(props.modelValue ?? '');
const modelValueWritable = computed({
  get() {
    return props.modelValue ?? modelValueLocal.value;
  },
  set(value) {
    emit('update:modelValue', value);
    modelValueLocal.value = value;
  },
});
const InputComponent = defineComponent({
  render() {
    return withDirectives(
      createElementBlock(
        'input',
        {
          'onUpdate:modelValue': (event: string | number) =>
            (modelValueWritable.value = event),
        },
        null,
        512 /* NEED_PATCH */
      ),
      [[vModelText, modelValueWritable.value, '', props.modelModifiers]]
    );
  },
});
這樣,我們在模板裡面的 <input> 只要換成 <InputComponent> 就大功告成了。
<InputComponent
  :id="id"
  :aria-describedby="describedby"
  class="atomic-text-field__input"
  :disabled="disabled"
  :maxlength="maxlength"
  :minlength="minlength"
  :name="name"
  :placeholder="placeholder"
  :readonly="readonly"
  :type="type"
/>
這樣一來,我們就不需要再自己實現 v-model 的修飾符,處理 type 等於 number 的情境,處理 compositionstart 與 compositionend 事件,只要使用 Vue 的 vModelText 搭配 render function 就可以完成這個功能。
<AtomicTextField> 使用了 <AtomicFormField> 元件的 UI 架構與外觀,我們只需專注於處理 v-model 的雙向綁定與修飾符,就可以完成這個元件。如果要更貼近 Vue 的 <input v-model="value">,還需要考量到非拉丁語系拼音與選字的問題。
字數統計上,最簡單的方法就是直接使用字串的長度作為字數,但如果想要更精確地計算視覺上看到的字數,我們可以使用瀏覽器內建的 Intl.Segmenter 或 string-length 這些工具來幫助我們。
最後,我們拆解了 <input v-model="value"> 的行為,發現這時的 v-model 並不是一個語法糖,而是一個 Vue 的指令。幸運的是,Vue 提供了 vModelText 這個指令的實作給我們使用,這讓我們不需要再自己實現 v-model 的修飾符,處理 type 為 number 的情境,處理 compositionstart 與 compositionend 事件,只需使用 Vue 的 vModelText 搭配 render function 就可以完成這個功能。
<AtomicTextField> 原始碼:AtomicTextField.vue
<AtomicFormField> 實作回顧:[為你自己寫 Vue Component] AtomicFormField