紀錄工作經驗、相關知識,解決技術相關問題。

Javascript, React, 工作雜談, 新手 Jamp Holes, 網站相關

JavaScript 表單互動:使用 selectionStart 與 setSelectionRange 控制游標位置

JavaScript 表單互動:使用 selectionStart 與 setSelectionRange 控制游標位置 特色圖片

在前端表單互動與富文本編輯器開發中,精準控制輸入框的焦點與游標位置是提升使用者體驗的關鍵環節。當我們需要掌握使用者的輸入焦點、選取特定文字區段,或是動態調整游標在文字方塊中的落點時,必須妥善運用 DOM 的 focus() 方法、selectionStart 與 selectionEnd 屬性,以及 setSelectionRange() 方法來達成。本文將透過 DOM focus 機制完整整理相關的實作方法、座標運算技巧,以及表單互動中的防呆機制與常見排錯方案。

DOM focus() 與輸入焦點控制

在網頁表單中要讓使用者直接開始輸入,主要會透過 focus() 方法將 DOM focus 轉移至指定的輸入框元素上:

const inputElement = document.querySelector('#username');
inputElement.focus();

這項操作在使用者開啟對話框、表單驗證未過需要自動聚焦於錯誤欄位,或點擊自訂按鈕時非常實用。然而,單純呼叫 focus() 只會將游標放置在該輸入框當前記憶的最後一個字元後方,若需要更精細地控制游標停放位置或選取特定文字,則需搭配屬性來處理。

selectionStart 與 selectionEnd 取得游標與選取範圍

當使用者在 <input> 或 <textarea> 中點擊或選取文字時,瀏覽器會透過 selectionStart 與 selectionEnd 屬性記錄目前的游標索引或文字選取範圍。

請注意確保語法拼寫正確,正確的屬性寫法為:

const input = document.querySelector('#content');

// 取得目前游標所在的起始索引位置
const cursorStart = input.selectionStart;

// 取得目前選取文字的結束索引位置
const cursorEnd = input.selectionEnd;

console.log(`游標起始位置:${cursorStart},結束位置:${cursorEnd}`);

若兩個屬性回傳的數值相同,代表此時畫面中沒有選取任何文字,該數值即為純粹的「游標位置」;若數值不同,則代表使用者選取了從 selectionStart 到 selectionEnd 之間的字串區段。

setSelectionRange() 設定游標與選取區段

若需要在程式碼中動態將游標移動到指定位置,或者反白選取某一段文字,可以呼叫 setSelectionRange(start, end, [direction]) 方法。

const input = document.querySelector('#message');

// 將游標直接移動到文字的最尾端
const length = input.value.length;
input.focus();
input.setSelectionRange(length, length);

// 或者反白選取前 5 個字元
input.focus();
input.setSelectionRange(0, 5);

透過這類彈性的數值設定,開發者可以針對特定表單互動情境(例如點擊按鈕自動全選輸入框內容、在特定字串插入點插入文字),精準控制使用者在輸入時的視覺焦點與游標行為。

表單互動控制規格對照表

為了讓開發者在實作時能更直覺掌握游標與選取範圍的相關屬性與方法,以下整理核心 API 的規格對照表:

屬性或方法型別/參數說明
element.focus()無參數將瀏覽器焦點強制切換至指定的表單輸入元素上。
selectionStart數字 (Number)取得或設定輸入框內目前選取範圍的起始索引(游標位置)。
selectionEnd數字 (Number)取得或設定輸入框內目前選取範圍的結束索引。
setSelectionRange(start, end)兩個整數參數強制設定輸入框的游標位置或文字反白選取區段。

輸入框類型支援度與限制對照表

比較維度文字輸入框 (text, textarea)數值與特殊輸入框 (number, date, checkbox)
selectionStart 支援度完整支援,可精準讀取與設定字元索引。部分瀏覽器不支援或直接拋出 DOMException 異常。
setSelectionRange 運作允許任意設定游標與選取範圍。呼叫時容易引發瀏覽器主控台報錯,無法對非文字型輸入框進行範圍選取。
核心套用場景自動完成選單、標籤輸入、字串插入、游標記憶。僅能使用標準的 focus(),無法精細控制內部數字游標。

在非文字型輸入框誤用 setSelectionRange 導致 DOM 異常

在處理各類表單驗證與互動時,若未先檢查輸入框的型別,直接呼叫游標選取方法容易導致程式崩潰。

  • 踩坑情境:
    工程師寫了一個共用的表單游標控制函式,會自動對傳入的欄位執行 setSelectionRange(0, 0)。當使用者點擊到 type="number" 或 type="date" 的輸入框時,由於這些特殊的瀏覽器原生控制元件不支援文字選取範圍 API,瀏覽器主控台會直接噴出 DOMException: The input element's type ('number') does not support selection 錯誤。
  • 正確排錯與實作解法:
    在呼叫相關屬性前,必須先透過條件判斷過濾支援的輸入型別,確保只有文字與多行文字區域才執行游標控制:
