- 简要介绍 W3C Media Capture and Streams 草案中采集部分的约定
- 粗略介绍 Chromium 的多进程架构以及音频采集功能的实现
Media Capture and Streams
什么是 Media Capture and Streams TR
Media Capture and Streams 是由 W3C WebRTC Working Group 提出的规范草案,主要定义了获取本地媒体的 JavaScript API。目前该草案处于 CRD (Candidate Recommend Draft) 状态,也可以被称为技术报告(Technical Report, TR)
虽然 W3C 强调,W3C CRD 不能(MUST NOT)以 W3C 标准的名义引用。并且,W3C CRD 可能成为 W3C 标准,也可能不会成为 W3C 标准。但截至目前,主流浏览器都参考了这份草案进行了实现。各位 Web 开发者大可放心使用相关 API 开发自己的网页应用。

草案包含的 API
这份草案包含了两大类 API:MediaStream、MediaDevices。
-
MediaStream: 主要包含 MediaStream、MediaStreamTrack 两大类。
- MediaStreamTrack: 从一个媒体源获取的、单一类型的媒体。(例如:来自摄像头的视频)
- MediaStream: 由多个 MediaStreamTrack 构成的一个单元,可以被录制或者在 Media Element 上渲染。
-
MediaDevices: 扩展自 Navigator 接口,主要包含枚举媒体设备、获取媒体流两类。
- enumerateDevices: 收集浏览器可用的媒体输入设备和媒体输出设备。
- getUserMedia: 向用户申请权限,获取用户的摄像头或者其他的音视频输入信息。
怎样使用这些 API
下面的实例代码简单展示了如何采集摄像头流,并将流渲染到媒体元素上。
javascript
const startBtn = document.getElementById("startBtn"); // <button>
const videoPlayer = document.getElementById("videoPlayer"); // <video>
async function captureCamera() {
try {
// 约束
const constraints = {
video: {
width: 640,
height: 480,
},
audio: true,
};
// 采集
const stream = await navigator.mediaDevices.getUserMedia(constraints);
// 播放
videoPlayer.srcObject = stream;
videoPlayer.play();
} catch (err) {
console.error(err);
}
}
startBtn.addEventListener("click", captureCamera);
上述代码中包含了三个重要的步骤:
-
约束:向浏览器提供所需要的媒体流的信息。
- 每种媒体都可以指定其约束
- 只设置为 true 表示需要这个媒体流,但没有任何限制
JS:浏览器,请给我一个分辨率 640x480 的视频流,再来一个音频流。哪个设备的都可以。
- 采集:将约束传入 getUserMedia 接口。该接口将以 Promise 形式返回一个 MediaStream。
等了一会
浏览器:好嘞,你要的两个流。打包放一起了。
- 播放:该草案中也提供了在 HTMLMediaElement 上渲染这些流的接口。将 MediaElement 的 srcObject 设置为想要播放的流。然后调用 play 即可。
主要注意的是,传给 getUserMedia 接口的参数被称作"约束"。这是因为该接口最终返回的流可能与约束不一致。这就涉及到设置、能力与约束的区别了。
设置、能力与约束
Setting 设置
设置表示一个媒体源当前所应用的参数。显然,这个参数需要是只读的。
这里以上文采集到的音视频轨道为例,分别调用 getSettings() 接口。可以发现接口返回了如下内容。
javascript
// audio
{
autoGainControl: true,
channelCount: 1,
deviceId: "cc0e809ba21cc1e8a1403b835d13ea3a4e8541f438d0b31989294cace5f43ec2",
echoCancellation: true,
groupId: "075cbccd30ba3337cf5d990a77525475153112df8066833c3213706ceeab2b42",
latency: 0.01,
noiseSuppression: true,
sampleRate: 48000,
sampleSize: 16,
voiceIsolation: false
}
// video
{
aspectRatio: 1.3333333333333333,
deviceId: "d1216494e164337d39248c6d9610a0e7038ec774998f758665270dcaaae29093",
frameRate: 30,
groupId: "f9b73b0dac3ff31397c5bb01df58e1256aa1acc930d2634f7d090b24da06a513",
height: 480,
resizeMode: "none",
width: 640
}
这两个对象详细描述了音视频源正在应用的参数,也可以看到对于视频的分辨率约束也生效了。
一些常用的字段含义如下所示,不做赘述。
音频
deviceId:设备 IDchannelCount: 声道数autoGainControl:自动增益控制echoCancellation:回声消除noiseSuppression:噪声抑制
视频
deviceId: 设备 IDwidth:画面宽度height:画面高度frameRate:帧率
Capability 能力
能力是指媒体源支持哪些参数,每个参数的范围如何。
例如:一个摄像头可能支持 640x480@60, 1920x1080@30 等多种分辨率、帧率组合。一个麦克风可能支持 48000Hz 采样率,也支持 44100Hz 采样率。
然而通过上面两个例子,就可以发现完整列举设备能力是几乎不可能的。
- 参数种类很多,每个参数又有不同的取值范围。使用约束列表进行描述,将获得非常多的组合,无法完整列举。
- 过于详细的能力集合很容易用来作为设备指纹。
因此 getCapability() 只能获取已经采集到的流所对应的媒体源的参数。
还是以上面的采集到的音视频轨道为例。分别调用 getCapability() 接口,可以看到这两个设备的能力列表。
javascript
// audio
{
autoGainControl: [true, false],
channelCount: {max: 1, min: 1},
deviceId: "cc0e809ba21cc1e8a1403b835d13ea3a4e8541f438d0b31989294cace5f43ec2",
echoCancellation: [true, false],
groupId: "6190ef675de1872a2190edd82ee43c58899d1d6b0c6a613113de98cf3650c1c8",
latency: {max: 0.085333, min: 0.002666},
noiseSuppression: [true, false],
sampleRate: {max: 48000, min: 48000},
sampleSize: {max: 16, min: 16},
voiceIsolation: [true, false],
}
// video
{
aspectRatio: {max: 1920, min: 0.0005208333333333333},
deviceId: "d1216494e164337d39248c6d9610a0e7038ec774998f758665270dcaaae29093",
facingMode: [],
frameRate: {max: 30, min: 0},
groupId: "ebfbdd93ec4d8fc55a6e98ba59961767621761d27110e22c64a2033d26e19246",
height: {max: 1920, min: 1},
resizeMode: ['none', 'crop-and-scale'],
width: {max: 1920, min: 1}
}
可以发现,能力有以下几种描述形式。
- 列表:表示可以使用列表中的其中一项。
- 最大值与最小值:表示可以使用这个范围内的值。
Constraints 约束
约束这个概念比较抽象。可以理解为这是一种描述网页应用所需媒体流的特征的接口。向媒体源提供约束,将影响媒体源如何提供尽可能符合要求的媒体流。

