前端 Javascript 限制 input 中英文字數

Lastor
Code 隨筆放置場
9 min readFeb 26, 2023

筆記一下最近碰到的需求:

要如何在一個表單 input / textarea 上限制字數?

要討論這個,勢必就不是 String.length 這麼單純的事情 (笑)。HTML 上的 maxLength 屬性也是只計算 length。

現在是國際化的時代,一段文字穿插中英文或其他語言是很常見的事情。如果單純以中文為基準去限制字數,就會出現英文打不了幾個字的問題。

// 例如限制 5 個中文字, 中文當然沒問題
"哈囉你好嗎".length // 5

// 寫英文的話, 才一個單字就爆了
"Hello".length // 5

所以一般都會傾向用「字元數」來判斷。但「字元數」這個詞有點不太精確,爬了很多篇文章,有些人的字元數是指 bytes,而有些人則是單純指「字數」。為了避免混淆,這篇將統一用 bytes 來做描述。

中文字到底佔了幾 bytes?

現在大多人的印象中,中文字都是 2 bytes 英文字母則是 1 bytes,這其實是不太精確的。這是因為早期萬國碼 Unicode 尚未盛行,各地都是用各自的 local 編碼。

像是繁體中文有 Big5,簡體中文有 GBK、GB2312,日文則是 Shift_JIS 等等。在這些編碼體系中,「中文漢字」都是佔了 2 bytes,而這習慣一直延續到了今天,仍然大多人記憶中都會認為中文就是 2 bytes。

但是,現在 Unicode 萬國碼體系的 UTF-8 已經成為主流,而在 UTF-8 裡面,一個中文字其實是佔 3 bytes 的。所以用相關的 API 去計算中文的 bytes 數,如果還是用 2 bytes 去算,可能就會碰到很多不合時宜的坑。

實作 textarea 字數限制

接下來就以 UTF-8 為前提,中文字佔 3 bytes 的基準來限制 bytes 數。

先上完成的 demo。

這邊使用 petite-vue 來做 data binding,跟 Vue 是同款的操作方式,太基礎就不做解釋了。

先來介紹一下計算字串 bytes 數的方式,這邊使用到瀏覽器的 TextEncoder API。如果是 Node.js 可以用 Buffer 來替代。

/** @param {string} text */
countBytes(text) {
return new TextEncoder().encode(text).length
},

TextEncoder 默認使用 UTF-8 無法更改。透過 TextEncoder.encode() 就能取得一串編碼過的 Uint8Array,而他的 .length 就是 bytes 數。

countBytes('永').length // 3
countBytes('永').byteslength // 3, 與 length 等價
countBytes(' ').length // 3, 全形空格

countBytes('a').length // 1
countBytes(' ').length // 1, 半形空格

能夠計算 bytes 數之後,如果只是單純像下面這樣,判斷超過字數就不儲存,那是不夠的。

// 虛擬碼
onInput(e) {
if (bytesNum > max) return

// update state...
},

因為這種方式只能限制一個字一個字去 input 的情況,如果是複製貼上時超出限制,就會整段文字都被擋掉。這顯然會是不理想的 UX。

順帶一提,這邊最好是藉助 v-model 去做雙綁,專門負責同步 state。而 onInput 則單純用作 bytes 數驗證,把權責分開,不然用單綁會碰到拼字問題的坑。

注音輸入法在拼字時,每個注音的輸入都會觸發 onInput,這會給計算字數帶來很大的麻煩,得另外用 compositionstartcompositionend 去做輔助判斷,看當前到底是不是在拼字中。簡中的拼音輸入法、日文輸入法之類都會有這問題。用 React 的加油,這很煩的 (笑)。

為了解決「複製貼上」的問題,我們要再另外寫一段算法,去做裁切的動作,而不是無腦擋掉。這邊可以使用 TextEncoder 加上 TextDecoder 的組合技。

/**
* @param {string} text
* @param {number} maxBytes
*/
sliceBytes(text, maxBytes) {
// encode 後去 slice
const unit8 = new TextEncoder()
.encode(text)
.slice(0, maxBytes)

// slice 完再 decode 反解回來
const section = new TextDecoder('utf-8')
.decode(unit8)
.replace(/\uFFFD/g, '') // 去除亂碼

return section
},

透過 TextEncoder().encode() 進行編碼,就能獲得以 bytes 數填充的 Array,只要單純去 .slice() 就能限制不超過 max bytes 數。

然後再用 TextDecoder().decode() 就能轉回的 string。但是要注意,TextDecoder 是可以設定編碼格式的,所以最好是明示一下要用 UTF-8。

