从零在浏览器里跑 DeepSeek-R1:WebGPU + Transformer.js 全链路实战

从零在浏览器跑 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:让浏览器能跑模型的核心引擎

笔记原文:

bash 复制代码
transform.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 模型:完整的数据链路

笔记原文:

rust 复制代码
deepseek 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"

这是整个项目的核心,没有它就没有浏览器端的模型推理。它做了几件事:

  1. 模型发现:根据模型 ID 自动拼接 HuggingFace CDN 的文件 URL
  2. 模型下载:从 CDN 下载 ONNX 格式的模型权重文件,支持断点续传和进度回调
  3. 模型加载:将 ONNX 文件加载到 ONNX Runtime Web,创建推理会话
  4. 推理执行:提供高层 API(pipeline、generate)和底层 API(model._forward())
  5. 硬件选择:自动检测并选择最优后端(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) 初始化时 .currentnull,创建 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(navigatorlocationfetch 等)。Worker 中可以使用部分 BOM API,比如 navigatorfetch

// 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.insundefined!undefinedtrue → 进入 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 已经初始化过,不会重复下载 ✅ 但语义不够精确 |
| ??= | 左边是 nullundefined | 只有"未初始化"才下载 ✅ 语义精确 |

为什么不用 ||= 假设 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 的开发者。欢迎在评论区交流讨论!

相关推荐
CodeSheep2 小时前
稚晖君公司人事大变动,来了!
前端·后端·程序员
鱼樱前端2 小时前
用 AI 做内容变收入
前端·人工智能·ai编程
浮生望2 小时前
React + TypeScript 组件设计进阶:从类型约束到无状态组件的三次进化
前端
90后的晨仔3 小时前
Mac 开发 uni-app 终极方案:让安卓模拟器彻底脱离 Android Studio 独立运行
前端·vue.js
90后的晨仔3 小时前
别再搞混了!uni-app 中 node_modules 和 uni_modules 到底是什么关系?
前端
kyriewen3 小时前
面试官问"这段逻辑为什么这样写"——我才发现,这半年我写的代码,我自己都解释不了
前端·javascript·面试
谢小飞4 小时前
大文件上传很难?学会这招,GB文件秒传不是梦
前端·node.js
程序员黑豆4 小时前
Windows 系统 Java 环境变量配置全攻略:解决“不是内部或外部命令”
java·前端·ai编程
To_OC5 小时前
别只拿 useRef 绑 DOM 了,搭配 Web Worker 解决页面卡顿才是真的香
前端·react.js·dom