正如前面的例子,网页应用的需求是一个宽 640px、高 480px 的视频流。这个需求是约束。媒体源在应用这个约束时,会根据媒体源的能力选择最接近的设置,生成媒体流。
除了上文约束可以通过单一值来设置约束以外,还可以有如下形式的约束。
-
{max, mix}:表示一个范围。- 例如:
width: {max: 1080, min: 720}
- 例如:
-
{exact}:表示精确值,不满足约束时报错。- 例如:
deviceId: {exact: 'd1216494'}
- 例如:
-
{ideal}:表示理想值,不满足时浏览器可能会应用其他接近的设置。- 例如:
deviceId: {ideal: 'd1216494'} - 此外,还支持进阶约束(advanced constraint)。这些约束的优先级将小于普通约束。
- 例如:
那么浏览器应该如何实现这个 通过约束获得设置 的算法呢?这份草案中定义了 SelectSetting 算法。
SelectSetting 算法
SelectSetting 算法的目标就是在多个候选设置中,选出一个符合约束的设置。当然,如果没办法满足要求的话需要报错。
SelectSetting算法的输入分别是一组候选设置 Candidates 和一个约束 ConstraintSet (CS)。
- 选择 CS 中的普通约束,与所有候选设置进行匹配。如果匹配失败直接返回空值。
- 遍历 CS 中的进阶约束组,如果匹配失败则删除这个约束。
- 选择最终保留下来的候选设置,这个候选设置必须(MUST)是匹配距离最小的候选设置之一。