最後畫龍點睛的地方是屁股的 .replace()。這是一個數學問題,中文字佔了 3 bytes 的話,中英文混打在配上標點符號、空格之類,這 slice 一刀下去就有可能剛好把最後一個中文字符給腰斬了,反解回 string 就會變成亂碼。所以要用正規 replace 把亂碼給去掉。

現在有了 bytes 數計算,也有了裁切的算法,最後就只要在 onInput 的時候去做判斷 and 裁切就完成了。

onInput() {
if (this.countBytes(this.text) <= this.max) return

const slicedText = this.sliceBytes(this.text, this.max)
this.text = slicedText
},

傳統作法

接下來補充一下傳統作法,如果去 google 「characters count」這方向的關鍵字,大多能查到的都是用正規表達式去做的。用 TextEncoder 的作法,要用 bytes 作為關鍵字才查的到。而這 API 相對也比較新,所以網上大多都是採用前者。

傳統作法都是以 2 bytes 為基準來做的,必經是那年代。不過以前端 UI 角度來說,其實用 2 來算可能會比較合理,因為一個中文字符的 width 約等於兩個英文字母。如果字數限制的目的是針對前端 UI 不要跑版,可能就要評估一下用 2 bytes 算會更好。

計算 bytes 數

首先是 bytes 數計算,這方法是蠻巧妙的數學邏輯,概念上是用正規去抓出所有的中文字,然後把 string.length 疊加上中文的「字數」,就會是 bytes 數 (以中文是 2 bytes 為基準)。

/** @param {string} text */
function countBytes(text) {
// [^\x00-\xff]
// [\u4e00-\u9fa5]
// [\u00ff-\uffff]
// [\u4e00-\u9fcc]
const chineseArr = text.match(/[\u4e00-\u9fff]/g)
const chineseLength = chineseArr?.length || 0
return text.length + chineseLength
}

console.log(byteCount('中文 abc')) // 8

這邊比較頭痛的地方是那個正規,中文字符的範圍到底是多少,網上會查到很多版本,去問 chatGPT 也會微妙的不一樣…… 姑且把我查到的全列出來。

比較特別的是第一個 /[^\x00-\xff]/g,其他的 \u 開頭的都是去抓 Unicode 的區間,差只是差在抓的範圍不同 (包不包含日文漢字之類)。

而這段 [\x00-\xff] 是匹配 ASCII 的字符段,這是英文字母的編碼區間,最前面加個 ^ 是反轉的意思,反轉之後就是非英文的字符。就能模糊判斷他是中文字符。

那如果要用 3 bytes 來算,就把中文長度乘 2 就好。

// Chinese 3 bytes base
return text.length + (chineseLength * 2)

限制 / 裁切字數

傳統作法要裁切就比較麻煩了,概念上是跑迴圈重新填充 text 文本,一個一個字符去檢查是不是中文,是就 count + 2 or count + 3,不是就 count + 1。計數撞到 max 上限就 break。

/**
* @param {string} text
* @param {number} max
*/
function limitText(text, max) {
let count = 0
let result = ''

for (let i = 0; i < text.length; i++) {
const char = text[i]

// 是中文就 +2 不是就 +1
// 除了抓 255 之外也有抓 127 的
count += char.charCodeAt(0) > 255 ? 2 : 1
if (count > max) break

result += char
}

return result
}

console.log(limitText('中文 abc', 6)) // '中文 a'

這範例一樣是以 2 bytes 來算,如果要用 3 bytes 就把三元的地方改成 3 就好。

判斷中文的部分,除了正規之外,也可以用 String.charCodeAt() 來做。他會回傳 Unicode 的編碼值。可以參考 MDN 的說明,0 ~ 255 是拉丁語的範圍,而 0 ~ 127 則是 ASCII 的範圍,在這區間外就能模糊判定他是中文字符。看自己想抓哪個區間來做判斷。

結語

這字數計算是屬於做過才會知道毛很多的玩意,如果未來有哪個 PM 問說,限制個字數為啥要做這麼久? 就把這篇文章砸他臉上吧 (笑)。

這邊還只單純討論到中文 + 英文的字數限制而已,還有一個噁心的鬼東西叫做 Emoji 喔 (眼神死)。

同場加映

之前做過另一種更複雜的情境,不是限制字數,而是要限制在那個 UI 框的範圍內。有興趣的也可以參考參考。

--

--

Lastor
Code 隨筆放置場

Web Frontend / 3D Modeling / Game and Animation. 設計本科生,前遊戲業 3D Artist,專擅日本動畫與遊戲相關領域。現在轉職為前端工程師,以專業遊戲美術的角度涉足 Web 前端開發。