iT邦幫忙

2024 iThome 鐵人賽

DAY 23
2
Modern Web

為你自己寫 Vue Component系列 第 23

[為你自己寫 Vue Component] AtomicTextarea

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicTextarea

<textarea><input type="text"> 有需多相似之處,甚至大多數的特性都是共通的,例如都可以接收 placeholderdisabledrequiredminlengthmaxlength 等屬性。

儘管 <textarea><input type="text"> 有許多相似之處,但它們最大的差異在於 <textarea> 可以接收多行文字,而 <input type="text"> 只能接收單行文字。<textarea> 還有一些特殊的屬性,例如 rowscols,可以用來設定 <textarea> 的寬度與高度。一般情況下,我們可以通過點擊並拖曳右下角來調整 <textarea> 的大小。

元件分析

元件架構

AtomicTextarea 元件架構

  1. Label:Textarea 的標籤。
  2. Prepend:Textarea 的前置 Icon。
  3. Input:文字輸入框,用來輸入文字。
  4. Append:Textarea 的後置 Icon,不存在時不渲染。
  5. Message:Textarea 的說明文字,可以用來顯示欄位的說明或是錯誤訊息。
  6. Count:顯示目前輸入的字數。

功能設計

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

Element Plus

Element Plus Textarea

<template>
  <ElInput
    v-model="textarea"
    :rows="2"
    type="textarea"
    placeholder="Please input"
    autosize
  />
</template>

Element Plus 的 Textarea 整合在 <ElInput> 裡面,透過 type="textarea" 來指定輸入元素(表單控制元素)使用 <textarea>,並透過 :rows="2" 來設定其高度。

由於 Textarea 被設計在同一個元件中,所以在 <AtomicTextField> 那篇裡面提到關於 <ElInput> 特性與各種使用上的限制在這裡也同樣適用。此外,在 type="textarea" 模式下還有一些特殊的屬性,例如 rows 用於設定 <textarea> 的高度,以及 autosize,這個設定可以讓 <textarea> 隨著內容的增加或減少自動調整高度。

在 Element Plus 中,autosize 不僅可以是 boolean 值,還可以是一個物件,這個物件可以設定 <textarea> 的最小與最大高度。

Vuetify

Vuetify Textarea

<template>
  <VTextarea
    auto-grow
    class="mx-2"
    label="prepend-inner-icon"
    prepend-inner-icon="mdi-comment"
    rows="10"
  />
</template>

Vuetify 的 Textarea 與 Element Plus 不同,他將其獨立成 <VTextarea> 元件,而不是整合在 <VTextField> 中。這樣的設計讓 <VTextarea> 可以有更好的維護性,也可以有更多的自由度來設計。

<VTextarea> 也支援自動隨這內容增加或減少而調整高度的功能,在這裡我們可以透過 autoGrow 來設定,而 maxRows 可以用來設定 <textarea> 的最大高度。

Nuxt UI

Nuxt UI Textarea

<template>
  <UTextarea
    v-model="value"
    autoresize
    placeholder="Search..."
  />
</template>

Nuxt UI 的 <UTextarea> 比起前兩個元件功能單純很多,但它也支援自動調整高度的功能,我們可以透過 autoresize 來啟用者個功能,並使用 maxrows 來限制最大高度。

<AtomicTextarea> 實作上幾乎與 <AtomicTextField> 相同,但為了更好地針對 <textarea> 的特性來設計,我選擇將 <AtomicTextarea> 獨立成單一的元件,這樣我們在處理樣式、或是 autosize 的功能時就不容易不小心影響到其他元件。

綜合以上並結合自身經驗,我們統整出 <AtomicTextField> 的功能:

  • 使用 v-model 來綁定輸入的值。
  • 支援 modelModifiers,如果 v-model 使用了修飾符,則會收集在這個 props 裡面,如 trimnumberlazy 修飾符。
  • 支援 showCount,開啟顯示目前輸入的字數,但只對輸入為字串時有效。
  • 支援 autosize,讓 <textarea> 可以隨著內容的增加或減少而自動調整高度。
  • 支援 rowsmaxRows,分別表示最小行數與最大行數。
  • 支援 <textarea> 的各種設定,如:placeholderdisabledreadonlyrequiredmaxlengthminlength
  • 支援 appendprepend props 與 slots,讓開發人員可以在 <textarea> 前後加入需要的 UI。
  • 支援 <AtomicFormField> 的 props 與 slots。

使用結構如下:

<template>
  <AtomicTextarea
    v-model="value"
    autosize
    max-rows="5"
    placeholder="Please input"
    rows="2"
    show-count
  />
</template>

元件實作

首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:

屬性 型別 預設值 說明
modelValue string, number 雙向綁定的值
modelModifiers Record<'trim' | 'number' | 'lazy', boolean> {} v-model 的修飾符
autosize boolean, 是否啟用 autosize 功能
rows number, ${number} <textarea>rows 設定
maxRows number, ${number} autosize 時的最大行數
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> 前加入的文字
interface AtomicTextareaProps {
  modelValue?: string;
  modelModifiers?: Record<string, boolean>;
  placeholder?: string;
  label?: string;
  labelPlacement?: 'top' | 'left';
  labelWidth?: string | number;
  hideLabel?: boolean;
  prepend?: string | Component;
  append?: string | Component;
  name?: string;
  maxlength?: TextareaHTMLAttributes['maxlength'];
  minlength?: TextareaHTMLAttributes['minlength'];
  rows?: TextareaHTMLAttributes['rows'];
  maxRows?: TextareaHTMLAttributes['rows'];
  showCount?: boolean;
  message?: string;
  error?: boolean;
  required?: boolean;
  disabled?: boolean;
  readonly?: boolean;
  autosize?: boolean | 'cacheMeasurements';
}

interface AtomicTextareaEmits {
  (event: 'update:modelValue', value: string | undefined): void;
}

const props = withDefaults(defineProps<AtomicTextareaProps>(), {
  modelValue: undefined,
  modelModifiers: () => ({}),
  placeholder: undefined,
  label: undefined,
  labelPlacement: 'left',
  labelWidth: 'fit-content',
  prepend: undefined,
  append: undefined,
  name: undefined,
  maxlength: undefined,
  minlength: undefined,
  rows: 1,
  maxRows: undefined,
  message: undefined,
  autosize: false,
});

const emit = defineEmits<AtomicTextareaEmits>();

雙向綁定

為了確保當開發人員沒有在 <AtomicTextarea> 上使用 v-model 時,元件內部其他功能仍能正常運作,我們使用 modelValueLocal 作為備用資料。

const modelValueLocal = ref(props.modelValue ?? '');
const modelValueWritable = computed({
  get() {
    return props.modelValue ?? modelValueLocal.value;
  },
  set(value) {
    emit('update:modelValue', value);
    modelValueLocal.value = value;
  },
});

在雙向綁定的部分,我們已經在 <AtomicTextField> 中幾種解決方案,使元件能完整支援 v-model 的雙向綁定與 modelModifiers 的修飾符功能。這裡只需將要渲染的元素改成 textarea 即可。

const InputComponent = defineComponent({
  render() {
    return withDirectives(
      createElementBlock(
        'textarea',
        {
          ref: textareaRef,
          'onUpdate:modelValue': (event: string) =>
            (modelValueWritable.value = event),
        },
        null,
        512 /* NEED_PATCH */
      ),
      [[vModelText, modelValueWritable.value, '', props.modelModifiers]]
    );
  },
});

其他功能的作法及注意事項都跟 <AtomicTextField> 一樣,因此我們將重點放在 autosize 功能上。

Autosize

這裡我們簡單分析一下各個 UI Library 的底層怎麼處理 autosize 這個功能。

在 Element Plus 與 Vuetify 中,它們都額外建立了一個視覺上隱藏的 <textarea> 元素,我們這邊稱它為 hiddenTextarea

hiddenTextarea 的樣式與內容會與主要的 <textarea> 保持同步,每次更新完就將自己的 scrollHeight 回寫給主要的 <textarea>,這樣一來就可以讓 <textarea> 隨著內容的增加或減少而自動調整高度。

Element Plus 與 Vuetify 的主要差異是,Element Plus 使用單例模式設計,所有設有 autosize<textarea> 都共用一個 hiddenTextarea;而 Vuetify 則是每個設定有 autoGrow<textarea> 都會有自己的 hiddenTextarea

Nuxt UI 的作法相對簡單,只需要透過 scrollHeightlineHeight 來計算當前行數,並將最終行數限制在 rowsmaxRows 之間。

這裡我們採用 Nuxt UI 的作法。以下是我們現有的資訊:

  • rows<textarea> 的預設行數,也是最小行數。
  • maxRows<textarea> 的最大行數,如果沒有設定則表示沒有上限。

接著我們需要算出當前的 <textarea> 可容納的行數,我們需要知道 <textarea> 每一行的高度與實際可放入內容的高度。

const node = textareaRef.value
const computedStyle = window.getComputedStyle(node);

// 行高
const lineHeight = parseFloat(computedStyle.lineHeight)

// 真實可容納內容的高度
const height =
  node.scrollHeight -
  parseFloat(computedStyle.paddingTop) -
  parseFloat(computedStyle.paddingBottom);

這樣我們就能計算出當前的高度可以容納多少行。

const newRows = Math.floor(height / lineHeight);