W3C 草案也定义了计算匹配距离(fitness distance)的算法,具体如下图。
算法的输入是一个候选设置 Setting 与一个约束 CS。
- 遍历约束中的约束名称(ConstraintName, name)与约束值(ConstraintValue, value)。
- 对每一个 name,做图中的各种判断,计算出每个 name 对应的分数。
- 最终求和得到最终分数。

W3C 草案中的 getUserMedia 方法
结合上文我们了解到,getUserMedia 需要处理约束和能力的匹配,生成一套符合要求的设置,根据设置生成媒体流。因此 getUserMedia 方法的核心为上文提到的匹配约束的算法。
但 W3C 草案考虑到隐私与安全问题,定义了多种需要抛出错误的情况,详情可以参考下图。主要包含以下几个特点:
- 采集流程需要得到用户的授权。当没有被拒绝时,会在完成约束匹配后再弹窗申请权限。而在被拒绝时会直接跳过约束匹配流程。
- 采集流程需要页面处于 active / in view / focued 等活跃状态。从而限制了恶意代码在未经用户同意时静默打开摄像头或者麦克风。

Chromium 多进程架构
以下内容来自个人阅读 Chromium 文档和代码的总结,可能存在错误和缺漏,欢迎斧正。
在上文中介绍了 W3C Media Capture and Streams 草案的一些重要概念。也介绍了草案中对 getUserMedia 方法的描述。理想情况下,所有浏览器都应该严格遵守这个标准草案。但在实践中,部分浏览器可能处于多方权衡,总会与这份草案存在一定的偏差。本文以 Chrome 的开源项目:Chromium 为例,简单了解浏览器是如何实现 getUserMedia 接口的。
在阅读源码之前,需要对 Chromium 项目有一些整体认知。这些知识有助于降低阅读难度。
多进程架构
正如现代操作系统一样,使用多个进程将应用分离,从而提高健壮性。Chromium 架构的目标也是为了这种更健壮的设计。
Chromium 使用多进程结构,主要有以下两个好处。
- 可以防止单个进程的意外错误破坏整个程序。
- 可以隔离单个进程,控制其访问范围。

- Browser 进程:也被视为主进程。Browser 进程负责渲染 UI、管理其他 renderer 进程、接管 renderer 进程中对于操作系统的调用等功能。
- Renderer 进程:用来处理网页内容,这部分通常包含 Blink、V8 等引擎。
此外,随着浏览器复杂程度的提高,browser 进程的所负责的工作将会愈发臃肿。因此当前很多工作从 browser 进程中分离,以 service 的形式提供给 browser 进程或 renderer 进程。主要的 service 都列举在最顶层的 //services 目录下。
- audio: 主要用于处理音频采集相关功能
- video_capture: 主要用于处理视频采集相关功能
- webnn: 用于实现 Web Neural Network API 的服务。主要通过调用操作系统的硬件加速机器学习 API 来实现对应功能。
跨进程通信 - Mojo
Mojo 是一个跨平台的 IPC 框架,它诞生于 chromium ,用来实现 chromium 进程内/进程间的通信。主要提供三个 IPC 通信机制:MessagePipe,DataPipe 和 SharedBuffer。

