主题
HTML5 概览
把 HTML5 当成“一堆绘图 API”是这个领域最常见的误解。它的本质是文档与浏览器能力的标准:页面结构、表单、媒体、存储、线程、设备能力都归它管,而像素级绘制是后面几个板块的事。这一节先建立地图,再讲怎么做能力检测。
它到底是什么
“HTML5”今天指的是 HTML Living Standard——一份持续演进的标准,而不是一个冻结的版本号。2019 年 W3C 与 WHATWG 签署协议后,HTML 与 DOM 的规范由 WHATWG 单一维护,浏览器按同一份标准实现。所谓“HTML5 特性”,实际上是三条主线叠在一起:
| 主线 | 内容 | 本节是否覆盖 |
|---|---|---|
| 文档结构 | 语义化元素、<template>、<dialog>、<details>、表单与约束校验 | 覆盖 |
| 平台能力 | Web Storage、IndexedDB、File / 拖放、Web Worker、Observer、剪贴板、全屏 | 覆盖 |
| 绘制与媒体 | Canvas 2D、SVG、WebGL、WebGPU、音视频编解码 | 音视频(媒体元素)覆盖,绘制交给后续板块 |
一句话分辨
它是不是“界面的一部分”?(文本要能选中、能被读屏、能被 CSS 主题影响)→ 用 HTML + CSS。只有在“界面之外还需要一块画布”时,才轮到 Canvas / WebGL / WebGPU。
与图形技术的关系
按 六大图形技术选型 的分层,HTML5 位于最上层:它不描述像素,只描述结构与语义,剩下的事交给浏览器的排版引擎(Layout → Paint → Composite)。
| 维度 | HTML5(DOM) | Canvas / WebGL / WebGPU |
|---|---|---|
| 图形表示 | 元素树(保留模式) | 像素结果(立即模式) |
| 局部更新 | 改一个属性即可 | 通常整帧重绘 |
| 事件与命中检测 | 每个元素自带事件 | 自己做坐标换算 |
| 可访问性 | 天然可读、可选中、可打印 | 默认是一张图,要额外提供替代信息 |
| 适合的数量级 | 数百~数千个元素 | 数千~数十万以上 |
真实项目几乎都是组合使用:用 HTML 搭界面,用 Canvas / GPU 画主体。所以这一节并不是“入门前的热身”,它是所有图形示例的宿主环境——Canvas 的尺寸陷阱、媒体流的播放控制、把耗时计算丢给 Worker,全都发生在 DOM 这一层。
本节地图
| 页面 | 关注点 | 核心 API | 什么时候翻它 |
|---|---|---|---|
| 语义化标签与文档结构 | 结构与大纲 | header / nav / main / article / section / dialog / details | 写页面骨架、纠结用 div 还是 section 时 |
| 表单与约束校验 | 输入与校验 | ValidityState、setCustomValidity、FormData、新 input 类型 | 做表单、需要“不用 JS 校验”时 |
| 音频、视频与媒体 API | 播放与流 | HTMLMediaElement、MediaStream、captureStream、AudioContext | 播放媒体、把画布变成视频源时 |
| 图形相关元素 | 会“画东西”的标签 | canvas、svg、picture、srcset、createImageBitmap | 选图片格式、搭画布时 |
| Web 存储:本地与数据库 | 持久化 | localStorage、IndexedDB、Cache Storage | 保存用户数据、离线缓存时 |
| 拖放与文件 API | 文件进出 | DragEvent、File、FileReader、Blob URL | 做上传、拖拽、粘贴图片时 |
| 现代浏览器 API | 平台能力 | Worker、ResizeObserver、Clipboard、URL | 需要后台计算、监听尺寸/可见性时 |
| 可访问性与 ARIA | 人人可用 | role、aria-*、焦点管理 | 做可交互组件、给图形补替代文本时 |
眼见为实:先量一量自己的浏览器
下面的示例会对当前环境做一次全面体检,并渲染成一张支持度表。它本身就是“怎么写能力检测”的范例。
HTML5 / 图形能力检测表按 API 特征逐项检测,WebGPU 会异步二次确认
examples/html5/feature-detect.jsjs
/**
* 【HTML5 · 能力检测】盘点当前浏览器到底具备哪些能力
* ------------------------------------------------------------------
* 1. 用 API 特征检测代替 UA 判断,同一份代码在任何引擎上的结论都可信;
* 2. 检测函数只在“被调用时”才访问 DOM,模块顶层不碰 window / document;
* 3. WebGPU 这类“接口存在但可能拿不到适配器”的能力要异步二次确认。
*/
import { hint } from '../shared/runtime.js'
/** 探测 Web Storage:隐私模式下 setItem 会直接抛 QuotaExceededError。 */
const canStore = (store) => {
try {
store.setItem('__probe__', '1')
store.removeItem('__probe__')
return true
} catch {
return false
}
}
const hasContext = (kind) => !!document.createElement('canvas').getContext(kind)
const GROUPS = [
['文档结构', [
['语义化元素', '<article> / <figure> / <time>', () => 'content' in document.createElement('article')],
['details / summary', '原生折叠面板', () => 'open' in document.createElement('details')],
['dialog', 'showModal() 模态框', () => typeof document.createElement('dialog').showModal === 'function'],
['template', '惰性克隆的内容模板', () => 'content' in document.createElement('template')],
['自定义元素', 'customElements.define()', () => typeof customElements !== 'undefined' && 'define' in customElements],
['Shadow DOM', 'attachShadow() 样式隔离', () => 'attachShadow' in Element.prototype]
]],
['表单与校验', [
['新输入类型', 'date / time / color / range', () => { const input = document.createElement('input'); input.type = 'date'; return input.type === 'date' }],
['约束校验 API', 'checkValidity / ValidityState', () => 'checkValidity' in document.createElement('input')],
['表单序列化', 'FormData 与 form.elements', () => typeof FormData !== 'undefined'],
['output 元素', '声明式结果显示', () => 'htmlFor' in document.createElement('output')],
[':user-invalid', '失焦后才提示错误', () => typeof CSS !== 'undefined' && CSS.supports('selector(:user-invalid)')]
]],
['媒体', [
['视频 / 音频', 'HTMLMediaElement 播放', () => typeof HTMLVideoElement !== 'undefined' && 'play' in HTMLVideoElement.prototype],
['captureStream', '画布 / 元素 → MediaStream', () => 'captureStream' in HTMLCanvasElement.prototype],
['Web Audio', 'AudioContext 合成与处理', () => typeof (window.AudioContext || window.webkitAudioContext) === 'function'],
['MediaRecorder', '把媒体流录成文件', () => typeof MediaRecorder !== 'undefined'],
['字幕轨道', 'WebVTT TextTrack', () => 'addTextTrack' in HTMLMediaElement.prototype],
['画中画', 'requestPictureInPicture()', () => 'requestPictureInPicture' in HTMLVideoElement.prototype]
]],
['图形', [
['Canvas 2D', 'getContext("2d")', () => hasContext('2d')],
['OffscreenCanvas', '可转移到 Worker 的画布', () => typeof OffscreenCanvas !== 'undefined'],
['WebGL 1.0', 'getContext("webgl")', () => hasContext('webgl') || hasContext('experimental-webgl')],
['WebGL 2.0', 'getContext("webgl2")', () => hasContext('webgl2')],
['createImageBitmap', '位图解码与缩放', () => typeof createImageBitmap === 'function'],
['内联 SVG', 'createElementNS 矢量 DOM', () => !!document.createElementNS('http://www.w3.org/2000/svg', 'svg').createSVGRect]
]],
['存储与线程', [
['localStorage', '键值对 + 持久化', () => canStore(localStorage)],
['sessionStorage', '键值对 + 标签页级', () => canStore(sessionStorage)],
['IndexedDB', '结构化数据库 + 事务', () => typeof indexedDB !== 'undefined'],
['Cache Storage', 'caches.open() 请求缓存', () => typeof caches !== 'undefined' && typeof caches.open === 'function'],
['Web Worker', '后台线程', () => typeof Worker !== 'undefined'],
['BroadcastChannel', '同源标签页广播', () => typeof BroadcastChannel !== 'undefined']
]],
['观测与交互', [
['ResizeObserver', '元素尺寸变化', () => typeof ResizeObserver !== 'undefined'],
['IntersectionObserver', '可见性 / 交叉检测', () => typeof IntersectionObserver !== 'undefined'],
['MutationObserver', 'DOM 变更监听', () => typeof MutationObserver !== 'undefined'],
['requestIdleCallback', '空闲时段调度', () => typeof requestIdleCallback === 'function'],
['页面可见性', 'visibilityState + 事件', () => typeof document.visibilityState === 'string'],
['Clipboard API', '异步读写剪贴板', () => !!navigator.clipboard],
['Fullscreen API', 'requestFullscreen()', () => 'requestFullscreen' in Element.prototype],
['拖放 + 文件', 'DragEvent / FileReader', () => typeof FileReader !== 'undefined' && 'ondrop' in document.createElement('div')]
]]
]
export default function (container) {
let disposed = false
const wrap = document.createElement('div')
wrap.className = 'demo-dom'
const grid = document.createElement('div')
grid.className = 'demo-grid'
grid.style.gridTemplateColumns = 'repeat(auto-fit, minmax(260px, 1fr))'
wrap.appendChild(grid)
container.appendChild(wrap)
const summary = hint(container, '')
let supported = 0
let total = 0
const row = (name, detail, badgeText, ok) => {
const line = document.createElement('div')
line.style.cssText = 'display:flex;align-items:baseline;gap:8px;padding:4px 0;border-bottom:1px dashed var(--vp-c-divider)'
const label = Object.assign(document.createElement('span'), { textContent: name })
label.style.flex = '1'
const mid = Object.assign(document.createElement('span'), { className: 'demo-mono', textContent: detail })
mid.style.cssText = 'color:var(--vp-c-text-3);font-size:11.5px'
const badge = Object.assign(document.createElement('span'), { className: 'demo-badge', textContent: badgeText })
if (!ok) badge.style.cssText = 'background:var(--vp-c-danger-soft);color:var(--vp-c-danger-1)'
line.append(label, mid, badge)
return line
}
for (const [title, items] of GROUPS) {
const card = document.createElement('div')
card.className = 'demo-card'
const head = Object.assign(document.createElement('span'), { className: 'demo-badge', textContent: title })
const list = document.createElement('div')
list.style.marginTop = '8px'
for (const [name, detail, test] of items) {
total += 1
let ok = false
try { ok = !!test() } catch { ok = false }
if (ok) supported += 1
list.appendChild(row(name, detail, ok ? '✓ 支持' : '✗ 不支持', ok))
}
card.append(head, list)
grid.appendChild(card)
}
// WebGPU 必须异步确认:navigator.gpu 存在,也可能 requestAdapter() 返回 null
const gpuCard = document.createElement('div')
gpuCard.className = 'demo-card'
const gpuHead = Object.assign(document.createElement('span'), { className: 'demo-badge', textContent: 'GPU(异步确认)' })
const gpuRow = row('WebGPU', 'navigator.gpu + requestAdapter', '检测中…', true)
gpuCard.append(gpuHead, gpuRow)
grid.appendChild(gpuCard)
const refresh = () => { summary.textContent = `本环境检测结果:${supported} / ${total} 项可用` }
refresh()
;(async () => {
let text = '✗ 不支持'
let ok = false
try {
if (!navigator.gpu) text = '✗ navigator.gpu 缺失'
else {
const adapter = await navigator.gpu.requestAdapter()
if (!adapter) text = '! 无可用适配器'
else {
const device = await adapter.requestDevice()
text = `✓ ${(adapter.info && adapter.info.vendor) || '可用'}`
ok = true
device.destroy()
}
}
} catch (err) {
text = `! ${err.message}`
}
if (disposed) return
const badge = gpuRow.lastElementChild
badge.textContent = text
if (!ok) badge.style.cssText = 'background:var(--vp-c-warning-soft);color:var(--vp-c-warning-1)'
total += 1
if (ok) supported += 1
refresh()
})()
return () => {
disposed = true
wrap.remove()
summary.remove()
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
能力检测怎么写
原则只有一条:检测能力,不检测浏览器。UA 字符串会被伪造、会被冻结(UA-CH 就是为此而生),而且它回答不了“这台机器上 WebGL 是真能用还是被驱动拉黑了”。
js
// 1) 同步检测:问“这个对象/方法存在吗”
const canvas = document.createElement('canvas')
const support = {
canvas2d: !!canvas.getContext('2d'),
webgl2: !!canvas.getContext('webgl2'),
offscreen: typeof OffscreenCanvas !== 'undefined',
worker: typeof Worker !== 'undefined',
storage: (() => {
try {
localStorage.setItem('__probe__', '1')
localStorage.removeItem('__probe__')
return true
} catch {
return false // 隐私模式下 setItem 会直接抛 QuotaExceededError
}
})()
}
// 2) 异步检测:接口存在 ≠ 真的可用(WebGPU 的典型坑)
async function hasWebGPU() {
if (!navigator.gpu) return false
const adapter = await navigator.gpu.requestAdapter()
return !!adapter // 有些环境暴露了 navigator.gpu 却拿不到适配器
}
// 3) CSS 能力用 CSS.supports(),别用 JS 猜
const supportsUserInvalid = CSS.supports('selector(:user-invalid)')1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
检测结果要“可降级”,不要“白屏”
把检测结果接到三条路径上:完整实现 → 简化实现 → 可读提示。本站在 reference/webgl2-instancing.js 里演示了这种降级写法;shared/runtime.js 的 notSupported(container, message, hint) 就是给示例用的提示块。
支持度与基线
本节示例统一按“现代浏览器基线”编写(Chrome/Edge 113+、Firefox 115+、Safari 16+)。下面这些是实际写代码时最容易被绊到的差异:
| 能力 | Chrome / Edge | Firefox | Safari | 备注 |
|---|---|---|---|---|
| 语义化元素、新表单类型 | 全部 | 全部 | 全部 | 早已无需 polyfill |
<dialog>.showModal() | 37+ | 98+ | 15.4+ | 更早的版本只有非模态 show() |
:user-invalid / :user-valid | 119+ | 88+ | 16.4+ | 老浏览器回落到 :invalid + JS 打标记 |
| Web Storage / IndexedDB | 全部 | 全部 | 全部 | 隐私模式与“阻止站点数据”下会受限 |
| 拖放 + File API | 全部 | 全部 | 全部 | 移动端触摸屏不支持 HTML5 拖放 |
| Web Worker | 全部 | 全部 | 全部 | 老 Safari 对 Blob URL 建 Worker 有限制 |
| Clipboard API | 66+ | 125+ | 13.1+ | 必须安全上下文(HTTPS / localhost)且在手势中调用 |
structuredClone | 98+ | 94+ | 15.4+ | 老环境退回 JSON.parse(JSON.stringify()) |
requestIdleCallback | 47+ | 55+ | 不支持 | Safari 需用 setTimeout 自己模拟 |
| WebGPU | 113+ | 未默认启用 | 18+ | 必须异步确认适配器 |
关于“支持度”的正确态度
兼容性表会过期,用户浏览器不会。关键路径永远做特性检测 + 降级;非关键增强(动画、视觉效果)可以直接用新 API,但要保证不支持时页面依然可用。具体数据以 caniuse 与 MDN 的兼容性表为准。
API 速查
| 能力 | 检测方式 | 说明 |
|---|---|---|
| 语义化元素 | 'content' in document.createElement('article') | 老浏览器会把未知元素当行内元素,现代浏览器无需 polyfill |
| 新输入类型 | const i = document.createElement('input'); i.type = 'date'; i.type === 'date' | 不支持时 type 会被改写成 'text' |
| 约束校验 | 'checkValidity' in input | 配合 input.validity(ValidityState)使用 |
| Canvas 2D | !!canvas.getContext('2d') | 同一画布只能拿一种上下文,检测用完要丢弃 |
| WebGL / WebGL2 | canvas.getContext('webgl2') | 返回 null 只代表“此环境没有”,不代表代码有错 |
| WebGPU | navigator.gpu + await requestAdapter() | 必须异步确认适配器 |
| Web Storage | try { localStorage.setItem(...) } catch | 隐私模式下会抛异常,不能只判断 'localStorage' in window |
| IndexedDB | typeof indexedDB !== 'undefined' | 无痕窗口里也可能被禁用 |
| Web Worker | typeof Worker !== 'undefined' | Blob URL 可内联创建,无需额外文件 |
| Observer 三件套 | typeof ResizeObserver !== 'undefined' | ResizeObserver / IntersectionObserver / MutationObserver |
| 剪贴板 | !!navigator.clipboard | 需要安全上下文(HTTPS / localhost) |
| CSS 选择器支持 | CSS.supports('selector(:user-invalid)') | 判断伪类、属性、值是否被支持 |
常见坑与排查
典型报错
现象:localStorage.setItem 抛 QuotaExceededError,代码直接崩。
原因:浏览器处于隐私模式或站点数据被策略禁用,此时读 localStorage 属性本身可能就抛错。
解决:永远用 try/catch 包住读写,并把存储失败当成“功能降级”而不是“程序错误”。
| 现象 | 原因 | 解决方式 |
|---|---|---|
| 页面在旧的 IE / 老 Safari 上样式全乱 | 未知语义元素被视为行内元素 | 现代浏览器已无此问题;若必须兼容,加一条 article, section, header { display: block } |
在 Node / 构建期报 document is not defined | 服务端渲染时顶层访问了 DOM | 所有 DOM 访问放进函数体内(本站在 pnpm check:demos 里强制检查这一点) |
navigator.gpu 存在但 requestAdapter() 返回 null | 驱动被禁用、GPU 被策略屏蔽、虚拟机环境 | 异步检测并降级到 WebGL2,而不是断言“浏览器支持 WebGPU” |
| 隐私模式下页面功能异常但控制台没报错 | 存储 API 静默失败或抛异常被吞 | 检测时主动 setItem 试写一次,把结果展示给用户 |
| 页面卡在长任务里,动画全停 | 主线程被同步计算占满 | 用 Web Worker 承载计算,见 现代浏览器 API |