从零在浏览器跑 DeepSeek-R1:WebGPU + Transformer.js 全链路源码解析
前言
大家好,我是一名前端开发者。最近参考GitHub做了一个项目------在浏览器里本地运行 DeepSeek-R1 1.5B 推理模型,全程不需要后端服务器,所有 AI 计算都在用户自己的设备上完成。
这篇文章会严格按照我自己的学习笔记顺序,逐层拆解:先讲模型社区和 Transformer.js 是什么,再分析每个 npm 包的作用,然后深入到 Web Worker 多线程架构、WebGPU 硬件加速、TypeScript 类型系统,最后讲解单例模式的设计思想。每一段代码、每一条注释我都会展开分析。
一、HuggingFace:AI 圈的开源模型社区
笔记原文:
shell# webgpu-deepseek ## huggingface AI 圈最火的开源模型社区,各个厂商把AI模型发布到这里 modelscope
1.1 什么是模型社区
在 AI 时代,大语言模型(LLM)就相当于传统软件开发中的"第三方库"。传统开发中我们需要一个日期处理库就去 npm/pip 搜,AI 开发中我们需要一个模型就去模型社区搜。
我们可以把模型社区理解为一个巨大的"AI 模型的应用商店":
java
传统软件开发 AI 开发
───────── ──────
npm install lodash pipeline("text-generation", "model-id")
pip install requests AutoModel.from_pretrained("model-id")
开发者上传训练好的模型,其他人通过一个模型 ID 就能直接下载使用,省去了自己从零训练的巨额成本。
1.2 HuggingFace 与 ModelScope
| 平台 | 定位 | 背后公司 | 特点 |
|---|---|---|---|
| HuggingFace | 全球最大开源模型社区 | HuggingFace(美国) | 模型数量最多,社区最活跃 |
| ModelScope(魔搭) | 中国最大的模型社区 | 阿里云 | 国内访问快,中文模型多 |
笔记里提到 ModelScope,是因为从中国访问 HuggingFace 经常遇到网络问题,ModelScope 作为国内镜像备选非常重要。Transformer.js 也支持从 ModelScope 拉取模型文件。
1.3 模型 ID:模型的"身份证"
在我们的 worker.js 中,有这样一行核心配置:
ini
// worker.js 第11行
static model_id = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";
拆解这个 ID:
markdown
onnx-community / DeepSeek-R1-Distill-Qwen-1.5B-ONNX
↑ ↑
组织/用户名 模型仓库名
├── DeepSeek-R1:基础模型
├── Distill:蒸馏版本(大模型→小模型)
├── Qwen-1.5B:基于阿里的 Qwen 架构,15亿参数
└── ONNX:模型格式(不是 PyTorch,是跨平台的 ONNX)
对应的 HuggingFace 真实地址:
arduino
https://huggingface.co/onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX
框架内部如何利用这个 ID:
arduino
Transformer.js 拿到 model_id 之后,自动拼接出所有需要的文件:
{model_id}/resolve/main/
├── tokenizer.json ← 词表文件,把文字映射成数字 ID
├── tokenizer_config.json ← 分词器的特殊配置
├── config.json ← 模型的架构参数(多少层、隐藏维度等)
└── onnx/
└── model.onnx ← 神经网络的所有权重(量化的 ONNX 格式)
设计意图: 开发者只需要记住一个 ID,框架自动发现并下载所有依赖文件。这就是"约定优于配置"------HuggingFace 规定了模型仓库的文件结构,Transformer.js 按照这个约定去查找,不需要开发者手动指定每个文件的 URL。
二、Transformer.js:让浏览器能跑模型的核心引擎
笔记原文:
bashtransform.js web 访问 id 远程下载,访问,并执行nlp任务 场景
2.1 Transformer.js 是什么
Transformer.js(npm 包 @huggingface/transformers)是 HuggingFace 官方推出的 JavaScript 库。它做的事可以概括为一句话:让你在浏览器里用 JavaScript 直接运行 Transformer 架构的 AI 模型。
底层它依赖的是 ONNX Runtime Web------一个能在浏览器中执行 ONNX 格式神经网络的计算引擎。ONNX Runtime Web 又可以选择两种后端:
javascript
ONNX Runtime Web 的两种推理后端:
┌──────────────────────────────────────────────┐
│ 后端选择 │
│ │
│ WebGPU 后端(我们的选择) │
│ ├─ 直接调用 GPU 硬件加速 │
│ ├─ 推理速度:快(5-20x CPU) │
│ └─ 要求:浏览器支持 WebGPU API │
│ │
│ WebAssembly 后端(降级方案) │
│ ├─ 在 CPU 上模拟执行 │
│ ├─ 推理速度:慢 │
│ └─ 要求:几乎所有浏览器都支持 │
└──────────────────────────────────────────────┘
2.2 它和 Python 版的 API 几乎一模一样
Transformer.js 的设计目标是让熟悉 Python Transformers 的开发者可以零学习成本切换到浏览器端:
ini
# Python 版 Transformers
from transformers import AutoTokenizer, AutoModelForCausalLM
tokenizer = AutoTokenizer.from_pretrained("model-id")
model = AutoModelForCausalLM.from_pretrained("model-id")
javascript
// JavaScript 版 ------ 语法完全一致,只是 import 语法不同
import { AutoTokenizer, AutoModelForCausalLM } from "@huggingface/transformers";
const tokenizer = await AutoTokenizer.from_pretrained("model-id");
const model = await AutoModelForCausalLM.from_pretrained("model-id");
设计意图: HuggingFace 刻意保持了两套 API 的高度一致性。这意味着社区里海量的 Python Transformers 教程和示例代码,几乎可以直接翻译到浏览器端。这是降低开发者迁移成本的关键设计。
2.3 两个核心概念:Tokenizer 与 Model
要理解 Transformer.js 做了什么,必须先理解这两个概念:
scss
┌─────────────────────────────────────────────────────────┐
│ Tokenizer(分词器) │
│ │
│ 作用:在"人类文字"和"模型认识的数字"之间互相转换 │
│ │
│ 编码 (encode):文字 → 数字序列 │
│ "你好,世界" ──→ [108386, 104738, 111419] │
│ │
│ 解码 (decode):数字序列 → 文字 │
│ [108386, 104738, 111419] ──→ "你好,世界" │
│ │
│ 文件大小:约 1MB │
│ 底层是一个巨大的映射表(词表),记录了每个词对应的数字 ID │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Model(模型本体) │
│ │
│ 作用:接收 token IDs,预测下一个最可能的 token │
│ │
│ 输入:[108386, 104738] ("你好,") │
│ ↓ │
│ 输出:概率分布 → 采样 → [111419] ("世界") │
│ │
│ 文件大小:约 800MB(量化后) │
│ 底层是一个巨大的矩阵乘法网络(Transformer 架构) │
└─────────────────────────────────────────────────────────┘
这两个组件总是成对使用:Tokenizer 负责"翻译",Model 负责"思考"。
三、DeepSeek-R1 模型:完整的数据链路
笔记原文:
rustdeepseek deepseek-r1-distill-qwen 1.5B 文件上传(GB)-> huggingface -> transform.js -> load -> web 下载到浏览器本地(慢)-> 浏览器缓存 -> webgpu(新特性,兼容性)-> nlp 任务
3.1 为什么要一步步来?------ 完整链路拆解
这张链路图涵盖了从"模型在开发者电脑上"到"模型在用户浏览器里执行 NLP 任务"的全过程。我们一步步拆:
scss
第1步:文件上传 → HuggingFace
┌────────────────────────────────────────────┐
│ DeepSeek 团队训练好模型 │
│ 原始格式:PyTorch (.safetensors) │
│ 文件大小:约 6GB (fp32) / 3GB (fp16) │
│ │
│ onnx-community 社区将其转换为 ONNX 格式 │
│ 并做 INT4 量化 → 文件缩小到约 800MB │
│ │
│ 上传到 HuggingFace,分配模型 ID: │
│ "onnx-community/DeepSeek-R1-Distill- │
│ Qwen-1.5B-ONNX" │
└────────────────────────────────────────────┘
第2步:Transformer.js load
┌────────────────────────────────────────────┐
│ 用户在浏览器里点 "Load model" │
│ │
│ App.tsx 发指令: │
│ worker.current.postMessage({ type: "load" })│
│ │
│ Worker 调用: │
│ TextGenerationPipeline.getInstance() │
│ ├─ AutoTokenizer.from_pretrained(id) │
│ │ → 下载 tokenizer.json (~1MB) │
│ └─ AutoModelForCausalLM.from_pretrained() │
│ → 下载 model.onnx (~800MB) │
│ │
│ 下载过程中每 16KB 触发一次 progress_callback │
│ → Worker postMessage → 主线程更新进度条 │
└────────────────────────────────────────────┘
第3步:Web 下载到浏览器本地(慢) → 浏览器缓存
┌────────────────────────────────────────────┐
│ 首次访问:下载 800MB(慢,1-5分钟) │
│ │
│ Transformer.js 内部用 IndexedDB 做持久化缓存 │
│ 第二次访问:直接从 IndexedDB 读取(秒开) │
│ │
│ 这就是笔记里标注"(慢)"的原因: │
│ 首屏加载体验差,但缓存后体验极好 │
└────────────────────────────────────────────┘
第4步:WebGPU(新特性,兼容性)
┌────────────────────────────────────────────┐
│ 浏览器通过 WebGPU API 调用 GPU 硬件加速推理 │
│ │
│ 如果不支持 WebGPU: │
│ → 降级到 WebAssembly (CPU 推理,慢 5-20 倍) │
│ → 1.5B 模型在 CPU 上可能慢到不可用 │
│ │
│ 兼容性现状: │
│ Chrome 113+ ✅ | Edge 113+ ✅ | Safari ✅ │
│ Firefox Nightly ⚠️ | 旧版浏览器 ❌ │
└────────────────────────────────────────────┘
第5步:执行 NLP 任务
┌────────────────────────────────────────────┐
│ 文本生成、数学推理、代码生成、逻辑推理等 │
│ 所有计算在用户本地完成,数据不出设备 │
└────────────────────────────────────────────┘
3.2 为什么笔记里要标注"(慢)"和"浏览器缓存"
这是两个互相关联的关键点:
"慢"的原因: 1.5B 参数的模型即使做了量化也是个约 800MB 的大文件。在国内网络环境下,从 HuggingFace CDN 下载可能耗时 2-10 分钟。这是"浏览器端 AI"最大的体验瓶颈。
"浏览器缓存"是解决方案: Transformer.js 内部使用浏览器的 IndexedDB 来缓存下载好的模型文件。IndexedDB 是浏览器提供的本地数据库 API,存储容量远大于 localStorage(几百 MB 到几 GB)。
scss
首次访问: 再次访问:
网络下载 800MB ──→ IndexedDB IndexedDB ──→ 直接读取(毫秒级)
(持久化存储) (无需网络请求)
↓ 慢 ↓ 快
用户等待 2-10 分钟 用户秒开
设计意图: 牺牲首次加载体验,换取后续使用的丝滑体验。这类似于 PWA 的"首次安装,后续离线可用"策略。
四、安装依赖:每个 npm 包都在做什么
笔记原文:
bash## 安装依赖 - @huggingface/transformers js 版本的transformers 库,用于加载模型,执行推理。 - "marked": "^15.0.5", aigc 返回的是markdown 格式文本,有利于在文本中表示一定的格式, 比如代码,加粗,引用等等。 显示到页面前需要把md 格式转换成html格式,才能在浏览器中显示。 更简洁 # <h1></h1>
4.1 @huggingface/transformers:浏览器端的推理引擎
perl
"@huggingface/transformers": "^4.2.0"
这是整个项目的核心,没有它就没有浏览器端的模型推理。它做了几件事:
- 模型发现:根据模型 ID 自动拼接 HuggingFace CDN 的文件 URL
- 模型下载:从 CDN 下载 ONNX 格式的模型权重文件,支持断点续传和进度回调
- 模型加载:将 ONNX 文件加载到 ONNX Runtime Web,创建推理会话
- 推理执行:提供高层 API(pipeline、generate)和底层 API(model._forward())
- 硬件选择:自动检测并选择最优后端(WebGPU > WebAssembly)
4.2 marked:Markdown 转 HTML 的"翻译官"
json
"marked": "^15.0.5"
笔记原文写道:"aigc 返回的是 markdown 格式文本,有利于在文本中表示一定的格式,比如代码,加粗,引用等等。"
AI 大模型生成的内容默认是 Markdown 格式的纯文本。这是因为:
原因一(笔记里的核心观点):Markdown 更简洁
css
Markdown 写法 HTML 写法
───────────── ─────────
# 标题 <h1>标题</h1>
**加粗** <strong>加粗</strong>
- 列表项 <ul><li>列表项</li></ul>
`代码` <code>代码</code>
同样表示一个大标题,Markdown 只需 2 个字符 # ,HTML 需要 4 个标签字符 <h1></h1>。 在生成成千上万个 token 的场景下,使用 Markdown 比 HTML 省 token、省推理时间、省流量。而且 # 标题 是人类书写时就习惯用的格式,模型本来就是从人类写的内容中学来的。
原因二:Markdown 适合表达结构化文本
AI 回复经常包含代码块、列表、引用、表格等。Markdown 的语法就是为此设计的:
scss
下面是一段 Python 代码:
```python
def hello():
print("Hello, World!")
- 优点一:简单
- 优点二:强大
ini
这些格式用 Markdown 写极其自然,用 HTML 写就非常啰嗦。
**"显示到页面前需要把 md 格式转换为 html 格式,才能在浏览器中显示。"**
这是关键步骤------浏览器只认识 HTML,不认识 Markdown。如果你直接把 `# 标题` 这个字符串塞进页面,浏览器只会把它当普通文字显示出来,不会把它渲染成一个大标题。
```js
// 不做转换
element.innerText = "# 标题";
// 页面显示:# 标题 ← 这只是普通文字,不是标题样式
// 用 marked 转换
const html = marked.parse("# 标题");
// html = "<h1>标题</h1>"
element.innerHTML = html;
// 页面显示:一个大大的"标题" ← 这才是我们想要的
4.3 完整的 Markdown 渲染管线
swift
┌────────────────────────────────────────────────────────────┐
│ Markdown 渲染管线 │
│ │
│ ① AI 模型输出 Markdown 文本 │
│ "## 解题步骤\n\n1. 首先...\n2. 然后...\n\n```python..." │
│ │ │
│ ↓ marked.parse(markdown) │
│ │ │
│ ② marked 将 Markdown 语法转换为 HTML 标签 │
│ "## 解题步骤" → "<h2>解题步骤</h2>" │
│ "1. 首先..." → "<ol><li>首先...</li>" │
│ "```python" → "<pre><code class="python">" │
│ │ │
│ ↓ DOMPurify.sanitize(html) │
│ │ │
│ ③ DOMPurify 过滤可能的危险标签(XSS 防御) │
│ 移除 <script>、<iframe>、onclick 等 │
│ │ │
│ ↓ element.innerHTML = safeHTML │
│ │ │
│ ④ 浏览器解析 HTML,渲染出带样式的格式化内容 │
│ 用户看到排版精美的回复 │
│ │
└────────────────────────────────────────────────────────────┘
三层防线:
| 层 | 工具 | 职责 |
|---|---|---|
| Markdown 输出 | AI 模型 | 天然格式简洁,语法有限,无法嵌入可执行脚本 |
| 语法转换 | marked | 规则固定的白名单转换,不会凭空创造危险标签 |
| 安全过滤 | DOMPurify | 兜底防护,即使前两层有漏洞也能拦截 |
五、引入 Web Worker:多线程架构
笔记原文:
shell## 引入webworker 个人介绍,聊一下自己的项目 webgpu-deepseek 怎么学习?看你不知道的javascript,掘金等社区, 关注一些AI博主,github 源码,输出内容到社区。
笔记里这段更像是自我规划,我结合代码来展开 Web Worker 的内容。
5.1 问题:JavaScript 是单线程的
浏览器的 JavaScript 引擎只有一个主线程。它同时负责渲染页面、处理用户交互、执行 JavaScript 代码。如果主线程被一个耗时任务(比如 1.5B 参数的模型推理)占用,所有的 UI 都会凝固------点击没反应、滚动不掉、动画冻结。
arduino
没有 Worker 的情况:
┌─────────────────────────────────────────┐
│ 主线程(唯一) │
│ │
│ 渲染UI ✅ → 处理点击 ✅ → 模型推理 🔴 │
│ │ │
│ 占用主线程 10-60 秒 │
│ 期间所有 UI 完全冻结 │
│ │ │
│ 浏览器弹窗: │
│ "页面未响应" 💀 │
└─────────────────────────────────────────┘
5.2 解决方案:Web Worker 独立线程
Web Worker 是 HTML5 提供的多线程能力,它创建一个完全独立的 JavaScript 运行环境,有自己的事件循环,和主线程并行执行。
javascript
引入 Worker 之后:
┌── 主线程 (UI Thread) ────────────────┐ ┌── Worker 线程 ──────────┐
│ │ │ │
│ 事件循环 │ │ 事件循环 │
│ ├─ 渲染 React 组件 │ │ ├─ 接收主线程指令 │
│ ├─ 响应用户点击、滚动 │ │ ├─ 下载模型文件 │
│ ├─ 动画(进度条、加载状态) │ │ ├─ 模型推理(逐 token) │
│ └─ 更新 DOM │ │ └─ 返回推理结果 │
│ │ │ │
│ 有:window、document、DOM API │ │ 没有:window、document │
│ 能:操作页面、绑定事件 │ │ 能:纯计算、网络请求 │
│ │ │ │
│ ↕ postMessage 通信 ↕ │ │ │
└───────────────────────────────────────┘ └────────────────────────┘
5.3 Worker 的创建 ------ 关键代码分析
在 App.tsx 中,Worker 是在 useEffect 里创建的:
go
// App.tsx 第24-31行
useEffect(() => {
if (!worker.current) { // 只实例化一次
// html5 新特性
worker.current = new Worker(new URL("./worker.js", import.meta.url), {
type: "module", // 前端不是默认支持esm
});
// 消息通信
worker.current.postMessage({ type: "check" }); // Do a feature check
}
// ... 绑定事件监听器
}, []);
逐行拆解:
① if (!worker.current) ------ 守卫条件
scss
if (!worker.current) { // 只实例化一次
Worker 只需要创建一次。useRef(null) 初始化时 .current 是 null,创建 Worker 后赋值,后续组件 re-render 时 worker.current 已经有值,这个条件判断跳过,不会重复创建。
这里用 useRef 而不是 useState 的关键原因:Worker 实例这个值变化时不需要触发 UI 重新渲染。 useRef 的 .current 改变不会触发 re-render,useState 的 setter 会。我们需要的是"持久化引用",不是"响应式状态"。
② new Worker(new URL("./worker.js", import.meta.url)) ------ 动态路径
arduino
new Worker(new URL("./worker.js", import.meta.url), { ... });
为什么不能直接写 new Worker("./worker.js")?
因为打包工具(Vite)在处理生产构建时会给文件加哈希值:
bash
开发环境:./worker.js ← 文件名不变
生产环境:./worker-a3b8f2c1.js ← 加了哈希,用于缓存失效
如果你写死字符串 "./worker.js",Vite 不会知道这是一个文件依赖,生产环境就引用不到正确路径的文件。
new URL("./worker.js", import.meta.url) 让 Vite 识别到:"哦,这个文件被引用了",从而在打包时自动替换成正确的哈希路径。import.meta.url 是当前模块的 URL,new URL() 相对于它解析出 Worker 文件的绝对 URL。
③ type: "module" ------ ES Module 模式
go
{ type: "module", // 前端不是默认支持esm }
Worker 有两种模式:
go
Classic Worker(默认,不加 type: "module"):
self.importScripts("lib.js"); // ← 只能用这个老式方法加载外部脚本
// 不支持 import / export 语法
// 非严格模式
// 不支持顶层 await
Module Worker(新的,加 type: "module"):
import { AutoTokenizer } from "@huggingface/transformers";
// 支持标准的 ES Module import/export
// 自动严格模式
// 支持顶层 await
我们的 worker.js 里使用了 import { ... } from "@huggingface/transformers",必须用 Module 模式,否则浏览器会报 SyntaxError: Cannot use import statement outside a module。
④ worker.current.postMessage({ type: "check" }) ------ 环境预检
css
worker.current.postMessage({ type: "check" }); // Do a feature check
Worker 一创建就发一条 check 指令,让它检查 WebGPU 是否可用。这叫预检(Pre-check) ------不等用户操作,先把环境检测好,用户点"加载模型"时已经有结论了。
5.4 Worker 端的消息路由
typescript
// worker.js 第64-86行
// 事件监听
self.addEventListener("message", async (e) => {
const { type, data } = e.data;
switch (type) {
// 检查webgpu是否支持
case "check":
check();
break;
// 加载模型
case "load":
load();
break;
// 生成文本
case "generate":
break;
// 中断生成
case "interrupt":
break;
// 重置模型
case "reset":
break;
}
});
逐行拆解:
self.addEventListener("message", ...)
self 在 Worker 中指向 Worker 自身的全局作用域。Worker 没有 window(没有 DOM 环境),所以用 self 代替。这个监听器是 Worker 接收主线程消息的唯一入口。
const { type, data } = e.data;
这是 ES6 的解构赋值。e 是一个 MessageEvent 对象,e.data 是主线程通过 postMessage 传来的数据。解构出 type(指令类型)和 data(附加数据)两个变量。
switch (type) ------ 消息分发器
这本质就是一个简易的 RPC 路由器。和 HTTP 后端的路由设计完全一样:
scss
Worker 消息路由 HTTP 路由
─────────────── ──────────
case "check" → check() GET /api/check → check()
case "load" → load() POST /api/load → load()
case "generate" → generate() POST /api/generate → generate()
case "interrupt"→ interrupt() POST /api/interrupt→ interrupt()
case "reset" → reset() POST /api/reset → reset()
设计意图: 用 type 字段区分消息类型,接收方根据 type 分发到不同的处理函数。整个系统的所有 Worker 通信都靠这个协议。
5.5 主线程端的状态机
kotlin
// App.tsx 第34-66行
const onMessageReceived = (e) => {
switch (e.data.status) {
// 下载
case "loading":
setStatus("loading");
setLoadingMessage(e.data.data);
break;
// 初始一个下载文件
case "initiate":
break;
// 下载进度
case "progress":
break;
// 下载完成
case "done":
break;
// 所有文件都下载了,ready 使用
case "ready":
break;
// 开始生成
case "start":
break;
// 流式 有内容到达
case "update":
break;
// 生成完成
case "complete":
break;
// worker 出错了
case "error":
setError(e.data.data);
break;
}
}
这是对 Worker 消息协议的注释版定义。每一种 status 代表 Worker 当前处于什么阶段:
scss
Worker 的状态流转:
loading ──→ initiate ──→ progress ──→ progress ──→ ... ──→ done ──→ ready
(开始) (新文件) (下载中) (下载中) (完成) (就绪)
ready ──→ start ──→ update ──→ update ──→ ... ──→ complete
(就绪) (开始生成) (收到token) (收到token) (生成结束)
任何时刻 ──→ error (出错)
设计意图: 主线程不关心 Worker 内部如何实现,只根据 status 字段更新 React 状态。状态变了 → UI 自动更新 → 用户看到正确的界面。这就是 React 声明式 UI 和状态机设计的配合。
六、WebGPU 检测:navigator.gpu 与 TypeScript
笔记原文:
swift## !!(navigator as any).gpu navigator.gpu 报错,比较新,实验阶段的属性,ts 没有很好的识别Navigator 类 ts的理解和学习 navigator as any as 类型断言, any ts 的原生类型 任意类型,不要乱用,会泛滥。 用于忽略ts 类型检查。 别的方式? ts 里有专门的类型申明文件,@types/webgpu 本质是缺失类型申明文件 pnpm i -D @types/webgpu 安装类型申明文件 开发期间依赖 开发阶段用ts,代码打包后js
6.1 问题描述
navigator.gpu 是 WebGPU API 的入口属性。但这个 API 比较新(2023 年才在 Chrome 中稳定),TypeScript 的内置类型声明文件中可能还没有它。直接写 navigator.gpu,TS 编译器会报类型错误。
6.2 三种解决方案详解
方案一:as any 类型断言(笔记里提到的方式)
typescript
// App.tsx 第5行
const IS_WEBGPU_AVAILABLE = !!(navigator as any).gpu;
逐层拆解:
(navigator as any) ------ as any 是 TypeScript 的类型断言(Type Assertion) 。它告诉 TS 编译器:"把 navigator 当成 any 类型来处理,不要对它做类型检查。"
any 是 TypeScript 的特殊类型,表示"任意类型"。当一个变量被声明为 any 后,你可以访问它的任何属性,TS 都不会报错------因为 any 意味着放弃了类型检查。
!! 是双非运算符,把任意值转成布尔值:
yaml
navigator.gpu // undefined(不支持) 或 GPU 对象(支持)
!navigator.gpu // true(不支持)或 false(支持)
!!navigator.gpu // false(不支持)或 true(支持)
笔记里提醒:"any 不要乱用,会泛滥。" 这是因为 any 会让 TypeScript 丧失类型检查能力。你可以在 any 类型上调用任何不存在的方法、访问任何不存在的属性,TS 都不会提醒你------这等于放弃了使用 TypeScript 的意义。
as any 适合临时绕过类型检查的场景(比如快速原型、第三方库类型不完整),但不应该作为长期方案。
方案二:安装类型声明文件(推荐方案)
bash
pnpm i -D @types/webgpu # 安装类型声明文件,开发期间依赖
@webgpu/types 是 WebGPU 的官方 TypeScript 类型声明包。它使用 TypeScript 的声明文件(.d.ts)机制,告诉 TS 编译器 navigator.gpu 的类型是什么。
笔记里特意提到:"开发期间依赖" ------ 因为 TypeScript 的类型声明只在编译阶段有用,打包成 JS 后类型信息会被完全擦除。所以放在 devDependencies 而不是 dependencies。
方案三:自己写声明文件
也可以手动扩展 Navigator 接口:
php
declare global {
interface Navigator {
gpu?: GPU;
}
}
三种方式的适用场景:
| 方式 | 适用场景 | 风险 |
|---|---|---|
as any |
临时调试、快速原型 | 丧失类型安全 |
| 手动声明 | 少数几个属性缺失类型 | 需要维护 |
@webgpu/types |
正式项目 | 无,推荐 |
6.3 两层检测机制
项目里有两个层次的 WebGPU 检测:
第一层:App.tsx ------ 组件外的快速检测
typescript
// App.tsx 第5行
const IS_WEBGPU_AVAILABLE = !!(navigator as any).gpu;
这行代码在 function App() 外面,意味着整个应用生命周期只执行一次,不会随组件 re-render 重复计算 。如果返回 false,直接显示错误页,后续的 Worker 创建、模型下载都不会发生------Fail Fast(尽早失败)。
第二层:worker.js ------ 适配器级别的深度检测
javascript
// worker.js 第30-49行
async function check() {
try {
// window
// DOM Document Object Model document
// BOM Browser Object Model navigator
// adapter 是 GPU 适配器的抽象,
// 后续所有 WebGPU 计算/渲染操作都通过 device 执行
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
// 抛出错误
throw new Error("WebGPU is not supported (no adapter found)");
}
// fp16_supported = adapter.features.has("shader-f16")
} catch (e) {
self.postMessage({
status: "error",
data: e.toString(),
});
}
}
代码注释逐条解析:
// window --- Worker 中没有 window 对象。Worker 的全局作用域是 self。
// DOM Document Object Model document --- 注释在对比 DOM 和 BOM:
- DOM(Document Object Model):操作 HTML 文档的 API(
document.querySelector等)。Worker 中不能操作 DOM。 - BOM(Browser Object Model):浏览器自身功能的 API(
navigator、location、fetch等)。Worker 中可以使用部分 BOM API,比如navigator和fetch。
// adapter 是 GPU 适配器的抽象 --- requestAdapter() 返回的 GPUAdapter 对象代表一块物理 GPU。它是对操作系统 GPU 驱动的封装,后续所有 WebGPU 操作(创建 device、分配显存、执行计算着色器)都通过它创建的 GPUDevice 来执行。
// 后续所有 WebGPU 计算/渲染操作都通过 device 执行 --- adapter 是"识别 GPU",device 是"使用 GPU":
arduino
adapter.requestDevice() → GPUDevice
↓ ↓
"这台电脑有 NVIDIA RTX 4060" "我要在这块显卡上分配内存、跑计算"
// fp16_supported = adapter.features.has("shader-f16") --- 检查 GPU 是否支持 fp16(16 位浮点数)在着色器中的运算。fp16 精度稍低但速度快 2-4 倍。对于模型推理(不需要高精度的科学计算),fp16 是性价比最高的选择。
// 抛出错误 --- 手动 throw 会被下面的 catch 捕获,通过 self.postMessage 把错误信息传回主线程显示。
6.4 TypeScript 编译与运行时的关系
笔记里的一句关键理解:"开发阶段用 ts,代码打包后 js。"
bash
开发时: 构建后(生产环境):
──────── ────────────────
App.tsx ──→ tsc 类型检查 App.js
worker.js ──→ Vite 打包 worker-a3b8f2c1.js
类型声明 ──→ 指导 IDE 提示 类型信息被完全擦除
TypeScript 的所有类型注解、接口、泛型......
在打包后都不复存在,运行时只有纯 JavaScript。
所以 @types/webgpu 放在 devDependencies 就够了 ------
它只在开发阶段帮助 IDE 和编译器,不影响最终产物。
七、TypeScript 配置文件详解
笔记原文:
tsconfig.json typescript 配置文件 根据项目需求做各种配置 types 配置 安装的类型文件
7.1 完整的 tsconfig.app.json 逐字段解析
json
{
"compilerOptions": {
// tsBuildInfoFile: 增量编译的缓存信息文件路径
// TypeScript 会把上次编译的信息存在这里,下次只编译改动的文件
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
// target: 编译目标 JS 版本
// "es2023" 意味着编译产物可以使用 ES2023 的所有特性
// 不需要降级到 ES5/ES6(因为现代浏览器都支持 ES2023)
"target": "es2023",
// lib: 告诉 TS 代码运行环境中可用的 API
// "ES2023" → 可以使用最新的 JS 内置对象和方法
// "DOM" → 可以使用浏览器的 DOM API(document、window 等)
"lib": ["ES2023", "DOM"],
// module: 模块系统
// "esnext" → 使用最新的 ES Module 规范(import/export)
"module": "esnext",
// types: 额外引入的类型声明包
// "vite/client" → Vite 特有的类型(如 import.meta.env)
// "@webgpu/types" → WebGPU API 的类型声明
// 这就是笔记里说的 "types 配置 安装的类型文件"
"types": ["vite/client", "@webgpu/types"],
// allowArbitraryExtensions: 允许导入任意扩展名的文件
// 比如导入 .png、.svg 等非 TS/JS 文件时不报错
"allowArbitraryExtensions": true,
// skipLibCheck: 跳过 .d.ts 类型声明文件的检查
// 加快编译速度,避免第三方库的类型问题影响编译
"skipLibCheck": true,
// ====== Bundler 模式配置 ======
// moduleResolution: 模块解析策略
// "bundler" → 把模块解析交给打包工具(Vite),TS 只做类型检查
"moduleResolution": "bundler",
// allowImportingTsExtensions: 允许 import 带 .ts/.tsx 后缀
"allowImportingTsExtensions": true,
// verbatimModuleSyntax: 保留原始的 import/export 语法
// 不做模块语法的转换(交给打包工具处理)
"verbatimModuleSyntax": true,
// moduleDetection: 模块检测
// "force" → 把所有文件都当作模块处理
"moduleDetection": "force",
// noEmit: 不生成输出文件
// TS 只做类型检查,不编译成 JS(Vite/esbuild 负责编译)
"noEmit": true,
// jsx: JSX 语法转换方式
// "react-jsx" → React 17+ 的新 JSX Transform(不需要 import React)
"jsx": "react-jsx",
// ====== Linting 相关 ======
// noUnusedLocals: 有未使用的局部变量时报错
"noUnusedLocals": true,
// noUnusedParameters: 有未使用的函数参数时报错
"noUnusedParameters": true,
// erasableSyntaxOnly: 只允许可擦除的 TS 语法
// 禁止使用 enum、namespace 等在编译时需要生成运行时代码的语法
"erasableSyntaxOnly": true,
// noFallthroughCasesInSwitch: 禁止 switch case 穿透(缺少 break)
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
几个容易混淆的配置项解释:
| 配置项 | 作用 | 为什么要这样设 |
|---|---|---|
noEmit: true |
TS 不输出 JS 文件 | Vite 用 esbuild 编译,比 tsc 快得多 |
moduleResolution: "bundler" |
模块解析交给打包工具 | 让 TS 和 Vite 在模块查找上保持一致 |
erasableSyntaxOnly: true |
禁用 enum/namespace | 现代 TS 推荐只使用"类型注解",剩下的交给 JS |
jsx: "react-jsx" |
新 JSX Transform | 不需要在每个文件手动 import React |
八、设计模式:单例模式
笔记原文:
shell## 设计模式 OOP 面向对象编程,总结出来的23种解决特定问题的模式 数据结构,ADT 面向设计,而不是实现 Design Pattern ### 单例模式 类只实例化一次,全局只有一个实例。 用于解决全局变脸的问题,以及全局状态的问题
8.1 什么是单例模式
单例模式是 GoF 23 种设计模式中最常用的一种。笔记里一句话概括了它的本质:
"类只实例化一次,全局只有一个实例。"
用最简单的 Demo 来理解:
xml
<!-- singleton/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>单例模式打开窗口</title>
</head>
<body>
<button id="openBtn">打开新网页</button>
<script>
class Popup {
static ins; // 单例实例 静态属性 不需要先new Popup.ins
static getInstance() {
if (!Popup.ins) { // 第一次调用 → 创建
Popup.ins = new Popup(); // 后续调用 → 跳过
}
return Popup.ins; // 始终返回同一个实例
}
open(url) {
window.open(url, "_blank");
}
}
const a = Popup.getInstance(); // 单例模式,代替new
const b = Popup.getInstance();
console.log(a === b); // true ------ 两次拿到的完全是一个对象
const openBtn = document.getElementById("openBtn");
openBtn.addEventListener("click", () => {
window.open("https://www.baidu.com", "_blank");
a.open("https://www.baidu.com");
});
</script>
</body>
</html>
代码逐行解析:
static ins; --- static 关键字让 ins 成为类的静态属性 ,属于 Popup 类本身,不属于 Popup 的某个实例。访问方式是 Popup.ins,不需要 new Popup()。存的就是那个唯一的实例。
static getInstance() --- 静态方法,同样属于类本身。这个方法是获取单例的唯一入口 。外部代码不应该用 new Popup() 来创建实例(虽然 JavaScript 目前没有真正的私有构造函数来阻止)。
if (!Popup.ins) --- 这是关键:检查实例是否已经创建。
- 第一次调用
getInstance():Popup.ins是undefined→!undefined为true→ 进入 if 创建实例 - 后续调用:
Popup.ins已经是实例 →!实例为false→ 跳过创建,直接返回
const a = Popup.getInstance() --- 笔记注释:"单例模式,代替 new"。正常创建对象用 new Popup(),这里用 Popup.getInstance()。表面上都是拿到一个 Popup 对象,但 new 每次创建新对象,getInstance 始终返回同一个。
console.log(a === b) --- 打印 true。这证实了 a 和 b 指向内存中的同一个对象。
8.2 Worker 中的单例模式:懒加载 + ??=
javascript
// worker.js 第10-27行
/**
* This class uses the Singleton pattern to enable lazy-loading of the pipeline
*/
// pipeline 流水线 文本生成
// 分词器 大模型 配置文件
class TextGenerationPipeline {
static model_id = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";
// 单例模式 llm 只需要初始化一次,后面可以一直用,实例化好性能,单例管理
static async getInstance(progress_callback = null) {
// 分词器
// transform.js 提供的AutoTokenizer
// 适配DeepSeek-R1-Distill-Qwen-1.5B-ONNX
// 下载
// 下载进度跟新
// 100% from_pertrained 可以用了
this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
// 下载进度回调函数
progress_callback,
});
return Promise.all([this.tokenizer]);
}
}
注释逐条解析:
// pipeline 流水线 文本生成 --- Pipeline 是 HuggingFace 生态中的核心概念,把多个处理步骤(分词、推理、解码)串联成一个"流水线"。我们这里自定义的 TextGenerationPipeline 封装了"加载 → 分词 → 推理"的完整流程。
// 分词器 大模型 配置文件 --- 一个完整的模型加载需要三样东西:分词器(Tokenizer,负责文本 ↔ Token)、大模型(Model,神经网络权重)、配置文件(config.json,模型架构参数)。
// 单例模式 llm 只需要初始化一次,后面可以一直用 --- 这是单例模式的核心价值。一个 1.5B 参数的 LLM 加载到内存中占约 800MB 到几个 GB,加载一次耗时数分钟。如果每次都重新加载,用户体验不可接受。
// 实例化好性能,单例管理 --- 单例不是可选的,是性能要求。重复创建模型实例 = 重复分配几百 MB 内存 + 重复解析 ONNX 图结构,极其浪费。
// transform.js 提供的AutoTokenizer --- AutoTokenizer 的"Auto"表示自动选择合适的分词器类型。不同的模型使用不同的分词算法(BPE、WordPiece、SentencePiece 等),AutoTokenizer 会自动根据模型的 config.json 选择正确的实现。
// 适配DeepSeek-R1-Distill-Qwen-1.5B-ONNX --- from_pretrained 会下载并适配指定模型的 tokenizer。它下载 tokenizer.json(词表)和 config.json(配置),然后构建出能正确编码/解码该模型文本的 Tokenizer 对象。
// 下载 --- from_pretrained 的第一步:检查 IndexedDB 缓存,如果有就直接读,没有就从 HuggingFace CDN 下载。
// 下载进度更新 --- progress_callback 参数:每下载约 16KB 数据触发一次回调,Worker 通过 self.postMessage 把这个进度信息转发给主线程。
// 100% from_pretrained 可以用了 --- 下载完成 100% 后,tokenizer/model 构建完毕,可以使用了。
// 下载进度回调函数 --- 回调函数的具体形式在 load() 中传入:
scss
(x) => {
self.postMessage(x); // 原样转发给主线程
}
8.3 ??= 空值合并赋值 vs ||= vs =
kotlin
this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
progress_callback,
});
这是 ES2021 引入的操作符。它等价于:
kotlin
if (this.tokenizer === null || this.tokenizer === undefined) {
this.tokenizer = AutoTokenizer.from_pretrained(this.model_id, {
progress_callback,
});
}
三种赋值操作符的精确对比:
| 操作符 | 触发条件 | 示例中的表现 |
|-------|--------------------------|---------------------|-----------------------------------------------------------|--------------------------------------|
| = | 无条件 | 每次调用都重新下载------性能灾难 |
| ` | | =` | 左边是 falsy(null, undefined, false, 0, "", NaN) | 如果 tokenizer 已经初始化过,不会重复下载 ✅ 但语义不够精确 |
| ??= | 左边是 null 或 undefined | 只有"未初始化"才下载 ✅ 语义精确 |
为什么不用 ||=? 假设 tokenizer 对象有一个方法返回 0 或 ""(虽然不常见),||= 会误判为"未初始化"。??= 只关心"是否已经赋值过",这是更精确的语义。
8.4 Promise.all 并行加载
kotlin
return Promise.all([this.tokenizer]);
Promise.all 接收一个 Promise 数组,等所有 Promise 都完成后返回结果数组。在这个简化的版本中只有一个元素,但完整的实现中应该是:
kotlin
return Promise.all([this.tokenizer, this.model]);
makefile
串行加载(更慢):
Tokenizer ════→ 完成
↓
Model ════════════→ 完成
总耗时: T1 + T2
并行加载(更快):
Tokenizer ════→ 完成
Model ════════════→ 完成
总耗时: max(T1, T2) ← 只等最慢的那个
8.5 load() 函数:把进度回调传给单例
javascript
// worker.js 第51-63行
async function load() {
self.postMessage({
status: "loading",
data: "Loading model...",
});
const [tokenizer] = await TextGenerationPipeline.getInstance((x) => {
// We also add a progress callback to the pipeline so that we can
// track model loading.
console.log(x, '//////////////');
self.postMessage(x);
});
}
逐行解析:
self.postMessage({ status: "loading", data: "Loading model..." }) --- 先通知主线程"我开始加载了",主线程把 status 设为 "loading",UI 从首页切换到进度条视图。
TextGenerationPipeline.getInstance((x) => { ... }) --- 调用单例的 getInstance 方法并传入进度回调函数。x 是框架每次回调时传入的进度信息对象。
console.log(x, '//////////////') --- 调试日志,在浏览器控制台观察下载进度的原始数据。'//////////////' 是一个醒目的分隔符,便于在一堆日志中定位。
self.postMessage(x) --- 把收到的进度信息原样转发 给主线程。Worker 在这里只做数据中转,不做任何处理。所有业务逻辑(创建进度条、更新百分比、移除进度条)都在主线程的 onMessageReceived 中完成。
设计意图: Worker 保持纯粹------只负责计算和网络请求。UI 逻辑全部留在主线程。这是一条清晰的关注点分离线。
九、Vite 构建配置
javascript
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(), // ① React 插件
tailwindcss() // ② Tailwind CSS v4 插件
],
})
两个插件的作用:
@vitejs/plugin-react --- 让 Vite 支持 React 的 JSX 语法和 Fast Refresh(热更新时不丢失组件状态)。
@tailwindcss/vite --- Tailwind CSS v4 的 Vite 插件。v4 和 v3 的最大区别:不再需要 tailwind.config.js,改用 CSS 原生的 @theme 来定义设计 Token。
完整技术栈一览:
| 技术 | 作用 | 版本 |
|---|---|---|
| React | UI 框架,函数组件 + Hooks | 19.x |
| TypeScript | 类型安全 | 6.x |
| Vite | 构建工具,极速 HMR | 8.x |
| Tailwind CSS | 原子化 CSS | 4.x |
| @huggingface/transformers | 浏览器端模型推理 | 4.x |
| ONNX Runtime Web | 跨平台模型推理引擎 | (间接依赖) |
| WebGPU | GPU 硬件加速 | W3C 标准 |
| marked | Markdown → HTML | 18.x |
| DOMPurify | XSS 安全过滤 | 3.x |
| better-react-mathjax | 数学公式渲染 | 3.x |
十、App.tsx 组件架构全览
10.1 组件外的常量定义
ini
// App.tsx 第3-11行
const IS_WEBGPU_AVAILABLE = !!(navigator as any).gpu;
const STICKY_SCROLL_THRESHOLD = 120;
const EXAMPLES = [
"Solve the equation x^2 - 3x + 2 = 0",
"Lily is three times older than her son...",
"Write python code to compute the nth fibonacci number.",
];
这三个常量写在组件函数外面,原因是它们的值在整个应用生命周期中永远不变。写在里面每次 re-render 都会重新创建(新数组、新对象引用),写在外面只创建一次。
EXAMPLES 的三个问题有意覆盖了 DeepSeek-R1 的三大能力:数学推理、逻辑推理、代码生成。
10.2 React 状态定义
scss
// App.tsx 第17-28行
// Model loading and progress
const [status, setStatus] = useState(null);
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState("");
// Inputs and outputs
const [messages, setMessages] = useState([]);
status:null(首页)→"loading"(加载中)→"ready"(就绪)error:null(正常)→ 有值(显示红色错误信息)loadingMessage: 当前加载阶段的文案messages: 对话历史数组,每项包含{ role, content }
10.3 条件渲染逻辑
kotlin
// 如果 WebGPU 不支持 → 直接显示错误页
return IS_WEBGPU_AVAILABLE ? (
<div>...整个应用...</div>
) : (
<div>WebGPU is not supported by this browser :(</div>
)
ini
// 首页(status === null 且 没有消息)
{status === null && messages.length === 0 && (
// Logo、标题、介绍文字、Load model 按钮、示例问题
)}
// 加载中(status === "loading")
{status === "loading" && (
// 进度条列表
)}
// 聊天界面(status === "ready")
{status === "ready" && (
// Chat 组件、输入框
)}
// 错误提示
{error && (
// 红色错误框
)}
这四种状态互斥,同一时刻只会渲染其中一种 UI。这就是状态驱动 UI的核心思想。
十一、完整数据流回顾
php
用户输入 "解方程 x²-3x+2=0" 并按下 Enter
│
▼
┌─ 主线程 ─────────────────────────────────────────────────────┐
│ │
│ onEnter() → messages 追加 { role: "user", content: "..." } │
│ │
│ useEffect 检测到 messages 变化 │
│ → worker.current.postMessage({ type: "generate", │
│ data: messages }) │
│ │
└──────────────────┬────────────────────────────────────────────┘
│ postMessage
▼
┌─ Worker 线程 ─────────────────────────────────────────────────┐
│ │
│ case "generate": │
│ ① tokenizer.apply_chat_template(messages) → token IDs │
│ ② model._forward(token_ids) → 预测下一个 token │
│ ③ tokenizer.decode(new_token) → 文字 │
│ ④ self.postMessage({ status: "update", output: "解" }) │
│ ⑤ 循环 ②-④ 直到 max_tokens 或 EOS token │
│ │
└──────────────────┬────────────────────────────────────────────┘
│ postMessage
▼
┌─ 主线程 ─────────────────────────────────────────────────────┐
│ │
│ case "start": messages 追加 { role: "assistant", content:"" }│
│ case "update": messages[last].content += output │
│ React re-render → 用户看到逐字输出 │
│ case "complete": setIsRunning(false) → 输入框恢复 │
│ │
│ 渲染管线: │
│ marked.parse(assistant.content) → HTML 字符串 │
│ DOMPurify.sanitize(html) → 安全 HTML │
│ <MathJax> 包裹 → 数学公式渲染 │
│ React dangerouslySetInnerHTML → 显示到页面 │
│ │
└───────────────────────────────────────────────────────────────┘
这就是"在浏览器里用 WebGPU 运行 DeepSeek-R1"的完整技术解析。从 HuggingFace 模型社区,到 Transformer.js 引擎,到 Web Worker 多线程架构,到 WebGPU 硬件加速,到 TypeScript 类型系统,再到单例模式的设计思想------每一层都紧密结合代码和注释进行分析。
希望这篇文章能帮助同样在探索浏览器端 AI 的开发者。欢迎在评论区交流讨论!