- MessagePipe: 用于进程间的双向通信。底层使用 Channel 通道。
- DataPipe: 用于进程间的单向 数据块 传递。底层使用操作系统提供的 Shared Memory。
- SharedBuffer: 用户进程间的双向数据块传递。底层也是 Shared Memory。
Mojo 的在使用中的最大特点是提供一套方便的绑定接口。可以通过创建 .mojom 文件完成接口定义,由 BUILD.gn 中增加对应的编译代码就可以生成源代码文件。(这些源代码文件也是使 Chromium 源码跳转较为困难的原因)。
跨线程通信 - 任务队列
Chromium 在代码中大量使用任务队列来提高并发能力,但在不同场景中对于任务队列的要求各有不同。Chromium 在这里设计了多种任务队列满足不同需求。
- TaskRunner:普通的任务队列。使用线程池消费任务,不保证任务执行顺序。
- SequencedTaskRunner: 额外保证任务执行顺序的队列。遵循先入先出原则,但不保证任务在同一个线程中执行。
- SingleThreadTaskRunner: 额外保证在同一线程执行的有序队列。
如何获取、阅读 Chromium 源码
如何获取并编译 Chromium
Chromium 开源项目提供的非常完整的工具链和文档,如果你的网络情况较好、存储空间较充足、设备性能较好,可以尝试自己编译 Chromium。只需要参考官网文档即可。
www.chromium.org/developers/...
主要注意的是,Chromium 完整 git 历史+ 源码 + 一次全量编译的产物大约 200 GB。如果想减少对存储空间的占用,可以考虑 shallow fetch 等方式减少 git 历史文件的大小。
如何阅读
在线阅读
如果你是否有下载源码,都推荐选择在线阅读 Chromium 代码。在线版本 Chromium 代码不仅加载非常快,而且还有非常完善的引用调用查找功能。除了没办法调整字体外还是非常实用的。
source.chromium.org/chromium/ch...
如果你执念想使用本地代码阅读,那么你还要至少进行以下几个步骤才能愉快地开始符号跳转。
如果你使用 Visual Studio Code
使用 VSCode 的话,需要依赖于 clangd 插件。主要分为以下几个步骤。
- 完整编译 Chromium
- 生成 compile_commands.json 文件。其中
out/Default为编译产物所在位置。
sql
tools/clang/scripts/generate_compdb.py -p out/Default > compile_commands.json
- 开始索引。后台索引通常是自动开始的。索引耗时与设备性能高度相关,可能在 2 小时(MacBook Pro, M2 pro)至 7 小时(MacBook Air, M2)不等。
clangd 有时会因为单一文件出现大量报错,而停止分析。可以通过配置 .clangd 文件提高错误数量上线,从而得到更多文件分析结果。
ini
CompileFlags:
Add: -ferror-limit=100
如果你使用 JetBrains CLion
CLion 依赖于 CMake 进行语法分析,所以需要正确配置 CMakeLists.txt 。CMakeLists.txt 文件需要与 src 文件夹在同一目录。文件内容如下所示。
css
.
├── CMakeLists.txt
└── src
make
cmake_minimum_required(VERSION 3.10)
project(chromium)
set(CMAKE_CXX_STANDARD 14)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/out/Default/gen)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/third_party/protobuf/src)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/third_party/googletest/src/googletest/include)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/third_party/googletest/src/googlemock/include)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src/third_party/abseil-cpp)
# The following file used is irrelevant, it's only to improve CLion
# performance. Leaving at least 1 file is required in order for CLion
# to provide code completion, navigation, etc.
add_executable(chromium src/components/omnibox/browser/document_provider.cc)
如何获取日志
bash
currentTime=`date "+%Y%m%d_%H%M%S"`
filePath=${HOME}/chrome_debug_${currentTime}.log
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --enable-logging --v=1 --log-file=${filePath} &
sleep 1
open -a Console ${filePath}
以上方法可以在 macOS 上获取日志,日志存放于用户目录下。通常正式发布版本的 Chrome 只能获取 VERBOSE1 级别的日志,而且不包含代码中的各项断言检查。
Chromium 中的 getUserMedia
Chromium getUserMedia 音频流程图
Chromium 的麦克风音频采集主要包含获取设备能力、检查设备约束、创建 Stream、创建 Track、启动 Track 这几个步骤。至少涉及到三个进程间的通信,包含至少四个模块。

- renderer 进程:主要通过 UserMediaProcessor 对象流转 UserMediaRequest 中的状态,主要实现各种检查功能。
- browser 进程:主要通过 MediaStreamManager 对象管理所有媒体流。
- audio 进程:主要作为操作系统 API 的适配层,调用操作系统提供的音频接口。
主要模块的流转
UserMediaProcessor
位于 third_party/blink/renderer/modules/mediastream/user_media_processor.cc
该模块位于 renderer 进程中,主要用于处理 getUserMedia 请求。
- 每次只能创建一个 MediaStream
- 在各个函数中流转 UserMediaRequest 对象,以队列方式处理。

