上一篇读坐标换算时,里面已经出现好几处 browser.safari、browser.gecko 分支。其实整个 view 阶段读下来,几乎每个模块都散布着这样的判断:输入管线里有,DOMObserver 里有,选区同步里有,剪贴板里也有。这些判断的条件全部来自同一个文件 src/browser.ts,探测集中在一处,补丁落在各模块。这篇把这个文件和它旁边的工具集 src/dom.ts 读完,view 阶段就只剩收官一篇了。参考代码是 prosemirror-view 的 ca4c78e。
系列目录
browser.ts:二十几行的探测
这个文件一共二十几行,没有函数,只有一组模块加载时执行一次的正则和判断,导出一批布尔常量和版本号。探测分两类。
大部分是 UA 嗅探。开头三行把 navigator、document、userAgent 取出来,前面都带 typeof navigator != "undefined" 的防御,保证代码在没有 window 的环境(服务端渲染)里也能安全求值,所有常量落到 false。之后按浏览器家族逐个识别:
- IE 与旧 Edge:三个正则分别匹配
Edge/、MSIE、Trident/...rv:三种 UA 形态,ie是三者的并集,ie_version按形态分别取 documentMode、rv 号和 Edge 号。 - Firefox:
gecko要求 UA 里有 gecko 字样且排除 IE,gecko_version从Firefox/(\d+)取。 - Chrome:匹配
Chrome/(\d+),同样要排除 IE,因为旧 Edge 的 UA 里也带 Chrome 字样。 - Safari:不看 UA 里的 Safari 字样,看
navigator.vendor是否为 “Apple Computer”,因为 Chrome 的 UA 里也带着 Safari 字段。 - iOS:
safari为真,且 UA 含Mobile/或maxTouchPoints > 2。后半个条件把桌面模式的 iPad 也算进 iOS,注释里写明了这个意图。 - mac、windows、android 从 navigator.platform 和 UA 直接匹配。
唯一一处能力探测是 webkit:"webkitFontSmoothing" in document.documentElement.style。webkit 为真时还要回 UA 里用 AppleWebKit/(\d+) 取 webkit_version,后面的剪贴板补丁按这个版本号卡线。选 UA 还是选能力,取决于怪癖本身有没有可探测的 API 痕迹。内核身份有样式属性可查,就查属性;各家 contenteditable 的行为差异没有对应属性,只能按名字和版本分支。
这些常量加载时算一次,之后各模块 import * as browser from "./browser" 直接用,没有运行期开销。
消费方还有一个惯例:把基础 flag 再组合成针对具体怪癖的派生常量,同样只算一次。selection.ts 的 brokenSelectBetweenUneditable 是 safari || chrome && chrome_version < 63,domobserver.ts 的 useCharData 是 ie && ie_version <= 11,input.ts 的 timeoutComposition 是 android ? 5000 : -1,brokenClipboardAPI 是 ie_version < 15 || ios && webkit_version < 604。派生常量顶在各自模块的文件头部,后面的事件处理代码读起来就是「如果浏览器有这个毛病,绕一下」,条件本身的名字已经说明了毛病是什么。
补丁分布回各模块
前几篇读到浏览器分支时都就地解释了机制,这里把它们按模块收拢一遍,看 browser.ts 的输出是怎么被消费的。
input.ts 是消费 flag 最多的模块。举几处典型的:
- Safari 上给编辑区注册一个什么都不做的 input 监听器。注释说这个空处理器能修掉按回车时 composition 消失的问题,具体原因作者也没弄清楚。
- Android Chrome 的 keydown 收到 keyCode 13 直接 return,因为这个回车往往是一套混乱 composition 事件序列的一环,抢跑处理会损坏输入。
- iOS 的 Enter 不能 preventDefault,拦了虚拟键盘会错乱。做法是记下
lastIOSEnter时间戳放行,同时挂一个 200 毫秒的超时,超时后 DOM 仍没变化就用keyEvent(13, "Enter")合成一个键盘事件补发给 handleKeyDown;domchange.ts 读回时认得这个时间戳,会把浏览器真实插入的换行丢弃。 - composition 不活跃多久算结束,
timeoutComposition只在 Android 上是 5000 毫秒,其余平台是 -1,永不超时。 - compositionstart 时有两处按浏览器决定是否要提前结束已有 composition。Chrome 加 Windows 的组合下,光标位于不可编辑节点之前时要把 storedMarks 包成 markCursor 先收尾(注释指向 issue #1500);Gecko 下光标停在带 mark 的节点外侧时,新输入的文本继承不到 mark,要把 DOM 选区往文本节点里挪。
brokenClipboardAPI拼了两个条件:ie_version < 15,或者 iOS 且webkit_version < 604。注释解释了为什么这么粗暴:这两个平台的剪贴板 API 对象和方法都在,就是不工作,运行时又难以测试,只能按版本封死。- 修饰键约定按平台分叉:点选节点的
selectNodeModifier在 Mac 上是 metaKey,其他平台是 ctrlKey;拖拽复制的dragCopyModifier在 Mac 上是 altKey。
domobserver.ts 里的补丁围绕「浏览器连 mutation 都报不准」展开。useCharData 在 ie_version <= 11 时额外监听 DOMCharacterDataModified 事件,因为 IE11 的 MutationObserver 本身不可靠。同样是 IE11,有时 observer 回调先于 DOM 真正更新执行(注释指向 issue #930),对带节点删除的 mutation 直接 flushSoon 提前结算;它对部分插入还会给出错误的 previousSibling 和 nextSibling,有 addedNodes 时从被加入节点自身重新计算前后兄弟。Safari 的问题在表格:在单元格里结束 composition 时会往 TR 里插不合适的节点,检测到 composition 期间 TR 上的 childList 变更就置 badSafariComposition 标记并提前 flush。Gecko 则是读回时多出成对的 BR,added 列表里过滤出两个 BR 时按父子关系删掉一个。
这个文件里还有一处环境检查:每次确认发生了 DOM 变更,checkCSS 会看编辑区的 white-space 计算样式,落在 normal、nowrap、pre-line 这类值上,就警告应该加载包里自带的 prosemirror.css,同时在 Gecko 下把 requiresGeckoHackNode 置上。viewdesc 渲染文本块时拿它判断要不要在末尾补 hack 节点。
选区相关的补丁跨 selection.ts 和 viewdesc.ts 两个文件。brokenSelectBetweenUneditable = browser.safari || browser.chrome && browser.chrome_version < 63,WebKit 系不允许选区落在两个不可编辑块之间,temporarilyEditableNear 的解法是把旁边节点临时改成 contentEditable="true",设完选区再改回去;Safari 下还要顺手把 draggable 关掉,否则这个临时可编辑的节点会被拖动。IE11 选中不可见的光标包装节点时会出现带调整手柄的控件选区,补丁是对节点快速设置再清除 disabled 属性。viewdesc.ts 里设置 DOM 选区时有个 brKludge:Gecko 用 Selection.collapse 把光标放到 BR 后面不总生效,Safari 则是光标视觉位置落后于它报告的位置,两种情况都要换写法绕开。还有一处很小的 iosHacks:Mobile Safari 的列表 marker 会莫名消失,补丁是给 UL/OL 临时改一下 list-style 再改回去,强制浏览器重算样式。
domchange.ts 的补丁长在读回路径上,形状是「识别浏览器的怪异改写,把它修正或丢弃」。Chrome 退格有时会把被删内容替换成一个来路不明的 BR,读回时按 lastKeyCode 是 8 且发现无主的 BR 就收缩变更区间(issues #799、#831)。Safari 删掉列表项或表格单元格里最后一个字符时,会把整个 li 或 td 换成一个直接挂在 ul、ol、tr、table 下的 BR,读回遇到这种 BR 时用 skip 或 ignore 规则挡掉(issues #708、#862)。IE11 在空格前再输入空格时会往前插一个不换行空格,检测到变更内容正好是 ” ” 序列时把变更区间整体前移一位。Chrome 在 composition 期间偶尔会整段删掉再立刻插回,用 change.endB == change.start 识别出这种空变更并记下 lastChromeDelete 时间戳,后续事件拿它判断该不该忽略。Android 虚拟键盘的「回车选候选词」动作会先产生 DOM 变更再移动选区,而 ProseMirror 清理选区后浏览器干脆放弃移动,光标停在错误位置,补丁是从变更里摘掉新段落、事后补发一个模拟回车。
domcoords.ts 上一篇已经展开过,这里只收拢结论:Safari 的 caretRangeFromPoint 在可拖拽元素上返回无意义结果,要向上检查 draggable 链;Gecko 会返回 input 节点内部的 offset,要裁剪;WebKit 点击不可编辑节点右上方时会把光标报到节点之后,要减一修正。空 Range 测量能不能用,这个能力判断也直接由 browser.webkit || browser.gecko 给出。
clipboard.ts 和 index.ts 各有一处代表性补丁。WebKit 复制时会把不换行空格包成 span,Safari 带 Apple-converted-space 类,Chrome 是裸 span,粘贴回来时 restoreReplacedSpaces 把它们还原成普通空格,选择器按 chrome 与否分叉。index.ts 的 hasFocus 在 IE 下要沿 DOM 向上爬,因为光标落在带 resize 手柄的元素里时 activeElement 会是那个元素本身;updateState 里 IE 和 Chrome 在选区上下文变化后需要强制重设 DOM 选区,Chrome 那处还用 trackWrites 追踪选区所在节点有没有被这次更新改写;domSelectionRange 在 Safari 加 shadow DOM 的组合下走 safariShadowSelectionRange,从 shadow root 内部重新推导选区。
capturekeys.ts 兜底方向键和删除键,分支也最密。Mac 上 Emacs 风格的 Ctrl-h/d/b/f/p/n 被映射成删除和方向键,整组映射包在 browser.mac 条件下;反过来,Mac 上 Cmd 加方向键是系统级移动,编辑器不接管。Safari 有个专门的 safariDownArrowBug:光标位于文本块开头且后面紧跟不可编辑节点时按下方向键,Safari 的行为完全错误,补丁是把那个节点临时切到可编辑、移动后再切回来。Gecko 在选区紧贴不可编辑节点之前时行为古怪,对应分支把选区强制移进下一个节点。WebKit 系会围绕行内不可编辑节点制造出多余的光标停留位,左右方向键遇到这种节点时直接由代码接管,用 TextSelection 跳过整个节点(issue #937)。
dom.ts:DOM 工具函数
browser.ts 隔壁的 dom.ts 是另一块共享设施,十几个小函数,解决的共同问题是 DOM 规范没给现成 API。
domIndex(node):数 previousSibling 的个数,得到节点在父节点里的下标。DOM 没有 indexOf。parentNode(node):普通 parentNode 之外处理两个边界,assignedSlot(slot 分配场景)和 ShadowRoot(父节点 nodeType 为 11 时返回 host),让向上遍历能穿透 shadow DOM。nodeSize(node):文本节点取 nodeValue.length,其余取 childNodes.length,把两种「长度」统一成一个数。textRange(node, from, to):返回一个 Range,但永远是同一个。模块级变量reusedRange缓存唯一实例,注释解释了原因:DOM Range 对象创建昂贵,还会拖慢后续的 DOM 更新。coordsAtPos 里的矩形测量全走它,必要时用clearReusedRange释放。isEquivalentPosition(node, off, targetNode, targetOff):判断两个写法不同的 DOM 位置是否指向同一个地方,比如「文本节点之后」与「该文本节点末尾」。内部scanFor沿前后两个方向扫描,穿过等价边界,遇到块级描述、原子元素(img、br、input、textarea、hr)或 contentEditable=“false” 就停下;contentEditable=“false” 的子节点如果带着标了ignoreForSelection的 ViewDesc,则直接跨过去继续扫。选区同步靠它判断 DOM 选区已经是目标状态,不用重设。selectionCollapsed(domSel):本可以用原生 isCollapsed,但 Chrome 在 shadow DOM 里会错报 true(注释指向 Chromium issue 447523),于是改用 isEquivalentPosition 比较 anchor 和 focus。textNodeBefore/textNodeAfter:从任意 DOM 位置找到前后相邻的文本节点,遇到块边界或不可编辑节点返回 null。删除键处理和 composition 收尾用它。isOnEdge(node, offset, parent):位置是否沿边缘一路贴到 parent 的边界。从 (node, offset) 向上爬,每层都要求当前节点也是父节点的第一个或最后一个孩子,任何一层不在边上即失败。选区读取时用它判断光标是否贴在某个 ViewDesc 内容的边缘。hasBlockDesc(dom):沿 pmViewDesc 链向上找第一个带 ViewDesc 的节点,判断它是否块级,以及 dom 或 contentDOM 是否就是传入节点。keyEvent(keyCode, key):合成一个 keydown 事件,用 createEvent 加 initEvent 的老 API,再手动挂上 keyCode、key、code。iOS Enter 的兜底补丁靠它把补发事件送进 handleKeyDown。deepActiveElement(doc):activeElement 遇到 shadowRoot 就继续往里钻,返回最深的焦点元素。caretFromPoint(doc, x, y):坐标转 caret 的双实现。优先 caretPositionFromPoint,整个调用包在 try 里,因为 Firefox 会在难以预测的情况下抛异常;返回的 offset 用 nodeSize 裁剪,因为 Chrome 会返回 input 元素内部的文本 offset,那不能当普通 DOM offset 用。不支持时退到 caretRangeFromPoint。
这些函数被 view 包各处 import,domcoords.ts 一个文件就用了 nodeSize、textRange、parentNode、caretFromPoint 四个。
为什么必须有这一层
读完全部补丁,可以回答为什么编辑器必须有这么一层。
第一,这些差异没法用 polyfill 抹平。contenteditable 的行为由浏览器的排版和编辑引擎决定,MutationObserver 的触发时序、composition 事件序列、选区允许落在哪里,全是引擎内部行为,JS 层改不了,只能探测之后分支绕过。
第二,探测集中、补丁分散是权衡后的结构。补丁必须长在出问题的代码路径上,离开上下文就无法判断该绕过什么;探测集中到 browser.ts,让所有分支条件有统一来源,版本判断只在一份正则里维护。每个补丁旁边几乎都带着注释和 issue 编号,写明绕过的是什么、在哪个浏览器上出现,后来读到分支能直接对号入座。
第三,版本号字段让补丁能随浏览器修复自动失效。browser.chrome && browser.chrome_version < 63 这类条件在新版本浏览器上直接为假,分支不再执行。UA 嗅探在一般 Web 开发里是要避免的做法,在这里是务实选择:怪癖没有特征可探测,同时又希望浏览器修复后补丁自动退出,版本比较恰好同时满足这两点。
view 阶段的模块到这里全部读完。下一篇收官,抛开所有官方扩展,只用 model、state、view 三个核心包手写一个最小编辑器,把前面十几篇的机制串成一条完整链路。