function safeSetCursor(inputElement, position) {
  // 檢查元素是否存在且型別支援選取範圍
  const supportedTypes = ['text', 'search', 'url', 'tel', 'password', 'textarea'];
  
  if (inputElement && supportedTypes.includes(inputElement.type)) {
    inputElement.focus();
    inputElement.setSelectionRange(position, position);
  } else {
    console.warn('當前輸入框型別不支援游標區段設定:', inputElement?.type);
    inputElement?.focus();
  }
}

動態插入字串時游標位置跑掉的問題與解法

在開發留言板、聊天室或富文本輸入輔助工具時,常見的需求是在使用者當前游標停駐的位置插入特定的表情符號或範本文字。

  • 踩坑情境:
    當使用者在輸入框中間的某個位置(例如第 10 個字元後)點擊插入按鈕,若程式碼直接透過 input.value += '表情符號' 進行字串拼接,會導致新加入的內容直接被塞到整段文字的最尾端,破壞使用者的預期體驗。
  • 排錯與防呆解法:
    必須結合 selectionStart、selectionEnd 以及字串切割(substring)來精準計算插入點:
function insertTextAtCursor(inputElement, textToInsert) {
  const start = inputElement.selectionStart;
  const end = inputElement.selectionEnd;
  const val = inputElement.value;
  
  // 組合插入新文字前後的字串
  inputElement.value = val.substring(0, start) + textToInsert + val.substring(end);
  
  // 將游標移動到剛插入文字的結尾處
  const newCursorPos = start + textToInsert.length;
  inputElement.focus();
  inputElement.setSelectionRange(newCursorPos, newCursorPos);
}

非同步渲染畫面與 focus 焦點丟失的競態條件(Race Condition)

在現代前端框架(如 React 或 Vue)中,若直接以原生 DOM 操作修改 input.value 並呼叫 setSelectionRange,會遭遇 Virtual DOM 重新渲染時覆蓋游標位置的競態條件。這是因為框架的狀態更新是透過非同步排程進行的,當資料變動觸發重新渲染時,原先手動設定的游標位置常會被框架的更新覆蓋而彈回結尾或原點。

此外,在行動裝置(如 iOS Safari 或 Android Chrome)中,非同步觸發的 focus() 或 setSelectionRange() 常常因為沒有直接繫結在使用者觸控事件的呼叫堆疊內而被瀏覽器安全機制攔截,導致虛擬鍵盤彈不出或焦點丟失。

1. 現代前端框架狀態與原生 DOM 操作衝突分析

  • 雙向繫結 / 狀態覆蓋衝突(Virtual DOM 競態條件):
    工程師在事件處理函式中直接用原生 DOM 操作 input.value = ... 並呼叫 setSelectionRange() 想要強制調整游標;然而在下一個 Microtask 或框架狀態更新(State Update)時,框架偵測到資料流變動,直接觸發 Virtual DOM 重新渲染(Re-render),把輸入框的 value 重新用 State 覆蓋過去。這會導致游標瞬間被強制彈回字串最尾端,或在輸入中文(注音/拼音)組字過程中造成游標亂跳、選取範圍錯亂。
  • 行動裝置非同步觸發 focus() 遭到瀏覽器阻擋:
    在使用者點擊按鈕後,程式碼透過 setTimeout 或者是等待 API 回傳資料後才呼叫 input.focus()。在桌機瀏覽器通常能順利聚焦,但在 iOS Safari 的嚴格安全機制下,非同步呼叫堆疊(Call Stack)脫離了使用者的直接觸控(Touch/Click)事件,導致瀏覽器判定為「非自主聚焦」而直接攔截,虛擬鍵盤彈不出來,游標控制也隨之失效。

2. 實務排錯案例與程式碼範例

針對上述框架衝突與非同步競態條件,以下提供具體的安全處理與排錯解法:

// 【實務排錯範例】在 React / Vue 中安全地處理非同步焦點與游標定位
function handleAsyncFocusAndCursor(inputElement, newText, targetCursorPos) {
  if (!inputElement) return;

  // 1. 確保焦點轉移包在使用者互動的同步呼叫堆疊中
  inputElement.focus();

  // 2. 針對現代框架狀態更新的非同步特性,利用 setTimeout 或 nextTick 延遲調整游標
  setTimeout(() => {
    // 再次確認元素仍存在於 DOM 中
    if (document.activeElement !== inputElement) {
      inputElement.focus();
    }

    // 3. 檢查型別並安全設定游標範圍
    const supportedTypes = ['text', 'search', 'url', 'tel', 'password', 'textarea'];
    if (supportedTypes.includes(inputElement.type)) {
      inputElement.setSelectionRange(targetCursorPos, targetCursorPos);
    }
  }, 0);
}
  • 架構師重點解析:
    • 在現代框架中,若要避免游標被狀態覆蓋,解決方法是將游標與選取範圍的調整交由框架的生命週期處理(例如 React 的 useEffect 搭配依賴項,或 Vue 的 nextTick)。
    • 若必須透過原生 DOM 介入,利用 setTimeout(..., 0) 將游標調整推遲到事件迴圈(Event Loop)的巨任務(Macrotask)執行,能有效避開框架當前渲染週期的狀態覆蓋衝突。

以下為 React 與 Vue 的實際生命週期整合範例:

在 React 中應透過 useEffect 搭配狀態與 requestAnimationFrame,確保畫面完成 DOM 繪製後才安全呼叫焦點與游標設定:

import React, { useState, useEffect, useRef } from 'react';

function ControlledInputComponent({ externalValue }) {
  const [value, setValue] = useState(externalValue);
  const inputRef = useRef(null);

  useEffect(() => {
    // 當狀態更新且 DOM 繪製完成後,安全地鎖定游標位置
    const timer = setTimeout(() => {
      if (inputRef.current) {
        inputRef.current.focus();
        const len = inputRef.current.value.length;
        inputRef.current.setSelectionRange(len, len);
      }
    }, 50);

    return () => clearTimeout(timer);
  }, [value]);

  return (
    <input
      ref={inputRef}
      type="text"
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

而在 Vue 3 的開發中,則可利用 nextTick 確保 DOM 更新完畢後再執行游標控制:

import { ref, nextTick } from 'vue';

const inputValue = ref('');
const inputRef = ref(null);

const updateAndFocus = async (newText) => {
  inputValue.value = newText;
  
  // 等待 Vue 完成 DOM 渲染
  await nextTick();
  
  if (inputRef.value) {
    inputRef.value.focus();
    const len = inputRef.value.value.length;
    inputRef.value.setSelectionRange(len, len);
  }
};

常見輸入框游標控制問題排查與解決方案

瀏覽器自動填寫(Autofill)後游標與選取狀態異常的原因與判斷機制

在實作表單時,瀏覽器內建的密碼管理員或自動填寫功能經常會在頁面載入後自動將儲存的帳密帶入欄位中。

  • 常見情境與技術盲點:
    當瀏覽器自動填寫表單時,雖然畫面上有文字呈現,但開發者在 DOMContentLoaded 事件中綁定的游標控制或格式化邏輯可能無法正常觸發,或者因為自動填寫觸發的時間點晚於頁面載入完成,導致游標停留在非預期的位置。
  • 排查與防呆改寫機制:
    若需要針對自動填寫後的欄位進行游標調整或格式化檢查,建議監聽 input 或 change 事件,並搭配防手震機制進行安全處理:
const passwordInput = document.querySelector('#password');

passwordInput.addEventListener('input', (event) => {
  // 檢查是否為瀏覽器自動填寫觸發的數值變動
  if (event.inputType === 'insertReplacementText' || !event.inputType) {
    console.log('偵測到自動填寫或外部插入,調整游標至結尾');
    const len = passwordInput.value.length;
    passwordInput.setSelectionRange(len, len);
  }
});

手動聚焦與行動裝置虛擬鍵盤彈出時機衝突的排查與建議

在手機或平板等行動裝置上,表單互動的體驗往往受到虛擬鍵盤彈出機關的影響。

  • 機制對照與差異:
    • 桌機瀏覽器:呼叫 focus() 與 setSelectionRange() 會瞬間完成,游標與反白區段立即生效。
    • 行動裝置:當網頁以程式碼強制呼叫 focus() 時,作業系統會同時觸發虛擬鍵盤彈出,這會導致視窗捲動與排版重新計算。若在此時立刻進行複雜的游標選取或範圍計算,容易被鍵盤動畫打斷。
  • 排查建議與複製範本:
    在行動端進行表單游標控制時,建議加上適當的微幅延遲,等待行動裝置虛擬鍵盤穩定彈出後再執行游標定位:
function mobileSafeFocus(inputElement) {
  if (!inputElement) return;
  
  // 針對行動端預留動畫與視窗調整時間
  setTimeout(() => {
    inputElement.focus();
    const len = inputElement.value.length;
    try {
      inputElement.setSelectionRange(len, len);
    } catch (e) {
      console.warn('此行動裝置輸入框不支援範圍設定', e);
    }
  }, 100);
}

參考資料與延伸閱讀

發表迴響