ARTICLE · 1352972
排版插件开发手记4:样式为什么总跑偏,以及我怎么算对,不依赖浏览器,我自己算层叠
一 · 最直觉的做法,是个陷阱
第 3 篇结尾说,读进来的文档,不等于算对的样式。第 1 篇的「坑一」,说的也是它:导入后排版不对,但结构完好、不报错。
我第一版的做法,正是大多数人会想到的:把正文塞进一个隐藏的 iframe,调 getComputedStyle 把每个元素的实际样式读出来。
听起来很合理。浏览器算出来的,还能有错?
有。而且错得极其安静。
我拿同一份文件测了两条路径。算对的时候,标题行高输出 28.8px;走那条「省事」路径,整篇只剩 margin:14px 0。两条路径都对同一个文件,结果差了一整个排版世界。
根因在 CSP。那个 iframe 是 about:blank,它会继承宿主页面的内容安全策略。当宿主的 style-src 不放行内联样式时,iframe 里我塞进去的 <style> 被静默禁用——不抛错,getComputedStyle 老老实实返回一个全 UA 默认值的结果(17px / 27.2px / #333)。
产物「看着完整、其实全默认」。这是「导入后排版不对」最隐蔽的一类根因:你以为你在量文件,其实你在量浏览器。

二 · 正解:不依赖浏览器,自己算层叠
既然浏览器这把尺子会撒谎,那就别用它的尺子。我改成自己算。
核心是一个纯函数:planStyle(tag, cs, pcs, ctx)。给定标签、自身样式、父级样式、上下文,返回这份元素最终该写进 inline style 的属性集合。
它走五步:
- 解析样式表。
把 <style>块、元素自带style、以及 Markdown 的语义样式,都解析成规则。 - 按特异性匹配。
选择器的特异性(id > class > 标签)决定哪条规则赢。 - 处理强制优先级声明。
带强制优先级标记的规则单独成一层,赢了普通规则。 - 沿树继承。
可继承属性(字体、颜色、行高)从父级往下传。 - 展开简写。
font拆成 font-size/font-family/font-weight等,避免目标编辑器不认简写。
这套做法有三个好处,是浏览器方案给不了的:
- 确定性。
同一份输入,永远同一份输出。没有布局、没有 CSP、没有超时。 - 可在 node 单测。
不依赖 DOM,纯逻辑断言,276 项单测里相当一部分守着它。 - 可对照。
我可以拿真浏览器原生渲染当黄金基准,逐元素比对,而不是「重新导入再看一眼」。
三 · 可继承属性:最容易被「省掉」的坑
自己算层叠,最大的陷阱是可继承属性。
直觉上,子元素「和父级相同」的属性可以省掉不写——反正继承了嘛。但这前提是:父级的基线,也要进产物。
公众号的编辑器只认 inline style。我搬过去的子树,挂在陌生的父级下(比如 section.rich_media_content)。原文 body { line-height:1.85; color:#3f3f3f } 不会跟着走。只要当初按「与父级相同就省掉」,整篇就落到目标编辑器的默认(17px / 1.6 / #333)。
用户视角是「导入后排版和原文件不一样」,但不报错、结构完好——最难查的一类。
修法:顶层子元素无条件写基线(字体 / 字号 / 行高 / 字色 / 字重),子孙才相对父级去重。实测代价仅约 8 KB(对一份约 1.48 MB 的文档而言,几乎可以忽略)。
还有一个连带坑:行高要保留倍数 1.85,而不是算成 px。钉死成像素,用户在手机端或改字号时必错位。倍数跟着缩放,才是稳的。
四 · 把决策抽成纯函数,是为了能验证
为什么非要把「算哪条样式」抽成 planStyle 这样一个纯函数?
因为只有这样,我才能在没有浏览器的环境里验证它。同一份函数,只切换 isTopChild(是不是顶层子元素)一个入参,就是修复前和修复后的对照。
判据因此变得具体:同样是这个文档,修复前整篇落到默认,修复后顶层子元素都带基线。对照一跑,根因立刻收敛。
这也是第 3 篇说「黄金对照」的落地:不是凭感觉说「看起来对」,而是拿真浏览器渲染逐元素比。后者分不清「本来就这样」和「被我改坏了」,前者能。

五 · 衔接:算对了,送不进去
这一篇,我把第 1 篇的「坑一」拆完了。样式算对了,确定性、可对照、可单测。
但第 1 篇的「坑二」马上接上来:算对了,送不进去。
更气人的是,送不进去不是因为接口难调,而是因为一个叫「隔离世界」的东西——你在内容脚本里看到的页面,和页面自己看到的页面,不是同一个。下一篇讲这个。
最后
样式跑偏,根因往往不在你算错了,而在你量错了尺子。那把最危险的尺子,是浏览器——它会「安静地撒谎」。
换一把不会撒谎的尺子:自己算层叠,确定性、可对照、可验证。这一步做稳了,后面「送进去」「说清哪步错」才站得住。
下一篇,从一个反直觉的事实说起:为什么你在内容脚本里读不到页面的接口。















































































