自從 IE 登出,Trident 版 Edge 被放生,瀏覽器重回大一統時代。有別於前次的 IE 王朝,當今主宰前端的 Chromium 配備的 HTML / JavaScript / CSS 規格完備,功能與二十年前不可同日而語,且會自動更新不用太擔心支援問題,現在寫前端輕鬆許多。

2016 加入的自訂 HTML 元素功能深得我心,自訂元素這檔事幾乎所有前端框架都有自己的玩法,我玩過 knockout.jsAngularJSVue.js,每個都有,甚至早在 IE6 時代,微軟也有自家的網頁元件技術 - .htc HTML Component。自訂網頁元件,易於設計維護擴充重用,就像在前端 UI 實踐物件導向設計,好處很多。如今瀏覽器原生支援自訂網頁元素,不需依賴任何框架,效能更佳,運用起來更靈活。

UI 元素行為封裝成自訂 HTML 元素,最大好處是享受物件導向設計的低耦合與觀注點分離,大大提高程式碼可重用性及可維護性。原生 HTML 自訂元素之前曾玩過一回,做了會滾動的骰子,這回遇到另一個小需求,我想寫過自帶 X 鈕可清空內容的文字輸入框。

要做個帶 X 鈕的文字輸入框不難,一個 <input type="text"> 配上用 CSS 排版及自動隱藏的 <button> 就可以搞定,但我有個額外需求是想支援 Vue.js 的 v-model 繫結,讓它可以當成一般的 <input type="text"> 使用。

先展示成果:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>自帶清除鈕文字欄位 Web Component (支援 Vue v-model)</title>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <script src="textInputWithClearBtn.js"></script>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 20px;
        }
        h2 {
            color: #444;
        }
        input-with-clear-button {
            margin-bottom: 10px;
            display: block;
        }
    </style>
</head>
<body>
    <h2>自帶清除鈕文字欄位</h2>
    <input-with-clear-button id="t1" placeholder="Type something..."></input-with-clear-button>
    <input-with-clear-button id="t2" value="Hello World"></input-with-clear-button>
    <button onclick="showValues()">Check Values</button>
    <pre id="results">
    </pre>
    <script>
        function showValues() {
            document.getElementById('results').textContent = 
`t1: "${document.getElementById('t1').value}"
t2: "${document.getElementById('t2').value}"`;
        }
    </script>
    <hr>
    <div id="app">
        <h2>配合 Vue3 v-model 使用</h2>
        <input-with-clear-button v-model="searchText" placeholder="Search..."></input-with-clear-button>
        <p>Vue Data Value: {{ searchText }}</p>
        <button @click="searchText = 'Hello Vue3'">Set Value</button>
        <button @click="searchText = ''">Clear</button>
    </div>

    <script>
        const { createApp } = Vue;
        createApp({
            data() {
                return {
                    searchText: 'Init Value'
                }
            }
        }).mount('#app');
    </script>
</body>
</html>

以上網頁展示單獨使用 <input-with-clear-button>,使用 document.getElementById('t1').value 取值,以及 <input-with-clear-button v-model="searchText" placeholder="Search..."> 以 v-model 整合 Vue.js MVVM,兩種玩法都支援:

<input-with-clear-button> 定義我寫在獨立 textInputWithClearBtn.js,補充幾個小細節:

  • IIFE 把定義元件的邏輯包起來,可避免其變數名稱跟其他程式打架。
  • 最早我想到的做法是在 HTML 模版內嵌 <style>指定樣式,缺點是每次產生元件 HTML 會重複一次樣式宣告,累贅又沒效率。改用 new CSSStyleSheet()、.replaceSync() 定義 CSS 樣式,HTML 元件透過 adoptedStyleSheets() 共用樣式,優雅多了。
  • observedAttributes() 配合 attributeChangedCallback(name, oldValue, newValue) 可在 Attribute 異動時觸發處理邏輯。
  • 實測實作 get value() / set value() 後,<input type="text"> 的按鍵動作便會觸發 Vue v-model 繫結,但按清除鈕 input.value = '' 時需另外呼叫 this.dispatchEvent(new Event('input', { bubbles: true })); 模擬 input 事件。
(function() {
    // 建立共用的 CSSStyleSheet
    const sharedStyles = new CSSStyleSheet();
    sharedStyles.replaceSync(`
        .text-input-w-clr-btn {
            position: relative;
            display: inline-block;
            margin: 10px 0;
            input {
                padding: 8px 30px 8px 10px;
                font-size: 14px;
                border: 1px solid #ccc;
                border-radius: 4px;
                outline: none;
                min-width: 250px;
                &:focus { border-color: #4CAF50; }
                &:not(:placeholder-shown)~.clear-btn {
                    display: block;
                }
            }

            .clear-btn {
                position: absolute;
                right: 8px;
                top: 50%;
                transform: translateY(-50%);
                background: none;
                border: none;
                cursor: pointer;
                color: #999;
                font-size: 18px;
                padding: 0;
                width: 20px;
                height: 20px;
                line-height: 20px;
                text-align: center;
                display: none;
                &:hover { color: #333;}
            }
        }             
        `);

    class CustomInput extends HTMLElement {
        // 宣告要監聽的屬性
        static get observedAttributes() {
            return ['value'];
        }

        constructor() {
            super();
            this.attachShadow({ mode: 'open' });
            this.shadowRoot.adoptedStyleSheets = [sharedStyles];
        }
        // 元素插入 DOM 時觸發
        connectedCallback() {
            // 取得 placeholder 屬性值,若無則使用預設值
            const placeholder = this.getAttribute('placeholder') || 'Enter text...';
            const initialValue = this.getAttribute('value') || '';
            
            // 產生元件 HTML
            this.shadowRoot.innerHTML = `
                    <div class="text-input-w-clr-btn">
                        <input type="text" placeholder="${placeholder}" value="${initialValue}">
                        <button class="clear-btn" type="button">×</button>
                    </div>
                `;

            const input = this.shadowRoot.querySelector('input');
            const clearBtn = this.shadowRoot.querySelector('.clear-btn');

            clearBtn.addEventListener('click', () => {
                input.value = '';
                input.focus();
                this.dispatchEvent(new Event('input', { bubbles: true }));
            });
        }

        // 元素從 DOM 移除時觸發
        disconnectedCallback() {

        }

        // 當屬性變更時同步更新 input 值
        attributeChangedCallback(name, oldValue, newValue) {
            if (name === 'value' && this.shadowRoot) {
                const input = this.shadowRoot.querySelector('input');
                if (input && input.value !== newValue) {
                    input.value = newValue || '';
                }
            }
        }

        // 提供 value 的 getter/setter 供 Vue3 使用
        get value() {
            return this.shadowRoot?.querySelector('input')?.value || '';
        }

        set value(val) {
            const input = this.shadowRoot?.querySelector('input');
            input && (input.value = val || '');
        }
    }

    customElements.define('input-with-clear-button', CustomInput);
})();

附上線上展示

註:用原生自訂 HTML 元素跟 Vue 整合,訴求是彈性,可單獨使用不一定綁死 Vue;但若開發環境確定有 Vue 且元件行為跟 Vue 緊密融合,則建議用 Vue 寫自訂元件比較省力。

Showcases a native custom HTML input element with a clear (X) button, supporting both standalone use and Vue v-model binding for better reusability and maintainability.


Comments

Be the first to post a comment

Post a comment