Chromium 中的 SelectSetting 算法
位于 third_party/blink/renderer/modules/mediastream/media_stream_constraints_util_audio.cc
可以发现这段代码基本符合 W3C 草案中的 SelectSetting 算法。包含先处理基本约束(绿色区域),再处理进阶约束(橙色区域)。但比较显著的是,Chromium 忽略了不同 setting 的匹配距离部分(紫色区域)。
c++
AudioCaptureSettings SelectSettingsAudioCapture(
const AudioDeviceCaptureCapabilities& capabilities,
const MediaConstraints& constraints,
mojom::blink::MediaStreamType stream_type,
bool should_disable_hardware_noise_suppression,
bool is_reconfiguration_allowed) {
if (capabilities.empty())
return AudioCaptureSettings();
std::string media_stream_source = GetMediaStreamSource(constraints);
std::string default_device_id;
bool is_device_capture = media_stream_source.empty();
if (is_device_capture)
default_device_id = capabilities.begin()->DeviceID().Utf8();
CandidatesContainer candidates(capabilities, stream_type, media_stream_source,
default_device_id, is_reconfiguration_allowed);
DCHECK(!candidates.IsEmpty());
auto* failed_constraint_name =
candidates.ApplyConstraintSet(constraints.Basic());
if (failed_constraint_name)
return AudioCaptureSettings(failed_constraint_name);
for (const auto& advanced_set : constraints.Advanced()) {
CandidatesContainer copy = candidates;
failed_constraint_name = candidates.ApplyConstraintSet(advanced_set);
if (failed_constraint_name)
candidates = std::move(copy);
}
DCHECK(!candidates.IsEmpty());
// Score is ignored as it is no longer needed.
AudioCaptureSettings settings;
std::tie(std::ignore, settings) = candidates.SelectSettingsAndScore(
constraints.Basic(),
media_stream_source == blink::kMediaStreamSourceDesktop,
should_disable_hardware_noise_suppression);
return settings;
}
观察 ApplyConstraintSet 函数,这里基本上对应 W3C 草案中的 fitness_distance 的部分。可以发现其实 Chromium 完全没有参考草案中的流程。代码将约束分为四类:deviceId、groupId、number 与 boolean、processing_based。其中 processing_based 表示需要使用 WebRTC 模块提供的音频处理功能的约束。
这种计算方式显然也没办法得出一个分数,因此在 SelectSettingsAudioCapture 函数的注释中,也明显表示因为不再需要 score,所以将 score 忽略。
c++
// class DeviceContainer
const char* ApplyConstraintSet(const ConstraintSet& constraint_set) {
const char* failed_constraint_name;
failed_constraint_name =
device_id_container_.ApplyConstraintSet(constraint_set.device_id);
if (failed_constraint_name)
return failed_constraint_name;
failed_constraint_name =
group_id_container_.ApplyConstraintSet(constraint_set.group_id);
if (failed_constraint_name)
return failed_constraint_name;
for (size_t i = 0; i < kNumBooleanContainerIds; ++i) {
auto& info = kBooleanPropertyContainerInfoMap[i];
failed_constraint_name =
boolean_containers_[info.index].ApplyConstraintSet(
constraint_set.*(info.constraint_member));
if (failed_constraint_name)
return failed_constraint_name;
}
// For each processing based container, apply the constraints and only fail
// if all of them failed.
for (auto it = processing_based_containers_.begin();
it != processing_based_containers_.end();) {
DCHECK(!it->IsEmpty());
failed_constraint_name = it->ApplyConstraintSet(constraint_set);
if (failed_constraint_name)
it = processing_based_containers_.erase(it);
else
++it;
}
if (processing_based_containers_.empty()) {
DCHECK_NE(failed_constraint_name, nullptr);
return failed_constraint_name;
}
return nullptr;
}
MediaStreamManager
位于 content/browser/renderer_host/media/media_stream_manager.cc
该模块位于 browser 进程中,主要用于创建和关闭媒体设备,管理 MediaStream。
- 在各个函数中流转 DeviceRequest 对象

使用这些 API 可以做到什么
Media Capture and Streams API 主要支持了采集用户音视频流的功能。结合其他 Web API 可以实现多种功能。
- 结合 WebRTC API,可以将摄像头流和音频流发送其他用户,实现实时会议等功能。
- 结合 WebAudio API,可以将音频流进行处理,实现各种混音效果。
下面以拍照功能为例,简单介绍上述 API 的使用方法。

