<textarea>
與 <input type="text">
有需多相似之處,甚至大多數的特性都是共通的,例如都可以接收 placeholder
、disabled
、required
、minlength
與 maxlength
等屬性。
儘管 <textarea>
與 <input type="text">
有許多相似之處,但它們最大的差異在於 <textarea>
可以接收多行文字,而 <input type="text">
只能接收單行文字。<textarea>
還有一些特殊的屬性,例如 rows
與 cols
,可以用來設定 <textarea>
的寬度與高度。一般情況下,我們可以通過點擊並拖曳右下角來調整 <textarea>
的大小。
在開始實作前,我們先研究各個 UI Library 的 Textarea 元件是如何設計的。
Element Plus
<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
<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
<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 裡面,如 trim
、number
與 lazy
修飾符。showCount
,開啟顯示目前輸入的字數,但只對輸入為字串時有效。autosize
,讓 <textarea>
可以隨著內容的增加或減少而自動調整高度。rows
與 maxRows
,分別表示最小行數與最大行數。<textarea>
的各種設定,如:placeholder
、disabled
、readonly
、required
、maxlength
、minlength
。append
與 prepend
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
功能上。
這裡我們簡單分析一下各個 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 的作法相對簡單,只需要透過 scrollHeight
與 lineHeight
來計算當前行數,並將最終行數限制在 rows
與 maxRows
之間。
這裡我們採用 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();
});
此外,每當使用者更新內容時,我們也需要重新計算高度。這裡有兩個計算高度的時機點可以選擇:
watch
觀察 modelValueLocal
的變化,當 modelValueLocal
改變時重新計算。<textarea>
的 input
事件來觸發重新計算。第一種方式是觀察 modelValueLocal
的變化:
watch(modelValueLocal, calcTextareaHeight);
但這會遇到一個問題,如果開發人員在使用時選用了 v-model.lazy
,那麼使用者在輸入內容時,只要不觸發 <textarea>
的 change
事件,modelValueLocal
就不會改變,這樣也就無法觸發重新計算。
所以我們選擇第二種方式,使用 <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
時再額外計算高度。
目前在一些比較新的瀏覽器中,我們可以使用 CSS 的 field-sizing: content;
,不需要 JavaScript 就能實現 <textarea>
的 autosize 的功能。
⚠️ WARNING
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 的功能了!
<AtomicTextarea>
的實作與 <AtomicTextField>
非常相近,除了一些 UI 上的細節需要額外注意,其他部分沒有太大差異,因此在這裡就沒有過多著墨於 <AtomicTextField>
已經實作的內容。
而 <AtomicTextarea>
與 <AtomicTextField>
最大的功能差異在於 autosize
,這裡參考了 Nuxt UI 的作法。相較於 Element Plus 與 Vuetify,Nuxt UI 的解決方案更為簡單。我們只需取得 <textarea>
的 scrollHeight
與 lineHeight
,就可以計算出當前行數,並將其限制在 rows
與 maxRows
之間。
儘管 Nuxt UI 的作法簡單,但也因為不需要更新多個 <textarea>
,效能表現不僅沒有比較差反而更出色了!或許很多時候不一定要很複雜的作法或很高深的技巧,簡單的作法也能有很好的效能表現。
<AtomicTextarea>
原始碼:AtomicTextarea.vue
<AtomicFormField>
實作回顧:[為你自己寫 Vue Component] AtomicFormField
<AtomicTextField>
實作回顧:[為你自己寫 Vue Component] AtomicTextField