1. Web API设计原则
设计原则直接参照了伦理网络原则 [ethical-web-principles] 中提出的伦理框架。这些原则针对开发 Web 平台所需承担的更高层次伦理责任,提供了具体且实用的建议。
1.1. 以用户需求为先(相关方优先级)
如果必须做出权衡,始终将用户需求置于首位。
同样,在开始设计 API 时,务必理解并记录该 API 旨在解决的用户需求。
互联网是为终端用户服务的:对 Web 平台所做的任何变更都可能影响大量人群,甚至可能对个人生活产生深远影响。 [RFC8890]
用户需求优先于网页作者需求,网页作者需求优先于用户代理实现者需求,用户代理实现者需求优先于规范编写者需求,规范编写者需求优先于理论上的纯粹性。
和所有原则一样,这并非绝对。易于创作影响内容如何触达用户。用户代理必须优先分配有限的工程资源,这决定了功能如何到达作者。规范编写者也有有限的资源,理论上的考量反映了所有这些群体的根本需求。
另见:
1.2. 访问网页应当安全
在添加新功能时, 设计时需确保用户对“访问网页一般是安全的”这一预期得以保持。
Web因其超链接结构而得名。 为了保持Web的活力, 用户需要确信仅仅访问某个链接不会对其计算机安全或 隐私等重要方面造成影响。
例如,若某API允许网站 检测辅助技术的使用, 可能会让依赖这些技术的用户在访问未知网页时感到不安全, 因为任何网页都能检测到这些隐私信息。
只要用户能合理预期安全, 就能自行判断选择Web技术或其他技术。 例如,用户可能选择网页点餐而非安装APP, 因为安装本地应用的风险大于访问网页。
为了让Web的实际安全性与用户预期相符, 添加新功能时可以采取补充性措施:
-
可改进Web的用户界面,让用户明确知晓Web能做什么、不能做什么;
-
可改变Web的技术基础,使其符合用户对隐私的预期;
-
可考虑哪些情况下应提升用户预期,并据此同时调整技术基础和用户期望。
某些新功能即使带来安全风险,整体仍可能提升用户安全, 比如让用户在网页上完成某任务比安装本地应用更安全。 但这种收益需与用户对网页安全的合理预期进行权衡。
另见:
1.3. 可信用户界面必须值得信赖
考虑新功能是否影响可信用户界面。
用户依赖地址栏、安全指示器、权限提示等可信用户界面, 来判断自己与谁、以何种方式互动。 这些界面必须可设计为让用户信任 并验证 信息的真实性,且不会被网站伪造或劫持。
若新功能让不可信用户界面可模仿可信界面, 会让用户难以分辨信息是否可信。
例如,JavaScript alert()
允许页面显示类似浏览器的模态对话框,
这常被用来诱导用户访问诈骗网站。
若今天提出此功能,可能不会被采纳。
1.4. 以用户意图为导向的设计
使用你的 API 设计来鼓励网站在访问可能不安全的功能时符合用户的期望。
在满足用户需求的情境下,网页可能想要使用某个具有潜在危害的功能。只有当用户的期望与该功能的后果相匹配(例如,它可能暴露的个人信息或它改变的状态)时,才应允许使用此类功能。
将你的 API 设计为其行为与人们的意图和期望相匹配。这样在最理想的情况下,人们无需亲自参与允许 API 访问,也就无法做出日后会后悔的决定。仅在例外情况下才向用户请求确认。如果某项能力可以轻易恢复或仅会引起轻微烦扰且不会造成永久性伤害,应为用户提供停止该能力访问的选项。
例如,网页不需要为了播放声音而请求权限。如果发现某个站点滥用音频输出,停止音频很容易,浏览器也有多种机制可以做到这一点。
1.4.1. 帮助用户做出良好决策
通过在 API 级别限制风险并提供清晰性、上下文和用户控制来增强良好决策能力。
请求用户批准一直是处理剩余风险的常见方式。然而,人们常常无法对被询问的问题做出良好决定,因为他们缺乏关键的上下文和信息。面对困难或恼人的问题会导致决策疲劳、习惯性应答、频繁打断带来的恼怒以及后悔的决定。
当用户参与不可避免时,应赋能用户做出良好决策。使用以下原则:
- 首先在 API 级别处理风险,以限制错误决策的后果。 不要向用户提出可能导致严重后果的问题,例如设备被攻破或严重的个人数据泄露。
- 对持久性的决定提供持续的反馈。 如果用户的决定超出当前会话的时间,用户代理应提醒用户他们的过去决定仍然有效。提供撤销任何正在进行的权限的方法。API 应包含让站点得知状态变化的方式。
- 提出人们能够理解的问题。 当典型用户阅读有关某项功能的问题时,他们应能立即联想到相关风险。风险也应直接与该功能或 API 的用途相关联。例如,这通常意味着我们不能通过请求许可来接受指纹识别的风险,因为这些风险是微妙的、技术性的,并且通常与该能力本身无关。
- 提供充分的上下文。 人们需要能够轻松理解他们正在与谁打交道、这个决定是关于什么,以及网站将如何使用他们可能分享的任何信息。
如果你无法遵守以上所有原则,这很可能表明向用户提问并不是正确的方法。相反,应更改该功能或 API 来解决任何剩余的风险。
浏览器负责管理权限请求。浏览器无法精确理解用户与站点的交互如何导致对权限请求的合理预期。Web 平台为站点提供了多种方式来传达用途和意图,或进行欺骗和诡计。
因此,清晰传达意图的责任由 API 的设计、浏览器中的用户界面设计以及网站的构建方式共同承担。如果遵循 API 的简单、明显或默认用法模式自然会尊重用户意图,那是更可取的结果。该原则仅要求 API 设计鼓励构建尊重用户意图的站点。
在你的规范中,请求使用权限和提示用户选择这两个来自[权限]的算法是请求同意的良好方式。
如果你要求用户做出决定而他们选择不允许,网站不应能够做任何用户认为自己刚刚拒绝访问的事。例如,如果用户代理通过一个 API 请求权限以揭示一条信息,那么同一信息不应通过另一个 API 在无需权限的情况下被获取。
如果站点无法将拒绝与其他常见情形区分开来,拒绝将更为有效。这可以使站点更难以通过施压来迫使用户授予同意。
另请参阅:
1.5. 在场景中适当使用身份信息
让用户掌控 自己在Web不同场景下展示的身份信息, 并保持透明。
“身份”是个复杂概念,可有多种理解, 包括个人如何展现/看待自己,与他人、团体或机构的关系,以及行为与他人对待方式。 Web架构中,“身份”常用来指代标识符及附属信息。
涉及或依赖标识符及其附属个人数据的功能, 存在超越单一API或系统的隐私风险,包括被动生成的数据(如行为数据),也包括主动收集的数据(如表单填写)。
对于此类功能,你应当 了解其使用的上下文 ,包括它将如何与 Web 的其他功能一起使用。 确保 以用户意图为导向地设计 并 帮助用户做出良好决策。 将 API 设计为仅收集 必要的最少数据。 使用短期的临时标识符, 除非确实绝对需要持久标识符。
1.6. 支持全类型设备与平台(媒体独立性)
尽量确保Web功能可在不同输入输出设备、屏幕尺寸、交互模式、平台和媒体中可用。
Web的主要价值之一是极高的灵活性: 网页可在各种终端设备和屏幕尺寸下显示, 可用于打印媒体, 并支持多种交互方式。 新功能应与Web平台的现有灵活性保持一致。
这些功能仍可适配多种场景, 无法原生支持时可退化处理——如移动设备上的轻触会触发click事件。
功能设计也应保证最简用法同样具备灵活性。
有时某些功能在部分实现或平台上未支持,但在其它平台可用。 此时应保证代码可优雅降级或polyfill。 参见§ 2.6 新功能应可检测。
1.7. 谨慎添加新能力
添加新能力时需考虑现有功能和内容。
Web包含许多可扩展点以支持新功能; 例如见HTML § 1.7.2 可扩展性。
在添加新项目前,请考虑与现有的、类似的能力集成。如果这导致了一个只能通过添加项无法实现的更优设计方案,也可能依然可行;参见 § 1.8 更改或移除特性时优先考虑兼容性。
在没有核查之前,不要假设某项更改或移除是不可能的。
1.8. 更改或移除特性时优先考虑兼容性
在更改某个特性的行为之前,应了解当前网站是如何使用它的。
获得充分理解可能需要调研,例如通过在广泛使用的用户代理中添加统计指标,或检索 HTTP Archive。破坏现有内容会伤害用户,只有当更改带来的益处明显大于这种伤害时,才值得去做[the-web-is-unversioned]。
了解现有用法的责任同样适用于内容依赖的非标准化或未明确定义的特性。这包括厂商自有特性和实现中的 bug。
尽管如此,有时为了提升大量 web 用户的体验,打破部分现有内容是可以接受的。当出现以下情况时,破坏内容更容易被接受:
-
只有极少量的现有内容依赖该特性或行为。
-
只有极少数人会看到这些内容。
-
这些内容只出现在测试用例或示例中。
-
这些内容在一些广泛使用的用户代理中已经损坏,并且他们要么几乎没有收到相关的 bug 报告,要么该更改有望提升互操作性。
-
破坏内容带来的益处非常大。例如,移除旧版 SSL 和 TLS 协议会导致部分站点无法访问,但可防止更多的安全漏洞。
1.9. 让Web比你遇到时更好
为Web平台添加新能力时, 应提升整体平台质量。
平台某处有缺陷, 不能成为在该缺陷上继续扩展/添加的理由, 否则会进一步降低平台质量。
即使现有设计模式有问题, 也不应在新API或平台功能中延续此模式。 应尽量通过新能力改善平台质量, 弥补现有缺陷。
如需扩展有已知缺陷的平台功能, 应考虑如何修正这些问题。 不必完全修复,但扩展时应尽量改进, 以免新用法加剧缺陷危害。
Web平台各部分可独立演化。 当前某技术的问题可能在后续迭代中修正。 若重复这些问题,将使修复更难。 遵循此原则能保证平台质量随时间改善。
1.10. 最小化用户数据
设计功能时应仅使用完成用户目标所需的最少数据。
数据最小化 可减少数据泄露或滥用的风险。
将 Web API 设计为使站点比处理更通用或大批量的数据更容易请求、收集和/或传输少量数据或更细粒度、更加具体的数据。API 还应为传递给站点的数据提供粒度和用户控制,尤其是对 个人数据 的控制。当额外功能需要额外数据时,只有在 以用户意图为导向地设计 并在必要时 帮助用户做出良好决策 的情况下,API 才能启用这些功能。
2. 跨语言的API设计
2.1. 优先选择简单方案
深入寻找能够满足用户需求的简单方案。
简单方案通常优于复杂方案,虽然可能更难发现。简单的特性更易于用户代理实现和测试,更有可能实现互操作,也更容易让作者理解。尤其要确保你的特性能让最常见的用例易于实现。
确保你的用户需求定义明确。这样可以防止范围膨胀,并确保API真正满足所有用户的需要。当然,复杂或罕见的用例也值得解决,不过它们的方案可能更复杂。如 Alan Kay 所说,“简单的事情应当简单,复杂的事情应当可能。”
但需要注意的是,常见用例通常很简单,但常见性和复杂性并不总是相关。
另见:
2.2. 权衡高层与底层API
高层API让用户代理能以多种方式代表用户进行干预,比如确保可访问性、隐私或易用性。
TAG推荐使用字体选择器(高层API)(原文链接)而非字体枚举API(底层API),因为它满足绝大多数用例,同时保护用户隐私,不会像通用字体枚举API那样带来指纹识别风险。原生字体选择器还自带可访问性,并为终端用户提供一致体验。
底层API为作者提供实验空间,让高层API能随用例模式的演化自然产生,也能在高层API无法满足需求时提供“逃生通道”。
底层构件并非总能作为Web API暴露,原因包括保护用户安全和隐私,或避免将Web API绑定到特定硬件。但高层API应尽量以底层API为基础构建,这也有助于确定API需要多高层。
良好的分层方案应保证易用性与功能强大之间的权衡曲线连续,避免出现因用例复杂度略增而导致代码复杂度陡增的“断崖”。
2.3. 慎重命名
API命名要谨慎。好的命名能让作者更容易正确使用API。
详细命名建议请见命名原则一节。
2.4. 保持一致性
API设计时应参考先例,并尽量保持一致。
API易用性与一致性之间常有张力,尤其当现有先例可用性较差时。有时打破一致性以提升易用性是合理的,但前提是提升非常显著。
Web平台逐步演化,常有多种互相矛盾的先例。可以根据流行度(优先更流行的先例)、易用性(优先更易用的先例)、API年龄(优先更新的先例)等综合权衡。
内部一致性与外部一致性也常有冲突。内部一致性是与系统其它部分的一致,外部一致性是与外界的一致。Web平台通常有三层:API所属技术内部一致性(如CSS),与Web平台其它部分一致,以及部分场景下与外部领域的一致。此时应考虑多数用户是谁。大多数API的目标用户熟悉其所属技术,应倾向于与此保持一致。
关于命名一致性另有专门章节,见命名一致性。
2.5. 遵循特性规范中的指导
使用某项特性时,要遵循其规范中的指导。
为其它规范调用的特性,应在规范中给出使用指导,以确保整个平台的正确和一致使用。
部分指导举例如下:
-
使用HTTP构建协议, 尤其是定义头字段、 其它HTTP RFC、 以及HTTP工作组风格指南
使用他人规范时要与相关社区沟通,这有助于告知社区新用法,并为双方规范创造完善机会。
2.6. 新特性应可检测
为作者提供编程方法检测你的特性是否可用,让网页内容能优雅处理特性不可用的情况。
现有特性可能因多种原因页面不可用,常见原因有尚未实现,或仅在安全上下文中可用。
作者不应为每种情形都写不同代码,这样即使作者只关注某一种情形,代码也能适配全部。
某特性可用但因缺少所需设备而无法使用时,最好暴露特性可用性,并另有方法检测设备是否存在。这样作者可区分“设备不可用”与“特性不可用”,例如建议用户连接或启用设备。
作者应始终能通过JavaScript检测特性,在某些情况下也应能在对应语言中检测(如CSS中的@supports)。
某些场景下,不适合支持特性检测。是否可检测应以用户需求为依据。如果支持检测会违反用户需求或设计原则,则不应支持检测。
检测某项功能是否可用并不意味着能够检测是否已询问用户使用该功能。 通常,检测功能是否已实现可以与确定是否授权使用该功能分开进行。 在某些情况下,可能需要禁用功能检测以便能够拒绝使用该功能的请求。
如果某特性一般不向开发者开放,也不适合支持检测。例如,隐私浏览模式在规范中有定义,但不向作者开放。为了满足用户需求,不应支持检测隐私浏览模式。
另见:
2.7. 为人类设计文本格式
设计文本格式时应便于人类生成和使用。文本格式也提升了透明度。
优先考虑可读性而非紧凑性。文件体积可用工具优化,随着时间推移通常不是首要考虑。如果体积是绝对优先,也许不应采用文本格式。
接触文本格式的人应能通过文本编辑器轻松生成或修改内容。手动编辑会产生各类错误,用户可能难以发现和修正。
人们期望编辑时有一定灵活性。明确定义语法灵活性(如空白、引号、分隔符等)能保证内容易编辑且结果一致。
限定格式受语法错误影响的范围可提升健壮性和人类可用性。规范需完整定义处理和错误机制,保证全部输入都有一致结果。
如果某格式仅供机器使用,二进制格式往往更高效,也能避免人类直接编写和编辑内容。
2.8. 考虑仅在安全上下文开放新特性
如果某特性在无认证、完整性或保密性(仅安全上下文具备)下会对用户造成风险,应始终限定在安全上下文中使用。
其他特性方面,TAG成员未有统一结论。有些人认为所有新特性(除扩展现有特性外)都应限定在安全上下文,这有助于推广HTTPS,让用户整体更安全。
有人认为,只有有已知安全或隐私影响的特性才应限定在安全上下文,这降低了利用新特性的门槛,适用于无安全/隐私影响的新网页。
SecureContext]
扩展属性,限定接口、命名空间或其成员(如方法和属性)在安全上下文中使用。
但部分API(如分发事件)应在规范正文中明确限制为安全上下文。此时可考虑是否能为[SecureContext]扩展机制,为未来API开发提供便利。
但如无法优雅处理特性不可用,限定特性只在安全上下文可用可能会给可在安全/非安全环境通用的代码(如库)带来问题。
2.9. 不要暴露隐私浏览模式的使用
确保你的功能不会让作者有办法检测出用户正在使用隐私浏览模式。
有些人使用隐私浏览模式来保护自身的个人安全。因此,某人正在使用隐私浏览模式这一事实,可能是关于他们的敏感信息。如果这些信息被有权势的人(如雇主、父母、伴侣,或国家行为者)控制的网站获知,可能会对个人造成伤害。
鉴于这些风险,网站不应有能力检测隐私浏览模式是否被启用。
另见:
2.10. 考虑API在隐私浏览模式下的行为
如有必要,规范应指明API在隐私浏览模式下的不同表现。
例如,如果你的 API 会揭示足以让人将同一用户在隐私浏览模式内外的活动关联起来的信息, 请考虑可能的 缓解措施, 比如引入噪声,或者在必要时 帮助用户就这种跟踪做出良好决策。
隐私浏览模式让用户能无痕浏览网页,因此提供客户端存储的API不应在隐私浏览模式解除后保留数据。应做到不向网站暴露任何可检测的API差异。
如用户代理同时与某站点有普通和隐私会话,隐私会话中的存储变更不应暴露给普通会话,反之亦然。(storage事件不应在另一会话的window对象上触发。)
另见:
2.11. 不要暴露辅助技术的使用
确保你的API不会让作者检测到用户正在使用辅助技术。
Web平台必须对残障人士可访问。如网站能检测到用户使用辅助技术,可能拒绝或限制其服务。
依赖辅助技术的人常是社会易受伤害成员,他们的辅助技术使用情况是敏感信息。API若暴露此信息,可能泄露给有害他意者(包括国家行为者)。
有时有人提出旨在提升辅助技术用户体验的特性,但会副作用暴露辅助技术使用。虽出于善意,但却违反了§ 1.2 访问网页应当安全,需寻找替代方案。
另见:
2.12. 强大API需用户激活
部分强大API会生成侵入性界面(如自动播放音频)、暴露用户数据(如操作剪贴板)、后台活动无明显指示(如访问本地存储)、或促使用户与可信界面交互(如权限提示、硬件功能)。这些API应设计为必须有用户意图的指示(如用户激活)后才能运行,表明用户正在主动与网页交互。
用户激活的详细定义见HTML标准。设计API时应根据用户体验和风险,决定激活是全局一次(sticky)、周期性(transient)还是每次调用一次(transient consuming)。
请注意,虽然在许多情况下需要用户激活,但这并不总是 足以 保护用户免受侵入性行为。以用户意图为导向的设计 和 帮助用户做出良好决策 也很重要。
2.13. 支持非完全激活的BFCached文档
如可能,规范应指明特性在非完全激活的BFCached(前进/后退缓存)文档中的表现。
如你的特性涉及如下任何事项:
-
与文档“外部”交互(如向文档发送信息)
-
支持跨文档交互/资源共享(如持有锁)
-
文档被缓存为非完全激活后可能异常(如假定文档状态不会跨多次导航)
应按BFCached文档支持指南规定,指明特性与非完全激活BFCached文档的兼容方案。
注意:文档变为非完全激活也可能因其它原因(如iframe被移除),此指导只关注BFCache相关情形,不涵盖其它非激活来源。
2.14. 优先考虑可用性而非第三方工具兼容性
设计新特性时应以可用性为首要目标,第三方工具兼容性为次要目标。
Web平台受益于丰富工具生态,便于开发。新特性语法常与第三方工具冲突导致故障,尤其是这些工具常用于Web特性原型开发。
Web平台特性通常比第三方工具寿命更长,因此应优先保证特性的最佳语法和功能。
有时冲突会影响大量网站,需重设特性语法以避免冲突。
但此类情况应属例外。
决定是否因新语法破坏第三方工具时要考虑多因素,如破坏严重程度、工具流行度等。
最重要因素常是为避免破坏第三方工具而更改语法是否会严重损害Web特性的可用性?如有多个可用性相近方案,通常优先选择对第三方工具干扰最小者。
但如为避免破坏第三方工具而导致特性可用性显著下降,除非大量网站因此受损,否则通常不可接受。
语言还应提供可扩展机制,让作者能扩展语言而不破坏未来原生功能,以减少类似困境。
2.15. 通过组合简单类型构建复杂类型
定义可作为超类型替代的子类型。避免接口继承,除非父类型所有属性和行为对子类型都适用。
继承下,子类型能作为父类型的实例,需提供相同属性和方法,并保持行为一致。若子类型改变了某项行为,使用父类型处理时可能异常。
类型系统理论中,这称为Liskov替换原则。
更简单做法通常是避免继承,通过组合复用已有能力。新类型可定义属性以持有所需组件。
HTMLInputElement很难,因为input元素很复杂,专用化HTMLInputElement必须保持全部复杂性。自定义元素可通过组合需要与表单交互的项,无需应对HTMLInputElement的复杂性。
3. HTML
本节详细介绍通过HTML暴露的功能的设计原则。
3.1. 仅为类似功能复用HTML属性名
如果你要添加一个通过HTML属性指定的功能, 请检查其他元素上是否已有用于类似功能的属性名。 复用已有的HTML属性名,可以让作者利用现有知识, 保持语言的一致性, 并让词汇量更精简。
如果复用现有HTML属性, 尽量保持其语法与原属性一致。
for
属性最初用于
label
元素,指定关联的表单元素。
后来又被
output
元素复用,用于指定哪些元素的输入值参与或影响了计算。
后者语法更宽泛:可以接收以空格分隔的一组id,而前者只接收一个id。
但两者都遵循相同的语法,如果其中一个接收id列表,另一个接收selector,那就是反模式。
反之亦然: 如果你要添加的功能与现有属性功能不相似,则不要复用已有HTML属性名。
type属性在
input
和
button
元素上用于细化元素类型,
而在其它元素(如
link、
script、
style)
则用于指定MIME类型。
这是反模式,其中一组属性应该用不同的名字。
3.2. 短列表用空格分隔属性,长列表用多个元素
当为元素指定可为列表的元数据时,
通常做法是用空格分隔,并作为DOMTokenList暴露。
class
属性接收以空格分隔的类名列表。
classList
是一个DOMTokenList
,可添加/移除类名。
sandbox
属性接收以空格分隔的沙箱标志。
iframe.sandbox是DOMTokenList
,可添加/移除沙箱标志。
与Web平台其它部分保持一致很重要, 即使这意味着用其他字符分隔值。
无论语法如何,属性只应用于短列表。 长列表不建议嵌入属性, 而应用单独元素表示列表项(及元数据)。 这些元素可以是目标元素的子元素,也可通过属性链接。
极少数情况下需要其他权衡。
3.3. 不要阻塞HTML解析器
确保你的设计不要求HTML解析器为处理外部资源而暂停。
页面解析时, 浏览器会发现页面所需资源, 并决定并行加载优先级。 若有资源阻止后续资源的发现,则解析流程会被打断。 最坏情况下,浏览器串行下载资源,不能并行。 最好情况下,浏览器基于猜测解析进行队列下载,但猜测可能出错。
阻塞解析器的特性一般是为了在后续内容前向解析器注入新内容。
比如传统的<script src="…">元素可用document.write(…)注入解析器。
由于上述性能问题,新的特性不能这样设计。
3.4. 避免阻塞渲染的功能
需要在页面渲染前加载资源或执行操作的功能, 往往导致页面空白(或显示前一页),体验很差。
仅在整体用户体验得到提升时才考虑这种功能。 一个典型例子是为加载和处理样式表而阻塞渲染。 否则就会有无样式内容闪现(FOUC),这是不希望看到的。
另见§ 10.2.1 某些API应只暴露给dedicated worker。
3.5. 保持属性同步
新的内容属性应有同名IDL属性,并保持状态同步。 若同步的IDL属性命名不一致,会造成混淆,应避免。
input的
value、
option的
selected、
以及
input的
checked,
对应的IDL属性并不是同名的,
而是defaultValue、
defaultSelected、
defaultChecked。
3.6. 包含URL属性应按主要用途命名
如果元素允许用户跳转到属性中的URL,属性名应为href,如
a
元素的
href
属性。
注意:回头看,
form的
action
属性其实应该叫href。
如果元素会加载给定URL的资源,属性名应为src,如
img
元素的
src
属性,或
script
元素的
src
属性。
注意:HTML有一些遗留的不一致做法,不建议效仿,如
link的
href
属性可能用于跳转,也可能用于加载资源,取决于
rel
属性的值。
若属性标识的URL只是元素用途的辅助信息,如
video的
poster、
q的
cite、
或
a的
ping,
则应按语义命名该属性。
注意:含有URL的属性在IDL中应用USVString表示;
见§ 8.2 合理表示字符串。
3.7. 每个HTML元素只赋予单一用途
每个HTML元素都应有明确的用途。 HTML属性可修饰元素语义, 但不应根本改变其用途。
与其定义能以不同模式运行的元素, 不如为每种模式分别设立不同元素。 这样更便于作者使用。
通过属性在同一元素上过载语义以选择不同行为, 只有在无论属性值为何,元素用途都不变时才适用。
反例包括过度专用和依赖上下文确定语义的元素。
4. 层叠样式表(CSS)
本节详细介绍通过CSS暴露的功能的设计原则。
4.1. 根据层叠需求分离CSS属性
根据哪些值适合独立设置,决定应将哪些值分组为同一个CSS属性,哪些应单独设为不同属性。
CSS层叠 允许不同规则或不同样式表中的声明 相互覆盖。 应同时被覆盖的一组值应归为同一属性,以便一同层叠。 同理,应能独立覆盖的值应分成不同属性。
而initial-letter-align属性则应单独设立, 因为它用于设置整个文档的对齐策略,是一种通用样式选择, 并与文档所用的书写体系(如拉丁、斯拉夫、阿拉伯)相关。
4.2. 合理选择CSS属性是否继承
根据属性的效果在祖先和后代元素同时设置时,是应被覆盖还是叠加,决定该属性是否继承。
如果在后代元素上设置该属性需覆盖(而非叠加)祖先设置的效果,则该属性应继承。
如果后代元素上的设置是独立的效果,会在祖先设置基础上叠加,则该属性不应继承。
如某非继承属性的规范要求处理元素时查找祖先属性值(可能还很慢),这是“代码异味”,说明该属性其实应该继承。 如某继承属性的规范要求处理元素时忽略与父元素相同的属性值,也是“代码异味”,说明该属性其实不该继承。
如果background-image属性被继承, 则规范需增加不少复杂度以避免半透明图片在每个后代元素上重复显示。 这种复杂度可能要求属性值与父元素一致时行为不同, 这就属于上述“代码异味”,说明该属性其实不该继承。
若font-size属性不继承, 则初始值可能需要沿祖先链查找最近的非默认值祖先。 这也是上述“代码异味”,说明该属性其实应该继承。
4.3. 根据继承方式选择计算值类型
根据CSS属性如何继承(包括依赖其他属性的情况)选择其计算值类型。
继承意味着元素获得与父元素相同的计算值。 所以在到达计算值前的处理步骤会影响被继承的值, 之后的步骤(如used value)则不会。
但此时计算值是<number> 1.4, 不是<length> 28px。 (used value为28px。)
line-height属性可被继承到拥有不同font-size的元素, 这些元素上依赖line-height的属性, 必须使用自身相关的font-size, 而不是继承来源元素的font-size。
< body style = "font-size: 20px; line-height: 1.4" > < p > 这段正文行高为28px。</ p > < h2 style = "font-size: 200%" > 这个标题行高为56px,不是28px,即使line-height声明在body上。 这意味着40px字体不会溢出行高。</ h2 > </ body >
这些数字值通常是line-height的首选值,因为它们比长度值继承性更好。
另见:
4.4. 合理命名CSS属性与值
CSS属性名通常是名词,其值名通常是形容词(有时是名词)。
属性和值中的词用连字符分隔。一般避免缩写。
尽量用词根而非带语法前后缀的形式(如用“size”而非“sizing”)。
属性的值列表应选用可扩展方式,以便后续添加新值。 避免用yes、no、true、false等或等价复杂词。
避免在属性名中用“mode”或“state”,因为属性本身就是设置模式或状态。
有关命名的一般建议见§ 12 命名原则。
4.5. 内容默认应可见且可访问
设计CSS属性或CSS布局系统(通常为display属性的值)时,
应确保内容默认可见、可访问且可用。
overflow: hidden或left: -40em)时才可能出现这种情况。
不应因默认行为(如display: flex或position: relative)而导致此类问题。
5. JavaScript语言
5.1. Web API使用JavaScript实现
设计Web的命令式API时,应使用JavaScript。 可以充分利用语言特有的语义和约定,无需通用化。
CustomElementRegistry.define()
方法接受
Constructor Method引用。
这利用了JavaScript最近引入的类机制,以及方法引用在JavaScript中易于使用的事实。
5.2. 保持运行至完成语义
如有状态变化源自JavaScript执行上下文外,应在任务之间将变化传递到JavaScript, 如排队任务, 或作为更新渲染的一部分。
与C++、Rust等低级语言不同, JavaScript历来只允许一次执行一段代码。 因此,JavaScript作者默认函数运行期间数据不会意外变化。
非开发者操作导致的变化和异步变化, 不应在其他JavaScript执行期间发生,包括微任务之间。
while循环)或await已解析Promise后,开发者通常不会预期如下情况:
-
HTML解析器从网络加载新内容导致DOM更新
-
img.width因图片数据加载而变化 -
Gamepad按钮状态变化 -
scrollTop变化,即使视觉上可滚动 -
同步方法因异步状态变化而表现不同。例如,若
LockManager有同步方法,其行为取决于其它窗口的并发调用。
这些变化不是当前脚本造成,因此不应在当前任务期间发生。
数据可由开发者操作同步更新。
node.remove()
会同步改变DOM,且可立即观察到。
少数情况可违反此规则:
-
获取当前时间,如
Date.now()和performance.now(),但也可用如document.timeline.currentTime的任务范围一致时间。 -
用于中断同步工作的函数,如
IdleDeadline.timeRemaining()。 -
用于保护用户防止UI突变的状态,如transient activation。注意
navigator.userActivation.isActive违反了此场景应用方法的建议。
5.3. 不暴露垃圾回收机制
确保你的JavaScript Web API不会让作者获知垃圾回收的时机。
不同用户代理垃圾回收时机不同,且随性能优化可能变化。 若API暴露垃圾回收时机,程序在不同环境下会表现不同, 作者需额外编写兼容代码。 若大量代码依赖特定时机实现,也会让用户代理更难优化垃圾回收策略。
因此,不应暴露任何作为弱引用的API,如属性因垃圾回收变为。
JavaScript代码中的对象和数据生命周期应可预测。
getElementsByTagName
返回一个HTMLCollection
对象,
若对同一Document对象用同一标签名多次调用,可能重用该对象。
实际上,
只有对象未被垃圾回收才会返回同一对象。
因此,行为依赖垃圾回收时机。
若getElementsByTagName
现在设计,建议要么始终重用结果,要么每次创建新HTMLCollection。
getElementsByTagName
并未指出其行为依赖垃圾回收时机。
相比之下,像WeakRef
或FinalizationRegistry等API则明确让作者预期与垃圾回收的关系。
6. 设计JavaScript API
6.1. 合理使用WebIDL字典、接口和命名空间
为新API选择合适的WebIDL机制。
WebIDL提供多种结构定义Web API。 字典、接口和命名空间各自适合不同用途。
目标是确保API符合人体工程学、一致且自然,避免“伪类”或无功能的类等陷阱。
配置或只读输入数据请用字典
当API部分表示临时数据,尤其是接受一组参数、配置或选项时,选用字典。
字典适用于未持久存储或未变更的数据;仅在调用时使用。
例如,Web Share中的ShareData成员:
dictionary ShareData { USVString title ; USVString text ; USVString url ; };
常见用法如下:
await navigator.share({text: "Text being shared" });
字典易于扩展,后续可方便添加可选字段。
字典成员默认可选,需时可标记为required。
字典非常符合JavaScript习惯(idiomatic)。
直接传{ ... }是JS中最自然的配置方式。
字典因用户代理处理方式也较具前向兼容性。 未实现的字典成员会被忽略,新成员可无缝添加,旧代码不受影响。
字典适用于生命周期中无需按类型区分的对象(如instanceof检查只会是Object)。
字典是“值传递”给方法(即被复制)。 浏览器引擎从JS对象转换到WebIDL时会剔除未知成员,传入API后修改值无效。
再举ShareData字典例:
const data= { "text" : "Text being shared" , // 浏览器未实现"whatever"参数时会忽略 "whatever" : 123 , }; let p= navigator. share( data); // 调用.share()后再修改无效 data. text= "New text" ;
有功能、状态与身份请选接口
接口大致等价于JavaScript类。 当规范需要把状态(包括可见属性和内部“槽”)与操作(方法)绑定在一起时,用接口。
接口与字典不同:
-
可有带状态的实例,
-
可有只读属性,
-
赋值可有副作用,
-
可检查对象身份(即可用
instanceof判断全局范围的某类实例),
定义接口还会暴露到全局作用域,便于规范静态方法。例如URL接口的canParse()静态方法:
if ( URL. canParse( someURL)) { // 处理逻辑... }
有状态接口应有构造函数,若类无状态则不应添加构造函数,
否则是“伪类”(实例没做静态方法不能做的事)。如DOMParser或DOMImplementation就是伪类示例。
为接口数据提供序列化方法
添加序列化方法,把接口实例转换为多数应用可用的形式。
.toJSON()方法让接口实例可生成有用的JSON序列化;
.toBlob()可提取接口的二进制表示。
这样更自然与API配合使用。例如GeolocationPosition接口提供toJSON()方法:
const position= await new Promise(( resolve, reject) => { navigator. geolocation. getCurrentPosition( resolve, reject); }); const message= JSON. stringify({ user: userId, time: Date. now(), position, // .stringify()会自动调用.toJSON() });
行为型工具请用命名空间避免“伪类”
命名空间用于分组一组静态属性和方法,没有原型。
例如JS中的Math、Intl、Atomics和Console对象。
只有一两个小静态函数时,新建命名空间未免小题大做,挂到已有对象可能更合适。
反之,命名空间过大或过宽时应优化组织或分区。
“伪命名空间”
WebIDL接口可有属性,可通过非可构造接口属性创建“伪命名空间”。
典型例子是navigator对象的各种属性。
navigator本身有接口定义(Navigator),
其属性又通过接口实例暴露进一步功能。
例如:
-
navigator.credentials- Credentials Management API的CredentialsContainer接口。 -
navigator.geolocation- Geolocation规范的Geolocation接口。 -
navigator.permissions- Permissions规范的Permissions接口。
当你需要引用某实例(如特定浏览环境下的权限navigator.permissions),
即使该实例无可见状态时,伪命名空间很有用。
6.2. 属性应像数据属性一样工作
[WEBIDL]属性应表现为简单JS对象属性。
实际上IDL属性是访问器属性(getter/setter)。 要让它们像JS对象属性:
-
getter不能有可观察的副作用。
-
getter不应执行复杂操作。
-
保证
obj总为真,不能每次返回新值。. attribute=== obj. attribute -
尽量保证
obj后. attribute= xobj为真(如需转换则不强求)。. attribute=== x
如属性不这样工作,建议用方法替代。
6.3. 考虑对象应为实时还是静态
API如访问表示内部状态的对象,需决定该对象是否随状态变化而实时更新。
始终表示当前状态的是实时对象, 只表示创建时状态的是静态对象。
实时对象
如对象允许作者变更内部状态,应为实时对象。
例如DOM的Node
都是实时对象,便于作者按当前状态修改文档。
实时对象属性可在访问时计算,而非创建时。 若数据计算复杂,实时对象无需提前算好所有数据。
实时对象也可节省内存,无需为静态版本复制数据。
静态对象
如对象表示可能变化的列表,多数情况下应为静态对象。这样遍历列表时无需担心中途列表变化。
getElementsByTagName
返回实时对象表示列表,因此遍历时需谨慎:
let list= document. getElementsByTagName( "td" ); for ( let i= 0 ; i< list. length; i++ ) { let td= list[ i]; let tr= document. createElement( "tr" ); tr. innerHTML= td. outerHTML; // 此操作会把td从列表移除,导致遍历结果不可预测 td. parentNode. replaceChild( tr, td); }
querySelectorAll()
会返回静态对象,正是因为规范作者发现getElementsByTagName导致问题。
URLSearchParams
虽然表示列表,但不是静态对象,因为它用于变更URL的查询字符串。
注意:对maplike和setlike类型此建议未必适用,因为这些类型设计时就考虑了迭代时变更。
如无法在访问时计算属性,静态对象可避免为未使用的对象持续更新直至被回收。
如静态对象表示频繁变化的状态,应通过方法返回,而不是作为属性。
另见:
6.4. 访问器应表现为属性而非方法
IDL属性描述对象属性或getter,产生对象状态信息。
-
getter不能有(可观察)副作用。有副作用时应用方法。
-
getter不应抛异常。getter应表现为普通数据属性,数据属性读取不会抛异常。无效状态应通过拒绝写入而非读取时抛异常。已有getter更新为抛异常会破坏向后兼容,因API用户枚举或包装API时未预期异常。
-
getter不应执行阻塞操作。如需阻塞,应用方法。
-
对象未变时,getter每次应返回同一对象,即
obj.property === obj.property始终成立。每次返回新值不允许,否则应用方法。
注意:阻塞操作反例如getteroffsetTop会执行布局。
定义IDL属性时,尽量保存setter赋值后getter可返回的原值,即obj.property = x后obj.property === x为真(如需归一化或类型转换可酌情)。
返回对象可为实时或静态:
-
实时时,每次返回同一对象,直到状态变化需返回新对象。可由属性、getter或方法返回。
-
静态时,每次返回新对象,应用方法返回。
6.5. 可选与基础参数应用字典传递
API方法应优先用字典参数,而不是一串可选参数。
这样调用代码更易读,方法签名更易记,也便于后续扩展,尤其有多个同类型参数时。
new Event( "example" , { bubbles: true , cancelable: false })
比下面可读性更高:
new Event( "example" , true , false )
强制参数也可考虑用字典传递,如此更易读,尤其是基础类型参数。
字典本身应为可选参数, 这样如作者接受所有默认选项,可不传额外参数。
另见:
6.6. 方法参数应尽可能可选
如API方法参数有合理默认值,应设为可选并指定默认值。
布尔参数建议默认值为false。
默认值应为大多数作者会选择的值,如明显选项。布尔属性名也应选用让false为常用值。
API列表类型参数建议如下:
-
方法参数用
sequence<T> -
方法返回值用
sequence<T> -
属性用
ObservableArray<T>
另见:
6.7. 合理命名可选参数
可选参数命名应让默认行为一目了然,且不要用否定命名。
addEventListener()
接收一个包含once选项的对象,表示监听器不会重复调用。
该选项原可命名为repeat,此时默认应为true。API作者未用noRepeat,而用once,这样默认行为无需否定命名。
其它例子:
-
passive优于active -
isolate优于connect -
private优于public
另见:
6.8. 合理使用重载
如方法行为因参数变化而显著不同,通常应定义不同方法,而不是重载一个方法。
如方法某参数可为单值或数组,适当重载有用。传递选项字典也是好模式,灵活性更高。
但如后续参数因首参改变,或参数类型决定方法行为,可能影响代码可读性和API功能发现,不建议这样做。
6.9. 类应尽量有构造函数
API中的类如适用应有构造函数。
默认[WEBIDL]接口生成不可构造类:用new X()实例化会抛TypeError。
要可构造,需为接口添加构造函数操作并定义实例创建算法。
这样JS开发者可实例化类以测试、mock或与第三方库交互,也能继承该类,否则JS无法继承。
但并非所有场景都适用。例如:
-
部分对象表示特权资源,需工厂方法创建以访问资源。
-
部分对象生命周期受控,需专用方法创建和访问。
-
部分对象为抽象基类,不应实例化也不应被继承。
Event
类及其所有派生接口均可构造,便于测试事件处理代码,作者可构造Event传递给事件处理方法。
Window
类不可构造,因为创建新窗口是特权操作有副作用,应用window.open()方法创建。
ImageBitmap
类不可构造,因其表示不可变、可绘制的位图,生成过程需异步。应用createImageBitmap()工厂方法创建。
DOMTokenList
类目前不可构造,导致无法为自定义元素暴露其token属性为DOMTokenList。
Navigator、
History、
Crypto
等类不可构造,因为它们是单例,表示每窗口信息访问。
这种场景可用WebIDL命名空间特性,但这些特性设计早于命名空间,且超出现有命名空间能力。
如API需此类单例,建议用命名空间,如用有问题可反馈WebIDL问题。
工厂方法可辅助构造函数,但一般不应完全替代。如有基类和多个子类时,工厂方法可根据参数创建合适子类。通常工厂方法是派生类的静态方法。
6.10. 在合适场景下保持同步
在可能的情况下,设计新API时优先使用同步API。
同步API更易于使用,无需搭建额外的基础设施(如将函数声明为async)。
一般来说,API应为同步,如果满足以下经验法则:
-
API不需要权限提示或设备选择等弹窗。
-
API实现不会因锁、文件系统或网络访问(如进程间通信)而阻塞。
-
执行时间短且确定。
6.11. 用Promise设计异步API
如API方法需异步,使用Promise而非回调函数。
在Web平台统一使用Promise,便于API组合使用,例如链式调用Promise。 用Promise的代码通常比用回调函数的代码易懂。
API可能需要异步,如果:
-
用户代理需要弹窗请求权限,
-
部分信息需从磁盘读取或从网络请求,
-
用户代理需在其他线程或进程做大量工作才能返回结果。
另见:
何时使用回调函数
仅在需要立即响应时使用同步回调。
某些API场景需同步操作,特别是涉及延迟敏感行为时。 例如,浏览器需在事件冒泡结束后立即决定后续处理,媒体处理不能总等到下一个任务边界。 同步行为无法用Promise建模,应使用显式回调或事件。
在Promise reaction回调中, 不能要求在固定数量microtask内完成操作。
这样能保护Promise组合:如await、包装辅助函数、日志、Promise.race()或多个.then()分支都会多排一个microtask,不应明显改变被包裹代码行为。
事件处理器是同步调用和取消的。
开发者代码如需调用
event.
或用
preventDefault()fetchEvent.
选择响应对象,都需要用回调而不是传给
respondWith()Promise.的反应。
then()
非事件的例子如
captureController.
方法,会把用户选定的屏幕捕获窗置前。应用可根据用户选择决定。
问题是延迟处理会让站点有机会进行点击劫持攻击。
setFocusBehavior()
不是要求在getDisplayMedia()的promise
reaction microtask内调用该方法,
而是要求在同一promise reaction任务中调用,且有1秒超时限制。
6.12. 用AbortSignal取消异步API/操作
如异步方法可取消,允许作者通过选项字典传入AbortSignal。
const controller= new AbortController(); const signal= controller. signal; geolocation. read({ signal});
统一用AbortSignal取消异步操作,可让代码更简单。
比如,可用一个AbortSignal控制多个并发操作,再用对应AbortController一次性取消(如用户按“取消”,或SPA导航时)。
即使不能保证一定能取消,也可以用AbortController,
因为abort()是请求而非保证。
6.13. 常量与枚举用字符串表示
对于常量或一组枚举值,使用字符串作为取值。 选择字符串时请参阅 § 12 命名原则。
字符串方便开发者检查值、阅读相关代码。 JS引擎用整数没性能优势。 WebIDL枚举类型的值也是字符串。
如需表示多个属性组合(其他语言用位掩码),用字典对象代替。 字典对象可像单一位掩码一样传递。
WorkerType参数设置。
只有两个值("classic"和"module"),本可用布尔类型,字符串无损且代码更清晰。
6.14. 如需同步与异步方法,默认异步,同步为例外
如极少数场景需同步与异步方法共存,默认用异步,同步为例外。
因平台当前限制,部分Web平台领域缺乏异步支持,因此同时支持同步方法有助于可用性。 这类情况应视为例外,且应有将同步方法弃用的明确路径,随平台能力提升。
多数情况下,同步变体应以Sync()后缀区分。
6.15. 字节数组用Uint8Array返回
API如返回字节数组,应用Uint8Array,而不是ArrayBuffer。
ArrayBuffer无法直接读取,开发者需要创建如Uint8Array的视图来读取数据。
直接提供Uint8Array可省去此步骤。
如缓冲区字节可自然解释为其他TypedArray类型,则应用对应类型。
例如字节表示Float32值时,应用Float32Array。
6.16. 有副作用的函数应返回undefined
如函数目的是产生副作用而非计算值,应指定返回undefined。
站点通常不会依赖此返回值,因此未来如有新需求可轻松改为返回有意义值。
HTMLMediaElement的
play()
方法最初定义为返回undefined,
因其目的是改变媒体元素状态。
播放请求可能因多种原因失败,因此play()被改为返回Promise。
如果API最初定义为返回非undefined(如返回媒体元素,用于API链式调用),则不兼容于后续增强此API可用性。
另见:
6.17. 优先使用已有任务源
在分派工作时,尽可能使用已有的任务源。
只有在有明确理由时,才新建任务源。 规范通过任务源来分组相关工作(或“任务”),以便用户代理可以合理地调度它们。 一个任务会被加入到某个任务源, 每个任务源都关联着某个事件循环的任务队列。 在同一任务源内顺序有保证,但用户代理会自行选择下一个要处理的队列。
尽可能使用通用或已有的任务源。 HTML定义了通用任务源,旨在跨规范广泛复用。 包括但不限于: DOM操作任务源、 用户交互任务源、 以及网络任务源。 如果你的功能属于这些类别之一, 应当加入对应的任务源,而不是新建一个任务源。 这样有助于提升平台的互操作性、可预测性和性能调度。
新增任务源会带来性能和复杂度上的成本, 所有浏览器实现都需承担这些代价。 只有在确实有具体且可测试的需求时才新建任务源。 比如,当某个功能的时序/执行模型必须与通用任务源隔离时 (如媒体元素拥有自己的媒体事件任务源,以便与解码器/时间线语义保持一致)。
在撰写规范时,优先使用HTML的封装算法 (排队一个全局任务、排队一个元素任务), 并避免依赖特定的事件循环。 只有在确实需要时才明确指定事件循环(也可参见§ 7 事件设计)。
7. 事件设计
7.1. 一次性事件用Promise实现
遵循Promise规范编写指南中的一次性事件建议。
7.2. 可取消事件不要用Promise实现
参见何时使用回调函数。
7.3. 事件应在相关Promise resolve前触发
如Promise异步算法会调度事件,应在Promise resolve前调度事件。
Promise resolve时,会排队microtask运行reaction回调。 microtask在JS栈清空时处理。 事件分发是同步的,监听器之间JS栈会清空。 因此,若Promise resolve在相关事件分发前,所有因Promise reaction排队的microtask会在事件第一个和第二个监听器间调用。
先分发事件可避免这种交错。所有事件监听器都在任何Promise reaction回调前调用。
7.4. 不要自创事件监听机制
创建允许作者开始/停止通知生成过程的API时,用现有事件机制监听通知。 为底层过程提供独立API控制。
BluetoothRemoteGATTCharacteristic全局对象上提供startNotifications()方法,将对象加入“活动通知上下文集”。
用户代理收到蓝牙设备通知时,会在活动通知上下文集内的BluetoothRemoteGATTCharacteristic对象上触发事件。
参见:
7.5. 始终添加事件处理属性
API如添加新事件类型,应在所有可能处理该事件的EventHandler接口上添加对应的onyourevent
事件处理IDL属性。
持续定义事件处理IDL属性很重要:
-
有助于平台一致性
-
可用于事件支持的功能检测(见§ 2.6 新特性应可检测)
如事件需被HTML和SVG元素处理,应在GlobalEventHandlers接口mixin上添加事件处理IDL属性,而不是直接加到相关元素接口。
同理,窗口事件应加到WindowEventHandlers,而不是Window。
7.6. 用事件实现通知
事件不应用于触发变更,只用于通知变更已完成。
7.7. 防范递归风险
API如包含耗时或复杂算法,需防止算法正在运行时再次调用。
API方法如启动长算法,应用事件通知用户代码进度。但用户代码处理事件时可能再次调用API方法,导致复杂算法递归运行。相同事件可能再次触发,相同处理器又被调用,依此类推。
为防止此情况,应确保“递归”调用API方法时立即返回,不再进入算法。此技术称“守卫”算法。
AbortSignal的
add、
remove和signal abort
都会先检查信号是否aborted,
如已aborted,算法不再继续。
此处很多复杂逻辑在signal abort步骤中实现, 这些步骤会遍历算法集合,由add和remove方法管理。
例如,
ReadableStreamPipeTo
会在AbortSignal的算法集合中注册算法,
当触发signal abort步骤时执行,
通过abort()
调用关联的AbortController。
此算法可能会resolve Promise导致代码运行,其中包括对AbortSignal方法的调用。
由于signal abort会遍历算法集合,
运行期间不应允许集合被修改。
而且signal abort触发的代码可能又导致递归调用signal abort, 因此如信号已在signal abort步骤中,不应重复运行,防止递归。
注意:提前终止时如算法本会保证关键状态一致,需确保提前终止前调整状态,否则会造成状态不一致和用户可见bug。
注意:提前终止时抛异常需谨慎,考虑开发者调用场景及是否预期处理异常,例如此算法是否唯一会抛异常?
并非总能用守卫技术。算法如有太多入口无法全部检查时,可选择延迟调用作者代码到后续任务或microtask。 这样可避免递归栈,但仍可能陷入无休止的后续任务循环。
如算法可能在不同线程/进程运行,事件应始终延迟,以确保在正确任务队列处理。
守卫与延迟都有权衡:
守卫算法保证:
-
事件触发时,守卫算法结束到事件触发间状态不会变化。
-
算法中触发的事件(如状态变更通知事件)可立即触发,无需等下一个任务。
-
事件处理器中可直接访问实例对象的相关状态,无需事件中额外附带状态。
如事件选择延迟:
-
无法保证算法完成后事件一定排在任务队列首位。
-
任何其它任务都可能改变对象状态,需在事件中包含相关状态。
-
通常需新建Event子类带新属性保存状态。
例如
ProgressEvent有loaded、total等属性。
-
-
如算法不同部分需协调,需定义状态机(明确状态转换),确保延迟事件触发时状态检查/修改行为可预期。
如Payment Request API中,
PaymentRequest的[[state]]内部槽显式记录对象状态转移。 -
如延迟事件无需额外状态或状态机,可能仅用于通知算法完成。此时API应返回
Promise而非触发事件。 见§ 7.1 一次性事件用Promise实现。
注意:本节所述暴露递归可能性的事件有时称“同步事件”,不建议使用此术语,因为暗示可以异步分发事件。所有事件都是同步分发的。所谓“异步事件”其实是延迟触发事件。
7.8. 将状态放在 target
对象上,而不是 Event
上
将状态放在 target
上,并使用事件来通知该状态的更新,
而不是将状态放在 Event
对象上。
将状态放在 target
对象上,可以用来确定当前状态,
无需等待下一个事件。
如果有最终状态,即不会再有事件发生时,这种做法尤为有用。
通常不需要为 Event
创建新的子类,
但可以用来提供与状态变化方式相关的信息。
在一些特殊情况下,如果在对象上维护状态代价太高,
可以考虑其他模式,
例如从函数调用中返回一个 EventTarget,
此时函数调用表示对某项内容感兴趣的信号。
dataUsage API 用于报告环境已使用的数据量,推荐的模式为:
self. dataUsage. addEventListener( 'change' , () => { console. log( self. dataUsage. bytesReceived); });
其中 bytesReceived 存在于 dataUsage 的 EventTarget
上,而不是 Event
上。
如果在该对象上维护状态开销太大,可以考虑以下模式:
const dataUsage= await self. monitorDataUsage(); dataUsage. addEventListener( 'change' , () => { console. log( dataUsage. bytesReceived); });
在此模式下,调用 monitorDataUsage 表示对数据的关注,
状态只有在调用 monitorDataUsage 时才需要被更新。
7.9. 合理使用事件与观察者
一般来说,应使用 EventTarget
和通知 Event,
而不是使用观察者模式,
除非 EventTarget
对你的功能来说无法很好地工作。
使用 EventTarget
可以确保你的功能受益于
这一共享基类的改进,
例如增加了 once
属性。
如果使用事件会带来问题,如不可避免的递归,则可考虑采用Observer模式。
MutationObserver、
Intersection Observer、
Resize Observers、
IndexedDB Observers
都是Observer模式的例子。
MutationObserver
替代了已废弃的
DOM Mutation Events,
因开发者发现
DOM Mutation Events
-
触发频率过高
-
没有事件传播,导致太慢不实用
-
容易导致递归,难以防范
Mutation Observer的优势:
-
可以批量收集变更,在变更应用后统一通知观察者;
-
无需经历事件捕获和冒泡阶段;
-
提供更丰富的API以表达发生了哪些变更。
注意:事件也可以批量通知,但DOM Mutation Events未设计为此。事件不总需要参与事件传播,但针对DOM节点的事件通常会参与。
Observer模式的工作方式如下:
-
每个Observer实例在构造时传入回调函数,可选传入自定义观察选项。
-
实例通过
observe()方法开始观察目标对象,传入要观察的目标引用。选项可在此处传入而不是构造时。发生变化时会调用构造时传入的回调。 -
回调接收变更记录,包含变化的详细信息。可一次性接收多个记录。
-
作者可通过
unobserve()或disconnect()方法停止观察。 -
可选地,提供一个方法立即返回未送达的变更记录,如
takeRecords()。
IntersectionObserver
典型用法如下:
function checkElementStillVisible( element, observer) { delete element. visibleTimeout; // 处理任务队列中的观察结果 processChanges( observer. takeRecords()); if ( 'isVisible' in element) { delete element. isVisible; logAdImpressionToServer(); // 停止观察该元素 observer. unobserve( element); } } function processChanges( changes) { changes. forEach( function ( changeRecord) { var element= changeRecord. target; element. isVisible= isVisible( changeRecord. boundingClientRect, changeRecord. intersectionRect); if ( 'isVisible' in element) { // 元素变为可见 element. visibleTimeout= setTimeout(() => { checkElementStillVisible( element, observer); }, 1000 ); } else { // 元素变为隐藏 if ( 'visibleTimeout' in element) { clearTimeout( element. visibleTimeout); delete element. visibleTimeout; } } }); } // 创建IntersectionObserver实例 var observer= new IntersectionObserver( processChanges, { threshold: [ 0.5 ] }); // 观察广告元素 var ad= document. querySelector( '#ad' ); observer. observe( ad);
(示例代码改编自IntersectionObserver explainer。)
如需使用Observer模式,你需定义:
-
新的Observer对象类型
-
用于观察选项的对象类型
-
用于变更记录的对象类型
额外工作换来的好处:
-
实例可在观察时或创建时自定义。Observer的构造函数或observe方法可传入选项,每个回调可定制观察内容。
addEventListener()无法做到。 -
可通过Observer的
disconnect()或unobserve()方法轻松停止多回调监听。 -
可提供如
takeRecords()方法,立即获取相关数据,无需等待事件触发。 -
Observer为单一用途,无需指定事件类型。
Observer 和 EventTarget
有这些共同点:
-
两者都可以在创建时自定义。
-
两者都可以批量处理事件并随时分发。
EventTarget不需要是同步的; 它们可以使用微任务时机、空闲时机、动画帧时机等。 你不需要Observer就能实现特殊的时机或批处理。 -
EventTarget和Observer都不需要参与 DOM 树 (冒泡/捕获和取消)。 最常见的EventTarget是 DOM 树中的Node, 但许多其他事件是独立的; 例如IDBDatabase和XMLHttpRequestEventTarget。 即使使用Node, 你的事件也可以设计为不冒泡和不可取消。
IntersectionObserver
作为 EventTarget
子类的用法示例:
const io= new ETIntersectionObserver( element, { root, rootMargin, threshold}); function listener( e) { for ( const changeof e. changes) { // ... } } io. addEventListener( "intersect" , listener); io. removeEventListener( "intersect" , listener);
与 Observer 版本相比:
-
用相同选项观测多个元素更困难;
-
无法立即请求数据;
-
移除同一事件的多个监听器更麻烦;
-
作者必须提供多余的
"intersect"事件类型。
与 Observer 版本相同的是:
-
仍然可以批处理;
-
时机相同(基于 JavaScript 事件队列);
-
作者仍然可以自定义监听内容;
-
事件不会经过捕获或冒泡。
这些方面,无论哪种设计都能实现。
另见:
8. Web IDL、类型与单位
8.1. 合理使用数值类型
如设计API需用数字,建议采用如下[WEBIDL]数值类型,除非有特定原因:
unrestricted double-
任意JavaScript数字,包括无穷和NaN
double-
任意JavaScript数字,排除无穷和NaN
- [
EnforceRange]long long -
JavaScript数字,范围-263到263,四舍五入为整数。超出范围将抛出
TypeError。 - [
EnforceRange]unsigned long long -
JavaScript数字,范围0到264,四舍五入为整数。超出范围将抛出
TypeError。
JavaScript只有一种数值类型Number:
IEEE 754双精度浮点,含±0、±Infinity和NaN。
[WEBIDL]数值类型
规定了如何将任意JavaScript数字转换为具有特定属性的子集。
无论是方法还是属性setter,数字传给IDL接口时会应用这些规则。
如需额外规则,可在算法中指定。
octet
(8位,[0, 255]),会取数字的模。如将300转为octet,结果为300模255,即45,可能令人意外。
可用[EnforceRange]octet,超出范围则抛TypeError;
或[Clamp]octet,超出范围则截断为最大值(如300变255)。
bigint
仅用于期望超出253或小于-253的值。
API不应同时支持BigInt
和Number,
无论是多态支持还是分别提供只差类型的API。
否则会因隐式转换丢失精度,违背BigInt的初衷。
8.2. 合理表示字符串
设计操作字符串的Web平台特性时,除非有特定原因,建议用DOMString。
大多数字符串操作无需解释字符串中的码元,因此DOMString是最佳选择。
特定场景下可用USVString或ByteString,详见下文。[INFRA] [WEBIDL]
USVString
是Web IDL用于表示标量值字符串的类型。
若字符串常用算法操作标量值
(如百分号编码),
或操作无法处理输入中的代理项
(如需传递字符串到原生平台API),
应用USVString。
反映IDL属性
若内容属性定义为包含URL
(如
href),
应用USVString。
[HTML]
ByteString
仅用于表示如HTTP协议这类不区分字节和字符串的数据,不是通用字符串类型。如需表示字节序列,请用Uint8Array。
8.3. 时间度量统一用毫秒
如果你设计的API需要时间度量,统一使用毫秒作为单位。
即使秒(或其他单位)在API领域更自然,统一用毫秒能保证API之间的互操作性。 这样作者无需转换时间单位,也不用记住不同API用何种单位。
这种惯例起源于setTimeout()
和
Date
API,
并延续至今。
注意:高精度时间通常用浮点数表示分数毫秒, 而不是用更小单位如纳秒的整数。
8.4. 用合适类型表示时间和日期
在平台上表示日期时间时,使用DOMHighResTimeStamp类型。
DOMHighResTimeStamp
可用于比较时间戳,不受用户本地时间影响。
DOMHighResTimeStamp
的值表示以毫秒为单位的时间值。
详细见[HIGHRES-TIME]。
不要用JavaScript的Date
类来表示具体的日期时间值。
Date
对象是可变的(值可更改),且无法使其不可变。
Date,
见下述链接:
-
Frozen date objects?(es-discuss)
-
从Web IDL移除Date (Web IDL Bugzilla)
8.5. 错误用Error或DOMException表示
Web API中的错误应用ECMAScript错误对象(如Error)
或DOMException表示,
无论是异常、Promise拒绝值还是属性。
9. 封装设备/浏览器能力的API
现在Web平台正开发与设备交互的新API。 例如,作者希望用Web连接 麦克风和摄像头、 通用传感器(如陀螺仪、加速度计)、 蓝牙和 USB外设、 汽车等。
同样也适用于主机系统或外部服务可选提供的能力,包括需用户付费访问的能力。
这些能力可由底层操作系统或本地第三方库提供。 API可作为“封装器”抽象原生功能, 不引入重大复杂性,同时保证浏览器API安全性。 这些称为封装API(wrapper APIs)。
本节包含设计此类能力API时需考虑的原则。
9.1. 不要暴露多余的能力信息
遵循数据最小化原则, 如需让网站访问能力信息,只暴露完成任务所需的最少数据。
首先,认真考虑是否真的需要暴露这些信息。 评估你的用户需求能否用更弱的API满足。
暴露设备存在、附加信息或设备标识符,都会增加用户隐私受损风险。
用户拒绝访问设备或能力时,不应暴露该能力是否存在。 在此场景减少信息泄露比授权后更重要。
信息越具体, 指纹数据 被网站收集的可能性越大, 信息也越可能涉及用户敏感内容。
如无法设计更弱的API,暴露设备信息时应遵循以下原则:
- 限制标识符中的信息
-
在向 Web 平台公开的设备标识符中只包含尽可能少的可识别信息。 可识别信息包括品牌、厂商与型号等。 通常可以使用随机生成的标识符来替代。 确保你的标识符不可被猜测且不被重复使用。
- 让用户保持控制
-
当用户选择清除浏览数据时, 确保清除任何存储的设备标识符。
- 将敏感信息置于用户许可之后
-
如果无法以匿名方式创建设备标识符, 则应限制对其的访问。 确保你 帮助用户做出良好决策 关于网页访问这些信息的情况。
- 将标识符与同源模型绑定
-
为每个有权访问同一物理设备的源创建不同的标识符。
如果同一设备被同一源多次请求, 则为其返回相同的标识符 (除非用户已清除其浏览数据)。 这允许作者避免拥有同一设备的多个副本。
- 在必要时可持久化
-
如果获取设备标识符耗时较长, 请确保作者能够存储在一次会话中生成的标识符以便在之后的会话中使用。 可以通过确保生成标识符的过程对同一设备、 对每个源始终产生相同的值来实现。
另请参阅:
9.2. 谨慎开放设备选择与枚举API
尽量避免枚举设备。如无法避免,暴露的信息要最少。
如API暴露多个设备的存在、能力或标识符, § 9.1 不要暴露多余的能力信息中的风险会按设备数倍增。 同理,应评估你的用户需求能否用更弱API满足。[LEAST-POWER]
如API目的是让用户选择设备, 可能无需向脚本暴露设备列表。 可用由用户代理提供的设备选择器API即可。 这种API:
-
让用户保持控制,
-
帮助用户理解 他们的决定,
-
默认情况下不向外暴露任何关于用户环境的指纹识别数据,且
-
每次仅暴露关于一个设备的信息。
在设计允许用户选择设备的 API 时,可能也需要表明有可供选择的设备存在。这会向网站暴露关于用户环境的一条指纹识别信息,因此它不如没有此类功能的 API 安全。确保该 API 能在用户界面上提供足够的信息,使用户能够 做出良好决策。
RemotePlayback
接口不暴露可用远程播放设备列表。
而是允许用户通过用户代理的设备选择器选取设备。
它能让网站检测是否有可用远程播放设备, 网站可据此显示或隐藏用于弹出选择器的控件。
权衡在于这种API让作者多获得一点信息, 但能让UI不易让用户困惑——只有有设备时才显示弹出按钮。
如必须暴露设备列表, 尽量只暴露满足用户需求的最小子集。
比如,API可让网站请求筛选或约束过的设备列表, 这样设备数量更少。 但如允许作者用不同约束多次请求,仍可能获得全部列表。
最后,如必须暴露某类设备的完整列表, 请务必严格规定设备的排列顺序。 这样可减少互操作性问题,也有助于缓解指纹风险。 (排序也可能暴露信息,见指纹缓解 § 6.2 标准化)
注意:API不应按实现自定义顺序暴露完整设备列表, 但为Web兼容性有时不得不这样做。
9.3. 以用户需求为基础设计,而非底层能力
将原生能力带入Web时,应以用户需求为基础暴露新能力。
避免直接翻译现有原生API到Web。
而应分析原生API能实现的功能及对应的用户需求,设计满足这些需求的API,即使实现依赖原生API。
尤其要注意不要直接暴露底层原生API的生命周期和数据结构。如有可能,考虑对新硬件的灵活适配。
也就是说,新API设计应以预期用法为依据,而非底层硬件、设备或原生API当前实现方式。
9.4. 积极保障安全
将原生能力带到Web平台时,应尽量防御性设计。
原生能力引入Web平台会带来诸多影响。 用户可能不希望网站知道其计算机有何能力。因此,任何跨越逻辑origin边界的访问都应权限控制。
例如,若设备可存储状态,且该状态可被多个origin同时读取, 则一组可读写状态的API实际上就是一个破坏Web同源模型的旁路通道。
因此,即使设备允许非独占访问,也建议按origin强制独占访问,甚至进一步限制为仅当前激活标签页可访问。
此外,API设计时应确保应用能优雅处理物理中断,比如设备被拔掉。
9.5. 用Web平台原则适配原生API
将原生操作系统API适配到Web时,需确保新Web API遵循Web平台设计原则。
- API应可多平台实现
-
设计封装API时,要考虑不同平台如何提供其功能。
理想情况下所有实现都完全一致,但有时确实需要暴露仅支持部分平台的选项。 如遇此情况,应说明作者如何编写可在所有平台运行的代码。 见§ 2.6 新特性应可检测。
- 底层协议应开放
-
与外部硬件或服务交换数据的API,不应依赖闭源或专有协议。 依赖非开放协议会削弱Web开放性。
- API设计应兼容离线场景
-
如API依赖远程服务,需保证用户因各种原因无法访问服务时API仍能正常工作。
- 避免增加指纹追踪面
-
封装API可能无意间增加用户被指纹追踪风险。 请参考TAG关于未授权追踪的说明。
10. 其他API设计考量
10.1. 为新功能支持polyfill
polyfill对新功能推广极为有益。 技术架构组TAG关于Polyfill与Web演进的建议, 在开发新功能时应参考,特别包括:
-
“可polyfill”不是必须,但确实有好处
-
鼓励开发polyfill
10.2. API尽可能支持dedicated worker
开放新功能时,请考虑是否适合暴露给dedicated worker(通过DedicatedWorkerGlobalScope接口)。
许多功能本可直接在dedicated worker上运行,不支持会限制用户以非阻塞方式运行代码。
支持dedicated worker时存在一些挑战,尤其是需要用户输入(权限弹窗、选择器等)时。 虽然这些困难可能让规范作者不愿支持dedicated worker,但仍建议设计时考虑dedicated worker支持, 以免后续暴露API时遇到不必要的障碍。
10.2.1. 部分API仅暴露给dedicated worker
开发者更倾向于用简单代码,因此通常会用API的最简方式。
应避免添加阻塞渲染的功能。见§ 3.4 避免阻塞渲染的功能
如API最简用法易导致渲染阻塞或页面“卡顿”,用户体验会变差。 (在低性能设备上问题更突出,这些设备更多被弱势群体使用。请记住,Web是为所有人服务。)
因此,易阻塞主线程的API,不应暴露在Window接口。
限定这类API只在DedicatedWorkerGlobalScope接口暴露,
能让开发者默认获得最佳用户体验。
ScriptProcessorNode
在Web Audio API中已被AudioWorklet替代,
因为主线程使用ScriptProcessorNode
经常导致用户体验很差。[WebAudio]
10.3. 仅纯计算能力可全局暴露
开放新功能时,请评估是否适合在所有环境全局暴露
(通过[Exposed=*]
注解或所有全局接口)。
只有纯计算能力才可全局暴露。 即不执行I/O,也不影响用户代理或用户设备状态。
TextEncoder
接口将字符串转为UTF-8字节,是纯计算接口,通用性强,应该全局暴露。
localStorage会影响用户代理状态,因此不应全局暴露。
技术上console
可能影响用户代理(如开发者工具日志)或设备(如写日志文件),
但这些影响无法被运行代码观察,且console全局可用的实用性远大于弊端。
此外,依赖事件循环的功能不应全局暴露。 并非所有全局环境都有事件循环。
timeout
方法依赖事件循环,不应全局暴露。
其它AbortSignal
功能为纯计算,可全局暴露。
[Exposed=*]
注解也需谨慎使用。
如某功能在未全局暴露的其它功能缺席时用处不大,则默认也不全局暴露。
Blob
接口是纯计算,但Blob对象主要用于I/O或由I/O产生。
按保守原则,Blob不应全局暴露。
10.4. 规范添加新数据格式
新增数据格式时,务必定义对应MIME类型,并扩展现有API支持此类型。
Web新能力有时涉及新增数据格式,如图片、视频、音频、文本或浏览器需处理的其他数据类型。 新格式应使用严格验证的标准MIME类型,文本类新格式仅支持UTF-8编码文本。
虽有历史媒体格式未严格要求MIME类型(有时依赖头部检测), 这仅用于兼容旧格式,不应作为新格式设计标准。
规范作者还应将新格式集成进现有API,使其在输入(如ReadableStream解码)和输出(如WriteableStream编码)都安全可用。
例如添加新图片格式时,先定义新MIME类型,再为HTMLImageElement添加对应解码器(及编码器),
并扩展HTMLCanvasElement.toBlob()和HTMLCanvasElement.toDataURL()等输出接口。
浏览器为兼容旧格式支持MIME类型嗅探,但不建议扩展模式匹配算法, 因为有安全隐患。新格式建议严格使用MIME类型。
新MIME类型应有规范,并向IANA注册。
10.5. 优先扩展已有manifest文件而非新建
如果你的功能需要manifest文件, 请先考虑是否可以扩展现有的manifest规范。
新的Web特性应尽量做到自包含和自描述,理想情况下不需要额外的manifest文件。 现有的一些manifest文件包括:
-
Web App Manifest,包含与Web应用相关的功能。
-
Payment Method Manifest,用于Web支付API中的支付方式。
-
Publication Manifest,用于部分Web出版物工作组标准。
-
Origin Policy,用于设置安全策略。
我们鼓励大家优先扩展已有的manifest文件。 应尽量将变更纳入原规范,或至少与规范编辑讨论扩展内容。 讨论将有助于达成更优的设计,并更好地与平台集成。
设计manifest新键值时,请确保它们确实有需求(即能实现明确的用例)。 同时请检查是否已有类似的键。如果现有键值对基本满足需求,优先协作扩展原规范以支持你的用例。
但如果你的功能需要大量特定领域的复杂元数据,则新建manifest可能是合理的。
如果manifest文件的领域与现有manifest不同,可能需要新建manifest文件。 例如,抓取时机不同,或manifest复杂度较高。应用元数据应加到Web App Manifest或作为其扩展。 专用于特定应用或需与非浏览器互操作的manifest,可能需采用不同方式。 Payment Method Manifest、Publication Manifest和Origin Policy就是此类例子。
举例来说,如果你只有一条元数据,即使抓取时机与现有manifest不同,也建议使用现有manifest(或最好让设计不需manifest)。 但如确实需要大量特定领域的复杂元数据,新建manifest则是合理选择。
注意:所有情况下,命名规范应保持一致(参见§ 12 命名原则)。
注意: 按原则,现有manifest都采用小写、下划线分隔命名。 有时需要在DOM API中复用manifest中的字典,这时名称需转换为驼峰式。 例如image resource。 因此,如果一个键可以明确用单词表达,建议这样做。
10.6. 序列化时要考虑消费者
当你添加或扩展涉及解析器或序列化器的功能时,应考虑序列化的效果。序列化结果的相关方包括:-
用户——因为序列化结果可能直接展示给最终用户
-
工具——可能以多种方式依赖序列化输出(比如判断解析器是否需要纠正输入错误)
-
Web API——因为序列化结果可能作为参数传入其它Web平台API
要考虑语言特定的预期——例如,在某些语言中,空白的有无可能很重要。 不同语言对浮点数精度的接受程度也有差异。 序列化结果:
-
应符合开发者预期(比如序列化CSS属性时,输出不应与CSS作者手写的差异很大)
-
幂等性——解析器输出的序列化结果应该能反复解析-序列化后仍得到自身
-
不应加剧错误积累——把API序列化输出反复输入API,内部状态应保持一致
10.7. 确保功能对开发者友好
任何新功能都应对开发者友好。 虽然“友好”难以量化,至少要考虑以下几点。
异常中的错误文本应当通用,而开发者工具(如开发者控制台)中的错误信息必须有意义。 当开发者遇到错误时,信息要具体,不能过于宽泛。
理想情况下,面向开发者的错误信息应包含足够内容,帮助定位问题。
声明式特性(如CSS),实现时可能需额外工作以提升调试性。 规范中定义调试要求不仅让功能更友好,也保证用户开发体验一致。
一个将调试性作为规范一部分的好例子是Web Animations。
10.8. 使用最佳加密算法,并预期其不断演进
只采用经过安全领域专家公正评审的加密算法, 并确保所选算法经过验证且为最新。 加密协议和算法不仅可能过时或变得不安全,还会快速演进。
10.9. 不要通过客户端提示暴露新信息
使用Client Hints时,不要暴露网页本身无法访问的新信息。
客户端提示是重要优化手段,但不能成为暴露信息给网站的唯一方式。正如 RFC 8942 §4.1 中定义Client Hint时所说:
因此,依赖本文件定义Client Hint头的特性 不得提供应用程序本不能通过用户代理获得的新信息,如已有请求头、HTML、CSS或JavaScript。
如你要添加新客户端提示,且其暴露了网页无法通过其它方式获取的信息, 请优先考虑通过API暴露这些信息。
11. 编写高质量规范
本文档主要关注Web API设计, 但API设计者也应撰写他们所设计API的规范。
11.1. 明确规范要求的目标读者
既要描述作者如何用你的API写出优质代码,也要说明API实现者如何处理写得不好的代码。
与其它平台相比,Web设计目标之一是对格式不规范内容的强容错, 使得用老标准的网页在新用户代理上仍可浏览, 也降低了作者的学习门槛。
为此,Web规范作者需描述如何处理格式不规范内容,以及规范内容。
实现者需要理解“支持的语言”,而不仅仅是作者应使用的“符合规范语言”,因为前者更复杂。
11.2. 规范要完整、避免歧义
规范功能时,要确保信息足够,让作者不必为不同实现编写不同代码。
规范不够具体时,实现者可能做出不同选择,导致作者需写额外代码适配差异。
实现者不应依赖其它实现细节来避免上述问题。 规范本身应足够完整和清晰。
注意:这并不意味着实现不能有不同的渲染,或权限弹窗等用户界面不一样。
注意:如规范信息不明确,应由实现者反馈bug。
11.2.1. 算法需定义清楚
算法应写得清晰简明。
最常见的算法写法是明确的步骤序列,通常类似伪代码。
showModal()
方法以编号步骤描述,清楚说明何时抛出异常,何时运行规范其它部分的算法。
写步骤序列时,想象它是一段函数式代码。
-
明确输入输出,为算法和变量命名,明确算法何处返回结果或错误。
-
尽量避免写有副作用的算法。
在详细说明前先总结算法目的,让读者决定是否需要读步骤细节。 例如下面这些步骤,确保每个顶级浏览上下文最多有一个待处理的X回调。
有时纯步骤序列不是写算法的最佳方式。 比如,可能需要定义或复用正式语法/文法以避免重复,或定义状态机专用状态。 用这些额外结构时,也应遵循前述建议。
尽量按实际实现方式描述算法,这可能让规范书写更难,但有助于实现者直接对照规范实现。 否则不同实现可能做出不同决策,导致后续特性只能在部分实现上可行。
CSS选择器在阅读时是从左到右,但实际实现是从右到左匹配。 这样可以更快判断最具体项是否命中,避免无谓工作。 CSS选择器匹配算法按此方式描述,而不是按CSS作者常见的阅读顺序。
另见:
11.2.2. 用显式标志表示状态
写算法时,避免用文字描述状态,应用显式标志。
显式标志能清楚指明不同错误情况下状态是否变化,以及标志指示的状态何时重置。
11.3. 兼顾互操作性与可实现性
规范功能如同所有工程工作,都需权衡取舍。
有时,即使所有人都尽力,也无法找到可互操作规范,又被所有实现者认同可实现的方案。
选择方案时应以最终用户利益为先。
首先,分析可实现性问题的本质。 可能实现者认为存在用户风险,决定不实现该特性。 此时,最好不要规范该特性,已发布的实现也应撤回。 注意撤回特性可能导致用户与作者困惑。
有时同一API所有引擎都能实现,但行为无法完全互操作。 只要行为有差异,就要考虑最终用户影响。 这种方式的不足是行为差异无法特性检测。 但如果未来实现能统一行为,网站无需更新即可受益。
注意:作者可能认为主流实现的行为是正确的,其它行为是bug,这反而巩固了主流实现地位。
backdrop-filter属性在不同实现间有明显可见的行为差异。 当前维护者认为共享API的好处大于互操作性代价。
如行为差异严重且无法统一,可能更适合规范不同API,各实现者选择可实现的替代方案。
这种方式的风险是:如果实现最终能统一行为,则需要长期支持多个API,增加开发者负担和维护成本。
针对同一用例暴露两个API,迫使作者通过特性检测为不同浏览器编写不同代码。
注意:风险在于作者可能认为主流实现支持的API才是“正确”的,或根本不知道其它实现支持的API,从而进一步巩固主流实现地位。
如选择此方案,应尽量减少API差异。可考虑将非标准部分设为大规范的小组件。
[ENCRYPTED-MEDIA]和[payment-request]规范都通过将非标准组件(如内容解密模块和支付方式)与主API解耦,来最小化API差异。
有时标准组织会规范API,明知部分实现者不愿实现。 作者可用特性检测,仅在API可用时使用,用户在支持API的环境下可受益。 这种结果总有问题,尤其只有一个实现者愿意实现时最糟。 有些标准组织明文禁止规范只有单一实现者感兴趣的功能。 即使不受此限制,也强烈不建议规范仅有单一实现者的功能。
还有可能上述所有方案都不可行。虽最佳选择可能是不规范该功能,但也有风险:某些实现者可能会以非标准API发布该功能。
11.4. 避免monkey patch
monkey patch是指在已有规范基础上叠加新功能,以扩展、重写或修改原规范行为。 Monkey patch通常被视为不良实践,应尽量避免,原因如下,但有时不得不采用(见如何应对不可避免的monkey patch)。示例:规范A定义某内部算法,规范B直接覆盖或修改该算法功能,而不是用公开的扩展点。
monkey patch本质上假设底层功能不会改变,这会导致多个问题:
-
如原规范变动,monkey patch内容可能不再适用,导致失效或困惑。
-
多个规范对同一规范做monkey patch时,应用顺序不同可能导致行为不一致。
-
原规范作者可能不知道你的monkey patch,错失协作修复机会。更糟的是,所修改行为可能与其它问题或行为关联,使问题更复杂。
-
实现者维护代码时仅参考原规范,可能误将monkey patch行为当作bug而回滚。
Wikipedia还描述了monkey patch的其它陷阱。
11.4.1. 如必须monkey patch该怎么做
有时monkey patch不可避免(如新规范设计阶段)。如必须monkey patch现有规范,请确保:-
在你的规范中,明确标记monkey patch为对其它规范的临时变更,用类似如下语言:
-
用引用原文或链接定义项明确指出你要修改的内容。替换整个定义时用链接,修改编号步骤时包含当前编号,但不要只用编号(编号会变)。如要修改定义中多个位置,可将整个定义粘贴到
<blockquote>中,用<ins>和<del>标记变化。在HTTP fetch第4.2步前,“如request的redirect mode为"follow",则将request的service-workers mode设为"none"。”插入如下步骤:
-
新的第一个步骤。
对于CSS2.1块级格式化[CSS2],CSS 2 § 10.3.3中“over-constrained”计算规则应被此处指定的对齐规则取代,相应margin属性的used值不再为over-constraint做修正。
在定义
和URLSearchParams.delete()时,追加URLSearchParams.has()optional USVString value参数:partial interface URLSearchParams {undefined delete (USVString name ,optional USVString value );boolean has (USVString name ,optional USVString value ); };按如下方式修改font-size-adjust定义:
Name: font-size-adjust Value: none | [ ex-height | cap-height | ch-width | ic-width | ic-height ]? [ from-font | <number> ] Initial: none Applies to: all elements and text Inherited: yes Percentages: N/A Computed value: a number or the keyword none关键词none,或一组度量关键词和<number>Canonical order: per grammar Animation type: 如关键词不同则离散,否则 by computed value type -
-
保持monkey patch简短。如对算法增补多步,建议在规范中定义单独算法,由monkey patch调用。
-
替换或添加算法步骤时,务必使新步骤能被编辑者直接原样粘贴到上游算法中。控制流如“return”或“abort”应返回或终止整个上游算法,而非仅monkey patch部分。
-
在本社区审查并达成共识后,提交issue至原规范,请上游社区审查你的monkey patch。社区可能建议更优方案,需认真对待。他们或许能告知现有扩展点或快速创建新扩展点,让你比预期更快移除monkey patch。
-
更新上述
<p class="issue">块,指向你提交的上游issue。 -
如获得足够支持可合并到上游规范,配合规范维护者完成合并。
-
上游规范集成后,从你的规范中移除monkey patch。
注意,monkey patching不同于“模块化”,后者以自包含方式扩展技术,不影响其它底层规范(如CSS模块),被视为良好实践。
通常也可以用WebIDL的partial interface、 partial dictionary等方式扩展规范。 但即使如此,也强烈建议与被扩展规范作者协作。
12. 命名原则
名字的含义来源于:
-
标识(名字本身)
-
使用(人们随着时间理解名字的方式)
-
上下文(如左侧的对象)
12.1. 用常用词命名
API命名必须易读且用美式英语。 要记住大多数Web开发者并非英语母语者。 名字应尽量用大多数英语使用者初见即能理解的常用词汇。
优先考虑易读性,而不是简洁性。
避免使用缩写,
除非该缩写极为常见且易于理解。
例如 UIEvent,其中 "UI" 代表用户界面。
但请注意,
更短的名称往往更清晰。
比如,
在定义 API 的规范中,可能适合使用技术术语或众所周知的专业术语。
12.2. 用ASCII字符命名
名字必须遵守本地语言约束,例如CSS标识符规则等, 并应在ASCII范围。
12.3. 命名要广泛征询意见
API命名要广泛咨询。
你可能会在意想不到的地方找到好名字或灵感。
-
其它平台或主流库对类似API怎么命名?
-
问终端用户和开发者,他们如何称呼API操作或处理的事物。
-
查阅其它Web平台规范,并向相关领域的人员征询意见。
-
同时考虑命名是否具有包容性。
特别关注那些有明确原理说明的建议。
遵循Web一致性命名
在为在其它技术栈中也有暴露的特性或API命名时,应优先采用Web生态系统的命名约定,而不是其他社区的约定。NFC标准用media指Web中称为MIME
type的内容。
这种情况下,Web NFC的特性或API命名应优先采用与MIME type一致的命名。
使用包容性语言
应尽量使用包容性语言。
比如, 应用blocklist和allowlist代替blacklist和whitelist, 用source和replica代替master和slave。
如需描述泛指角色,如作者或用户, 应用通用代词“they”、“their”等。 例如:“A user may wish to adjust their preferences”。
12.4. 用描述用途的名字
命名应反映其用途,而不是实现方式。
反映用途的名字更具前向兼容性。 Web上的API很难移除, 所以名字应比实现细节更长久。
尤其要避免用 代码名、 品牌名、 或底层技术细节命名。 这些容易过时,将来可能需要替换。
Remote Playback API没有用它受启发的专有系统名(如Chromecast或AirPlay)命名, 而是选用描述API功能的通用词。[REMOTE-PLAYBACK]
WebTransport API实现了QUIC协议的网络能力, 但名称反映的是更通用的数据传输能力。 [WebTransport][RFC9000]
12.5. 一致命名事物
命名方案应追求一致性,避免混淆。相关名字的集合应在以下方面一致:
-
词性(名词、动词等)
-
否定——比如一组名字应都描述允许的内容,或者都描述被拒绝的内容
布尔属性 vs. 返回布尔值的方法
布尔属性、选项或API参数如询问其参数,
不应以is为前缀;而无副作用、作用相同的方法应以is为前缀,以与平台其它部分一致。
遵循现有API的大小写规则
虽然历史上并非总能统一,Web平台API设计已形成如下规则:
| 大小写规则 | 例子 | |
|---|---|---|
| 方法和属性 (Web IDL属性、操作和字典键) | 驼峰式 | createAttribute()compatMode
|
| 类和mixin (Web IDL接口) | 帕斯卡式(首字母大写) | NamedNodeMapNonElementParentNode
|
| API中的缩写词 | 全大写,但方法或属性首词例外 | HTMLCollectioninnerHTMLbgColor
|
| API中重复缩写词 | 同样规则 | HTMLHRElementRTCDTMFSender |
| “identity”/“identifier”的缩写 | Id,但方法或属性首词例外
| getElementById()pointerIdid
|
| 枚举值 | 小写,用连字符分隔 | "no-referrer-when-downgrade"
|
| 事件 | 小写,连写 |
|
| HTML元素和属性 | 小写,连写 | figcaptionmaxlength |
| JSON键 | 小写,下划线分隔 | short_name |
ismap属性在
img元素上映射为
isMap
属性(在HTMLImageElement上)。
JSON键规则适用于通过HTTP发送或磁盘存储的特定JSON文件格式,不适用于一般JS对象键。
重复缩写在平台中尤其不统一,典型反例有XMLHttpRequest和
HTMLHtmlElement。
不要效仿,应始终大写缩写,即使重复。
工厂方法名以create或from开头
工厂方法命名应以create或from开头,后面可加更具体名词。
如工厂方法构造新空对象,用create前缀;
如从已有数据创建对象,用from前缀。
工厂方法应是例外,仅在确有合理理由时使用。
典型例子是创建对象还需关联父对象,比如document.createXXX()。
如需从源对象转换为目标对象,用from前缀。
比如Foo.fromBar()表示用Bar对象创建Foo对象。
常见通用工厂方法命名为create()或from()。
避免发明其它前缀或用遗留前缀,除非为保持同一对象下现有工厂方法一致。
比如document.initXXX(),但新工厂方法不应再用此约定。
12.6. 危险特性应有警示
如有可能,削弱开发者保障的特性应以“unsafe”开头以便警示。
比如,
内容安全策略(CSP)
可防止某些内容注入漏洞,
但CSP也有削弱自身保障的特性,如unsafe-inline关键字允许内联脚本,降低CSP的保护能力。
13. 其它资源
其它关于如何编写规范的建议:
致谢
本文档内容集结于TAG现任和前任成员在TAG 设计评审过程中的经验。 感谢所有向我们申请设计评审的人。
TAG感谢 Adrian Hope-Bailie, Alan Stearns, Aleksandar Totic, Alex Russell, Alice Boxhall, Andreas Stöckel, Andrew Betts, Anne van Kesteren, Benjamin C. Wiley Sittler, Boris Zbarsky, Brian Kardell, Charles McCathieNevile, Chris Wilson, Dan Connolly, Daniel Ehrenberg, Daniel Murphy, David Baron, Domenic Denicola, Eiji Kitamura, Eric Shepherd, Ethan Resnick, fantasai, François Daoust, Henri Sivonen, HE Shi-Jun, Ian Hickson, Irene Knapp, Jake Archibald, Jeffrey Yasskin, Jeremy Roman, Jirka Kosek, Kenneth Rohde Christiansen, Kevin Marks, Lachlan Hunt, Léonie Watson, L. Le Meur, Lukasz Olejnik, Maciej Stachowiak, Marcos Cáceres, Mark Nottingham, Martin Thomson, Matt Giuca, Matt Wolenetz, Michael[tm] Smith, Mike West, Nick Doty, Nigel Megitt, Nik Thierry, Ojan Vafai, Olli Pettay, Pete Snyder, Philip Jägenstedt, Philip Taylor, Reilly Grant, Richard Ishida, Rick Byers, Rossen Atanassov, Ryan Sleevi, Sangwhan Moon, Sergey Konstantinov, Stefan Zager, Stephen Stewart, Steven Faulkner, Surma, Tab Atkins-Bittner, Tantek Çelik, Tobie Langel, Travis Leithead, 和 Yoav Weiss 对本文档及其前身HTML设计原则的贡献。
特别感谢 Anne van Kesteren 和 Maciej Stachowiak, 编辑了HTML设计原则文档。
如果你对本文档有贡献但未列名,请通知编辑以便更正遗漏。