IOS 拼音输入怎么有空格!!!

205 阅读3分钟

引言

在 iOS 上用拼音输入时,句子里老是莫名多出空格?这不是你手滑,多半是这些锅:键盘把空格当选词键、系统的 自动纠错/自动空格 在作怪、App 的输入框开了 autocorrect / smart spacing、或第三方输入法的联想设置在插空。结果就是——你明明只是在连打拼音,iOS 却“很贴心”地给你塞空格,标点一换行还可能再插一个。接下来只要关掉相关自动化或把输入框属性设对,基本就能止血。

1.gif

为什么会出现

IOS特有问题,称为合并输入

  • 事件时序跟普通键入不一样
    合成输入是:compositionstart → 多次 compositionupdate →(候选还在变)→ compositionend → 才触发一次“最终”的 input
    如果你在合成阶段(还没 compositionend)里改了 input 的 value,IME 会被打断,当前“中间态”字符串被强行上屏,有时还会带上 NBSP(\u00A0)或空格

  • iOS WebKit 的处理细节
    iOS 拼音在合成时会临时使用不可见空格/窄空格作为分词占位。你在合成期动了 value 或光标,WebKit 可能把这些占位符也写进最终值,于是就看到“莫名的空格”。

  • 框架/指令的“同步写回”

    • v-model.trim、在 @inputthis.value = this.value.trim()、或做正则替换——合成期执行这些会触发上面的打断。
    • React/Vue 自定义组件若在 input 冒泡时立刻 setState/emit 回去改 value,同样会把中间态(含空格)固化。
  • contenteditable 更容易踩坑
    在合成期往 DOM 里插节点/改 innerHTML,IME 直接“提交”当前片段,残留空格/标点。

解决代码

直接将以下代码贴到index.html中,直接一劳永逸。

      (function installGlobalInputGuards() {
        // 可覆盖类型:文本类 input & textarea
        const TARGET_SELECTOR = 'input:not([type=number]):not([type=range]):not([type=file]):not([type=checkbox]):not([type=radio]), textarea';

        // 每个元素的合成态标记
        const composing = new WeakMap();

        // 统一清洗:nbspace -> space;可选收尾空格;可选去中间空格(对账号/邮箱等)
        function sanitize(value, { trimEnd = true, collapseInner = false } = {}) {
          if (value == null) return '';
          let v = String(value).replace(/\u00A0/g, ' '); // NBSP -> space
          if (collapseInner) v = v.replace(/\s+/g, ' '); // 多空格合一(可选)
          if (trimEnd) v = v.replace(/\s+$/g, '');       // 去尾空格
          return v;
        }

        // 针对不同字段的默认策略
        function getPolicy(el) {
          // data-space-mode 可覆盖:none | trim-end | collapse | preserve
          // - none:去掉所有空白
          // - trim-end:只去尾部(默认)
          // - collapse:内部多空格合并 + 去尾部
          // - preserve:完全不处理
          const mode = (el.getAttribute('data-space-mode') || '').toLowerCase();

          // 针对常见类型的推荐
          const type = (el.getAttribute('type') || '').toLowerCase();
          const name = (el.getAttribute('name') || '').toLowerCase();

          // 默认
          let policy = { trimEnd: true, collapseInner: false, stripAll: false };

          if (mode === 'preserve') return { ...policy, trimEnd: false };
          if (mode === 'none')     return { trimEnd: true, collapseInner: true, stripAll: true };
          if (mode === 'collapse') return { trimEnd: true, collapseInner: true };
          if (mode === 'trim-end') return { trimEnd: true, collapseInner: false };

          // 智能推断:账号/邮箱/手机号/验证码等
          if (['email','url','search','tel','password'].includes(type)) {
            policy.collapseInner = true;
          }
          if (name.includes('email')) {
            policy.collapseInner = true;
          }
          if (name.includes('phone') || name.includes('mobile') || type === 'tel') {
            policy.collapseInner = true;
          }
          if (name.includes('code') || name.includes('otp') || name.includes('captcha')) {
            policy.stripAll = true; // 验证码去掉所有空白
          }
          return policy;
        }

        // —— 关键 1:拦截 IME 合成提交造成的英文分词(insertFromComposition)——
        document.addEventListener('beforeinput', (e) => {
          const el = e.target;
          if (!(el instanceof HTMLElement) || !el.matches(TARGET_SELECTOR)) return;

          // 仅处理“合成结果插入”的时机
          if (e.inputType === 'insertFromComposition' && typeof e.data === 'string') {
            // 如果是纯英文 + 空格(典型:拼音把 kongge 变成 "kong ge")
            if (/^[a-z\s]+$/i.test(e.data)) {
              e.preventDefault();
              const data = e.data.replace(/\s+/g, ''); // 去掉中间空格
              const start = el.selectionStart ?? el.value.length;
              const end   = el.selectionEnd   ?? el.value.length;
              el.setRangeText(data, start, end, 'end');
            }
          }
        }, { capture: true });

        // —— 关键 2:跟踪合成态,阻止合成中误触发提交/失焦 —— 
        document.addEventListener('compositionstart', (e) => {
          const el = e.target;
          if (!(el instanceof HTMLElement) || !el.matches(TARGET_SELECTOR)) return;
          composing.set(el, true);
        });

        document.addEventListener('compositionend', (e) => {
          const el = e.target;
          if (!(el instanceof HTMLElement) || !el.matches(TARGET_SELECTOR)) return;
          composing.set(el, false);
          // 合成刚结束,顺手做一次轻清洗(去掉尾空格 & nbsp)
          const p = getPolicy(el);
          el.value = p.stripAll ? el.value.replace(/\s+/g,'') : sanitize(el.value, p);
        });

        // —— 关键 3:blur / change / submit 时做统一清洗 —— 
        function cleanElement(el) {
          const p = getPolicy(el);
          el.value = p.stripAll ? el.value.replace(/\s+/g,'') : sanitize(el.value, p);
        }

        document.addEventListener('blur', (e) => {
          const el = e.target;
          if (!(el instanceof HTMLElement) || !el.matches(TARGET_SELECTOR)) return;
          if (composing.get(el)) {
            // 合成中不要丢焦(iOS 上“完成”常伴随 blur)
            e.preventDefault?.();
            setTimeout(() => el.focus(), 0);
            return;
          }
          cleanElement(el);
        }, true);

        document.addEventListener('change', (e) => {
          const el = e.target;
          if (!(el instanceof HTMLElement) || !el.matches(TARGET_SELECTOR)) return;
          cleanElement(el);
        }, true);

        document.addEventListener('submit', (e) => {
          const form = e.target;
          if (!(form instanceof HTMLFormElement)) return;
          // 提交前清洗所有受控输入
          const inputs = form.querySelectorAll(TARGET_SELECTOR);
          inputs.forEach(el => {
            if (composing.get(el)) e.preventDefault(); // 合成中禁止提交
            else cleanElement(el);
          });
        }, true);

        // 可选:给 body 标记,方便排查是否已安装
        document.documentElement.setAttribute('data-global-input-guards', 'on');
      })();

用你发财的小手点点赞吧 🙌🙌🙌