整理後寫出以下函數。記得!我們在每次計算時,都需要先將行數復原為最小行數(rows),然後再計算出新的行數,否則當內容減少時,行數不會跟著縮減。

const textareaRef = ref<HTMLTextAreaElement>();

const calcTextareaHeight = () => {
  if (!props.autosize) return;

  const node = textareaRef.value;
  if (!node) return;

  node.rows = Number(props.rows);

  const { paddingSize, lineHeight } = getSizingStyle(node);

  node.style.overflow = 'hidden';

  const newRows = clamp(
    (node.scrollHeight - paddingSize) / lineHeight,
    node.rows,
    Number(props.maxRows ?? Infinity)
  );

  node.rows = newRows;
  node.style.overflow = '';
};

在計算高度時,我們需要先將 overflow 設定為 hidden 以排除滾動條的影響,這樣才能正確計算出 scrollHeight。計算完畢後,再將 overflow 設回空字串,讓 <textarea> 正常顯示。

接下來就是更新時機。我們需要在 onMounted 時計算一次初始高度。

onMounted(() => {
  calcTextareaHeight();
});

此外,每當使用者更新內容時,我們也需要重新計算高度。這裡有兩個計算高度的時機點可以選擇:

  1. 使用 watch 觀察 modelValueLocal 的變化,當 modelValueLocal 改變時重新計算。
  2. 使用 <textarea>input 事件來觸發重新計算。

第一種方式是觀察 modelValueLocal 的變化:

watch(modelValueLocal, calcTextareaHeight);

但這會遇到一個問題,如果開發人員在使用時選用了 v-model.lazy,那麼使用者在輸入內容時,只要不觸發 <textarea>change 事件,modelValueLocal 就不會改變,這樣也就無法觸發重新計算。

AtomicTextarea 的 autosize 在 v-model.lazy 上的問題

所以我們選擇第二種方式,使用 <textarea>input 事件來觸發重新計算:

<textarea
  ...
  @input="() => calcTextareaHeight()"
/>

這樣我們就得到了隨內容增減自動調整高度的 <textarea>

不過我們還有一些情境需要處理,例如 <textarea> 大小發生變化後可能會影響到畫面上需要顯示的行數,這時也需要重新計算高度。

我們利用在 <AtomicScrollbar> 實作中寫的 createResizeObserver 來監聽 <textarea> 的大小變化。

let unobserve: (() => void) | null = null;
watch(textareaRef, (node) => {
  unobserve?.();

  if (!node) return;
  const { observe } = createResizeObserver();
  unobserve = observe(node, calcTextareaHeight);
})

onBeforeUnmount(() => {
  unobserve?.();
  unobserve = null;
});

這樣一來,我們就能在 <textarea> 發出 input 事件或寬高變化時重新計算高度。另外由於 ResizeObserver 的特性,在初始化時也會觸發一次 calcTextareaHeight,因此我們不再需要在 onMounted 時再額外計算高度。

AtomicTextarea 的 autosize 完成

使用純 CSS 的 Autosize

目前在一些比較新的瀏覽器中,我們可以使用 CSS 的 field-sizing: content;,不需要 JavaScript 就能實現 <textarea> 的 autosize 的功能。

⚠️ WARNING
field-sizing 目前仍然為實驗性功能,使用前需注意瀏覽器支援度。
field-sizing 瀏覽器支援度

html

<textarea>

scss

// font-size * line-height
$one-line-height = 16px * 1.15

textarea {
  field-sizing: content;
  min-height: $one-line-height * 3; // 最小 3 行
  max-height: $one-line-height * 5; // 最多 5 行
  width: 300px;
  padding: 12px;
}

這樣就可以不依賴 JavaScript 完成 Autosize 的功能了!

使用 CSS field-sizing 實作的 Autosize

總結

<AtomicTextarea> 的實作與 <AtomicTextField> 非常相近,除了一些 UI 上的細節需要額外注意,其他部分沒有太大差異,因此在這裡就沒有過多著墨於 <AtomicTextField> 已經實作的內容。

<AtomicTextarea><AtomicTextField> 最大的功能差異在於 autosize,這裡參考了 Nuxt UI 的作法。相較於 Element Plus 與 Vuetify,Nuxt UI 的解決方案更為簡單。我們只需取得 <textarea>scrollHeightlineHeight,就可以計算出當前行數,並將其限制在 rowsmaxRows 之間。

儘管 Nuxt UI 的作法簡單,但也因為不需要更新多個 <textarea>,效能表現不僅沒有比較差反而更出色了!或許很多時候不一定要很複雜的作法或很高深的技巧,簡單的作法也能有很好的效能表現。

參考資料


上一篇
[為你自己寫 Vue Component] AtomicTextField
下一篇
[為你自己寫 Vue Component] AtomicSelect
系列文
為你自己寫 Vue Component30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言