引言
在 iOS 上用拼音输入时,句子里老是莫名多出空格?这不是你手滑,多半是这些锅:键盘把空格当选词键、系统的 自动纠错/自动空格 在作怪、App 的输入框开了 autocorrect / smart spacing、或第三方输入法的联想设置在插空。结果就是——你明明只是在连打拼音,iOS 却“很贴心”地给你塞空格,标点一换行还可能再插一个。接下来只要关掉相关自动化或把输入框属性设对,基本就能止血。
为什么会出现
IOS特有问题,称为合并输入
-
事件时序跟普通键入不一样
合成输入是:compositionstart→ 多次compositionupdate→(候选还在变)→compositionend→ 才触发一次“最终”的input。
如果你在合成阶段(还没compositionend)里改了 input 的 value,IME 会被打断,当前“中间态”字符串被强行上屏,有时还会带上 NBSP(\u00A0)或空格。 -
iOS WebKit 的处理细节
iOS 拼音在合成时会临时使用不可见空格/窄空格作为分词占位。你在合成期动了 value 或光标,WebKit 可能把这些占位符也写进最终值,于是就看到“莫名的空格”。 -
框架/指令的“同步写回”
v-model.trim、在@input里this.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');
})();
用你发财的小手点点赞吧 🙌🙌🙌