预览视频流
这部分与上文相同,需要执行三个步骤。设置约束、采集、播放。可以直接沿用上文提供的代码。
javascript
async function previewCamera() {
const videoPlayer = document.getElementById("videoPlayer"); // <video>
if (!videoPlayer || !(videoPlayer instanceof HTMLVideoElement)) {
return;
}
try {
// 约束
const constraints = {
video: {width: 640, height: 480},
};
// 采集
const stream = await navigator.mediaDevices.getUserMedia(constraints);
// 播放
videoPlayer.srcObject = stream;
videoPlayer.play();
} catch (err) {
console.error(err);
}
}
获取可用设备
此处可以使用枚举用户设备接口实现。首先我们需要调用这个接口,获取设备信息列表。
javascript
const devices = await navigator.mediaDevices.enumerateDevices();
设备信息列表中包含设备名称、设备 ID 等信息。设备名称可以用来作为用户展示,设备 ID 则需要传给约束更新视频流。
javascript
async function reloadDevices() {
const selector = document.getElementById("device-select");
if (!selector || !(selector instanceof HTMLSelectElement)) {
return;
}
selector.innerHTML = "";
// 获取设备列表
const devices = await navigator.mediaDevices.enumerateDevices();
// 创建选项
devices.forEach((device) => {
if (device.kind === "videoinput") {
const option = document.createElement("option");
option.value = device.deviceId;
option.text = device.label || `Camera ${selector.length + 1}`;
selector.appendChild(option);
}
});
}
javascript
async function previewCamera(deviceId) {
const videoPlayer = document.getElementById("videoPlayer"); // <video>
if (!videoPlayer || !(videoPlayer instanceof HTMLVideoElement)) {
return;
}
// 清除正在预览的流
const stream = videoPlayer.srcObject;
if (stream instanceof MediaStream) {
stream?.getTracks().forEach((track) => {
track.stop();
});
}
try {
// 约束
const constraints = {
video: {width: 640, height: 480},
};
if (deviceId) {
constraints.video.deviceId = { exact: deviceId };
}
// 采集
const stream = await navigator.mediaDevices.getUserMedia(constraints);
// 播放
videoPlayer.srcObject = stream;
videoPlayer.play();
} catch (err) {
console.error(err);
}
}
拍照
拍照的部分可以使用 Canvas API 的 drawImage 接口。具体如下:
javascript
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
async function capture() {
const videoPlayer = document.getElementById("videoPlayer"); // <video>
if (!videoPlayer || !(videoPlayer instanceof HTMLVideoElement)) {
return;
}
canvas.width = videoPlayer.videoWidth;
canvas.height = videoPlayer.videoHeight;
if (!ctx) {
throw Error('Canvas 2d Context is not supported');
}
ctx.drawImage(videoPlayer, 0, 0, canvas.width, canvas.height);
const img = document.createElement("img");
img.src = canvas.toDataURL("image/png");
const photoView = document.querySelector(".photo-view");
if (photoView) {
photoView.appendChild(img);
}
}
总结
- W3C 标准通常从易用性、安全性上进行考量。很多功能都会尝试避免被用来收集设备指纹。
- Chromium 开源项目提供了非常完善的工具链。除去网络因素,通常很快就可以开始编译 Chromium。
- Chromium 开源项目中包含大量优秀的设计,非常值得参考和学习。但同时,作为历史较为悠久的开源项目,代码的组织结构和实现不可避免的存在一些瑕疵。通常项目成员也会认真对待各种类型的提交,所以也欢迎各位尝试参与进 Chromium 开源项目中来。
一些注释
- W3C 草案中通常使用用户代理(User Agent)这个概念。为了和
navigator.userAgent进行区分,本文统一翻译为 "浏览器" 。但实际上浏览器只是一种用户代理,下载管理器、爬虫等也可以称为是用户代理。
参考文献与资料
- W3C Media Capture and Streams 草案 www.w3.org/TR/mediacap...
- W3C 组织关于 TR 的要求 www.w3.org/standards/t...
- Chromium 多进程架构 www.chromium.org/developers/...
- 一篇非常详细介绍 mojo 的原理和使用方式的文章 keyou.github.io/blog/2020/0...
- MDN 关于用户代理概念的解释 developer.mozilla.org/en-US/docs/...