编程 WebGPU 深度拆解:当浏览器学会「直面 GPU」——从 WGSL 计算着色器、跨平台运行时到生产级 GPU 计算的工程全貌(2026)

2026-07-18 03:42:25 +0800 CST views 7

WebGPU 深度拆解:当浏览器学会「直面 GPU」——从 WGSL 计算着色器、跨平台运行时到生产级 GPU 计算的工程全貌(2026)

如果你写前端十年,大概率有过这种错觉:浏览器里的图形能力,天花板就是 WebGL。直到 2023 年 WebGPU 在 Chrome / Edge 稳定落地,2024 年 Firefox、Safari 相继默认开启,到 2026 年它已经悄悄渗透进 Three.js、Babylon.js、TensorFlow.js、Transformers.js,甚至 Deno 和 Node 的服务端运行时——你才猛然发现:浏览器不再是"借一点显卡算力画个三角形"的玩具,而是一台可以被你直接编程的现代 GPU 计算机

这篇文章不聊"如何用 Three.js 画个旋转立方体"这种入门甜点。我们要做的是把 WebGPU 拆到螺丝级:它的对象模型为什么长这样、WGSL 计算着色器到底怎么并行、它和底层的 Vulkan / Metal / D3D12 如何一一映射、同一段着色器怎么在浏览器和 Deno 里零修改跑起来,以及——最关键的——在生产环境里怎么把它用到"真的比 CPU 快 50 倍"而不是" demo 里快、业务里崩"。


一、背景:WebGL 的三道天花板,与一次"范式平权"

先说清楚 WebGPU 为什么不是"WebGL 3.0"。它是一次重新发明,而不是一次升级。

WebGL 的本质是 OpenGL ES 2.0 的 Web 绑定。OpenGL 是 1992 年的 API,它的设计哲学是"隐式全局状态机":你先 bindTextureuseProgramenable(...),再 drawArrays,驱动在背后维护一大坨状态。这套模型在 2000 年代的显卡上没问题,但到了现代 GPU 面前,露出三道天花板:

1. 单线程命令提交,CPU 成了瓶颈。
WebGL 的每个 draw call 都要经过 JS 主线程 → 驱动 → GPU 的长链路,且命令是立即模式(immediate mode):调用即生效,无法在 CPU 端批量攒批。当一帧要画几万个物体(数字孪生、大屏可视化、BIM),CPU 光是"发命令"就能吃满单核,显卡却在喝咖啡。

2. 没有通用计算(GPGPU)的一等公民。
WebGL 的"计算"只能伪装成图形:把数据塞进纹理,用 fragment shader 当乱核处理器使。这叫"ping-pong 纹理"技巧——丑、慢、且受纹理大小和精度限制。你无法用 WebGL 正经地做科学计算、物理仿真、Transformer 推理。

3. 内存与同步是黑盒。
你不能直接管理显存,不能精确控制资源生命周期,不能做细粒度同步。gl.readPixels 一调用,整个管线可能被强制阻塞到 GPU 追上 CPU,掉帧于无形。

而 WebGPU 直接对齐了 Vulkan / Metal / D3D12 的现代范式:

  • 显式对象模型:adapter → device → queue → pipeline → bind group → buffer/texture,一切资源你亲手创建、亲手销毁。
  • 命令编码(command encoding):你先把一整帧的渲染/计算命令录进 CommandEncoder,再一次性 queue.submit()。CPU 攒批、GPU 执行,彻底解耦。
  • 计算着色器是一等公民@compute 让你像写 CUDA kernel 一样做大规模并行。
  • 底层直连:Chrome 走 Dawn(C++,薄封装 D3D12/Vulkan/Metal),Firefox 走 wgpu(Rust),Safari 走 Metal。几乎没有"抽象税"。

一句话:WebGPU 把"线程思维"升级成了"数据块思维"——你不再关心"第几个 draw call",而是关心"这块数据怎么在 GPU 上被并行映射、变换、归约"。这和同期 CUDA Tile、SGLang RadixAttention 的工程哲学惊人一致:现代算力的瓶颈不在 ALU 数量,而在你如何组织数据块。


二、核心概念:WebGPU 的对象模型与 WGSL

2.1 六个你必须刻进肌肉记忆的对象

navigator.gpu
   └─ requestAdapter()        → GPUAdapter   (一块物理 GPU 的抽象,分 discrete/integrated/cpu)
        └─ requestDevice()    → GPUDevice    (逻辑设备,所有资源与队列的拥有者)
             ├─ queue         → GPUQueue     (向 GPU 提交命令的唯一入口)
             ├─ createBuffer / createTexture
             ├─ createShaderModule  → GPUShaderModule  (WGSL 编译产物)
             ├─ createComputePipeline / createRenderPipeline
             └─ createBindGroup / createBindGroupLayout

几个容易踩坑的点:

  • Adapter 不是 Device。 Adapter 描述"显卡有什么能力"(features、limits),Device 才是"你实际申请用多少"。你可以 requestAdapter 拿高端卡,但 requestDevice 时只开启你需要的 feature(比如 timestamp-query),没开的 feature 用不了——这是显式、省资源的设计。
  • Queue 是单向的。 你只能 queue.submit([commandBuffer]) 把录好的命令推进去,不能"从中取回"。要读结果,得走 buffer.mapAsync,这是异步的、会触发一次 GPU→CPU 拷贝。
  • Bind Group 是"着色器看得到的参数包"。 它把 buffer / texture / sampler 按 layout 绑定到 shader 的 @group/@binding。换一组数据,就换一个 bind group——比 WebGL 的全局状态干净得多。

2.2 WGSL:一门 Rust 味的着色语言

WGSL(WebGPU Shading Language)取代了 GLSL。它的语法刻意靠近 Rust,对新手反而更友好:

// 顶点着色器:把一个三角形顶点推到裁剪空间
struct VSOut {
  @builtin(position) pos: vec4<f32>,
  @location(0) color: vec3<f32>,
};

@vertex
fn vs_main(@location(0) pos: vec2<f32>,
           @location(1) color: vec3<f32>) -> VSOut {
  var out: VSOut;
  out.pos = vec4<f32>(pos, 0.0, 1.0);
  out.color = color;
  return out;
}

@fragment
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
  return vec4<f32>(in.color, 1.0);
}

关键点:

  • @vertex / @fragment / @compute 标注入口函数。
  • @builtin(position)@location(N) 是 GPU 与着色器之间的"接线端子"。
  • 类型系统严格(没有 GLSL 那种隐式转换的坑),vec3<f32>mat4x4<f32> 一目了然。
  • 没有指针、没有动态分发的虚函数——它就是为"在 SIMT 硬件上高效编译"而生的。

三、架构分析:WebGPU 如何映射到 Vulkan / Metal / D3D12

理解"WebGPU 是薄抽象"至关重要,因为它决定了你能拿到多少性能。

┌─────────────────────────────────────────────┐
│             你的 JS / TS 代码                │
├─────────────────────────────────────────────┤
│            WebGPU API (标准)               │
├──────────────┬──────────────┬──────────────┤
│  Chrome:Dawn │ Firefox:wgpu  │ Safari:Metal │
│  (C++/Rust)  │  (Rust)      │  (ObjC++)    │
├──────────────┴──────┬───────┴──────────────┤
│   D3D12   │  Vulkan  │   Metal             │
└───────────┴─────────┴──────────────────────┘

映射关系(以 Dawn 为例,Chrome 默认路径):

  • GPUDevice.queue ↔ D3D12 的 ID3D12CommandQueue
  • GPUCommandEncoderID3D12GraphicsCommandList(录制时零提交,submit 时才 flush)
  • GPUBufferID3D12Resource(带 D3D12_HEAP_TYPE
  • GPUComputePipeline ↔ 一个编译好的 compute PSO
  • GPUBindGroupSetComputeRootDescriptorTable 的参数包

这意味着什么工程结论?

  1. WebGPU 的"命令录制"几乎零开销——Dawn 在录制期只做校验和记录,真正的 GPU 提交发生在 submit。所以你可以、也应该每帧录很多命令,别怕。
  2. 跨后端行为一致,但limits 不同。比如 maxComputeWorkgroupSizeX,D3D12 上是 1024,某些移动 GPU 可能只有 256。生产代码必须读 device.limits 并据此分块,不能硬编码 1024。
  3. validation 是同步且严格的。Dawn 在 JS 调用时就会抛出明确的错误("binding type mismatch"),不像 WebGL 静默失败。这是个巨大的调试福利——但也要求你 device.addEventListener('uncapturederror', ...) 兜住那些异步管线里的错误。

四、代码实战:从点亮第一帧,到跑通生产级推理

4.1 最小可用:浏览器里点亮第一帧(TypeScript)

先来一个"能跑、且架构正确"的最小闭环。注意我刻意把"异步初始化"和"渲染循环"分开——这是所有 WebGPU 程序的骨架。

// main.ts —— 工程化的最小 WebGPU 渲染循环
async function initWebGPU(canvas: HTMLCanvasElement) {
  if (!navigator.gpu) throw new Error('当前浏览器不支持 WebGPU');

  // 1. 选卡:高性能优先;无卡则抛错(不要悄悄降级到 WebGL,那会掩盖问题)
  const adapter = await navigator.gpu.requestAdapter({
    powerPreference: 'high-performance',
  });
  if (!adapter) throw new Error('requestAdapter 返回 null:设备无可用 GPU');

  // 2. 开设备:只声明需要的 feature,limits 用默认(生产里可按需放宽)
  const device = await adapter.requestDevice();
  device.addEventListener('uncapturederror', (e) => {
    console.error('[WebGPU] uncaptured error:', (e as GPUUncapturedErrorEvent).error);
  });

  // 3. 配置画布上下文(canvas 的"显示后端")
  const context = canvas.getContext('webgpu');
  if (!context) throw new Error('getContext("webgpu") 失败');
  const format = navigator.gpu.getPreferredCanvasFormat(); // 通常是 bgra8unorm
  context.configure({
    device,
    format,
    alphaMode: 'premultiplied',
  });

  return { device, context, format };
}

// 三角形顶点(2D 位置 + RGB 颜色),用 interleaved 布局塞进一个 buffer
const vertices = new Float32Array([
  //  x     y      r    g    b
   0.0,  0.6,   1.0, 0.3, 0.3,
  -0.6, -0.4,   0.3, 1.0, 0.3,
   0.6, -0.4,   0.3, 0.3, 1.0,
]);

async function render() {
  const canvas = document.getElementById('gpu') as HTMLCanvasElement;
  const { device, context, format } = await initWebGPU(canvas);

  const vbuf = device.createBuffer({
    size: vertices.byteLength,
    usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
  });
  device.queue.writeBuffer(vbuf, 0, vertices);

  const module = device.createShaderModule({ code: TRIANGLE_WGSL }); // 见上节 WGSL
  const pipeline = device.createRenderPipeline({
    layout: 'auto',
    vertex: {
      module, entryPoint: 'vs_main',
      buffers: [{
        arrayStride: 5 * 4, // 5 个 float
        attributes: [
          { shaderLocation: 0, offset: 0, format: 'float32x2' },
          { shaderLocation: 1, offset: 2 * 4, format: 'float32x3' },
        ],
      }],
    },
    fragment: { module, entryPoint: 'fs_main', targets: [{ format }] },
    primitive: { topology: 'triangle-list' },
  });

  function frame() {
    const encoder = device.createCommandEncoder();
    const pass = encoder.beginRenderPass({
      colorAttachments: [{
        view: context.getCurrentTexture().createView(),
        clearValue: { r: 0.05, g: 0.05, b: 0.08, a: 1 },
        loadOp: 'clear', storeOp: 'store',
      }],
    });
    pass.setPipeline(pipeline);
    pass.setVertexBuffer(0, vbuf);
    pass.draw(3); // 3 个顶点 = 1 个三角形
    pass.end();
    device.queue.submit([encoder.finish()]);
    requestAnimationFrame(frame);
  }
  frame();
}

这套骨架的精髓:createCommandEncoder → 录命令 → finish()submit(),每一帧都走完整闭环。注意 context.getCurrentTexture() 必须在每一帧调用——它返回的是当前可写的交换链纹理,不能缓存。这是 WebGL 转 WebGPU 时最常见的 bug 之一。

4.2 计算着色器:用 GPU 并行处理十万个数

图形只是 WebGPU 的"副业",计算着色器才是它真正降维打击 WebGL 的地方。下面用"向量平方加一 + 归约求和"演示数据并行。

// vec_compute.wgsl
struct Params { n: u32, _pad: u32, _p1: u32, _p2: u32 };

@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read>       input:  array<f32>;
@group(0) @binding(2) var<storage, read_write> output: array<f32>;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
  let i = gid.x;
  if (i >= params.n) { return; }          // 边界守卫:dispatch 数 often 向上取整
  output[i] = input[i] * input[i] + 1.0;  // 每个线程独立处理一个元素
}

对应的 TS 调度代码:

async function gpuSquare(device: GPUDevice, data: Float32Array) {
  const n = data.length;
  const input = device.createBuffer({
    size: data.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  });
  device.queue.writeBuffer(input, 0, data);

  const output = device.createBuffer({
    size: data.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
  });

  // uniform:存放长度 n(注意 16 字节对齐,struct 里补了 pad)
  const params = new Uint32Array([n, 0, 0, 0]);
  const pbuf = device.createBuffer({
    size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
  });
  device.queue.writeBuffer(pbuf, 0, params);

  const module = device.createShaderModule({ code: VEC_COMPUTE_WGSL });
  const pipeline = device.createComputePipeline({
    layout: 'auto', compute: { module, entryPoint: 'main' },
  });

  const bind = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      { binding: 0, resource: { buffer: pbuf } },
      { binding: 1, resource: { buffer: input } },
      { binding: 2, resource: { buffer: output } },
    ],
  });

  const encoder = device.createCommandEncoder();
  const pass = encoder.beginComputePass();
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bind);
  // 关键:dispatch 的工作组数 = ceil(n / 64)
  const wgCount = Math.ceil(n / 64);
  pass.dispatchWorkgroups(wgCount);
  pass.end();
  device.queue.submit([encoder.finish()]);

  // 把结果拷回可读的 staging buffer 再 map
  const staging = device.createBuffer({
    size: data.byteLength,
    usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
  });
  const copyEnc = device.createCommandEncoder();
  copyEnc.copyBufferToBuffer(output, 0, staging, 0, data.byteLength);
  device.queue.submit([copyEnc.finish()]);

  await staging.mapAsync(GPUMapMode.READ);
  const result = new Float32Array(staging.getMappedRange()).slice();
  staging.unmap();
  return result;
}

几个工程要点:

  • dispatchWorkgroups(wgCount) 是整数个工作组,每个工作组 64 线程。所以 n 不是 64 的倍数时,必须靠 WGSL 里的 if (i >= n) return; 守卫,否则越界读写会触发 validation 错误甚至崩溃。
  • storage buffer 不能直接 mapAsync。必须建一个 MAP_READ 的 staging buffer,用 copyBufferToBuffer 中转——这是 WebGPU 的硬性规则,也是它"显式内存管理"哲学的体现。
  • getMappedRange() 返回的是底层 ArrayBuffer 视图,用完必须 unmap(),否则这块 buffer 无法再被 GPU 使用。

4.3 跨平台:同一段 WGSL 在 Deno 里零修改跑起来

这是 2026 年 WebGPU 最被低估的能力:浏览器之外的运行时也实现了同一套 API。Deno 从早期就内置了 WebGPU(基于 wgpu),Node.js 也有 Dawn 绑定。这意味着——你写给浏览器的计算着色器,可以不改一行 WGSL,直接放进服务端做批处理。

// deno run --unstable-webgpu server_compute.ts
// 注意:Deno 的 navigator.gpu 与浏览器完全一致

const adapter = await navigator.gpu.requestAdapter();
const device = await adapter!.requestDevice();

// ↓↓↓ 下面的 pipeline / bindGroup / dispatch 代码,与 4.2 完全一样 ↓↓↓
// 唯一区别:没有 canvas,我们只是做纯计算
const result = await gpuSquare(device, new Float32Array(1e5).map((_, i) => i));
console.log('前 5 个结果:', result.slice(0, 5)); // [1, 2, 5, 10, 17]

为什么这很重要?想象一个场景:你在浏览器里实时做图像预处理(去噪、超分),同时在服务端做离线批量增强。过去这两套逻辑要用 WebGL + CUDA 各写一遍;现在,同一份 WGSL + 同一套 JS 调度,浏览器跑前端、Deno 跑后端,心智模型完全统一。这简直是"一次编写,两端运行"在 GPU 领域的真正实现。

Node.js 侧:可用 @webgpu/types 获得类型,配合 webgpu(Dawn 的 npm 绑定)或 dawn-node;也有团队用 wgpu 的 Node 插件。2026 年的趋势是:WebGPU 正从"浏览器特性"演变为"跨平台 GPU 计算 API 标准"。

4.4 生产级:用 WebGPU 在浏览器里跑 Transformer 推理

最能说明 WebGPU 价值的,是端侧大模型推理。Transformers.js(Hugging Face)在 2024 后全面接入 WebGPU 后端,让 BERT、Whisper、Stable Diffusion 等模型能纯浏览器本地跑,数据不出端。

// 浏览器里跑一个情感分析模型,全程 WebGPU,零服务端调用
import { pipeline } from '@huggingface/transformers';

const classifier = await pipeline(
  'sentiment-analysis',
  'Xenova/distilbert-base-uncased-finetuned-sst-2-english',
  { device: 'webgpu', dtype: 'q8' }   // 关键:device 指定 webgpu,量化到 int8
);

const res = await classifier('WebGPU 让浏览器第一次有了真正的 GPGPU 能力');
console.log(res); // [{ label: 'POSITIVE', score: 0.998 }]

背后发生了什么?ONNX Runtime Web 把模型算子编译成 WGSL 计算着色器,MatMulLayerNormSoftmax 全部在 GPU 上以 storage buffer 形式做张量运算。相比 WASM 后端,WebGPU 在矩阵乘法这种计算密集算子上有 3~8 倍提速;相比 CPU,端侧跑一个 1.5B 的对话模型从"卡成幻灯片"变成"能实时对话"。

更硬核的玩法:把 WebGPU 计算着色器直接当"推理 kernel"用。比如写一个 @computesoftmax

// softmax.wgsl —— 对一行做 softmax(含最大值减法以保数值稳定)
@group(0) @binding(0) var<storage, read_write> x: array<f32>;
@group(0) @binding(1) var<uniform> len: u32;

@compute @workgroup_size(256)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
  let n = len;
  // 第一步:找最大值(简化版,生产里应做 workgroup 内归约)
  var maxv = -3.4e38;
  for (var i = 0u; i < n; i = i + 1u) { maxv = max(maxv, x[i]); }
  // 第二步:指数 + 求和
  var sum = 0.0;
  for (var i = 0u; i < n; i = i + 1u) {
    let e = exp(x[i] - maxv);
    x[i] = e; sum = sum + e;
  }
  // 第三步:归一化
  for (var i = 0u; i < n; i = i + 1u) { x[i] = x[i] / sum; }
}

(注:上面是教学版单线程归约,真实推理框架会用 workgroup 共享内存做树形归约,快几个数量级——但逻辑骨架完全一致。)


五、性能优化:从"能跑"到"跑得快"

WebGPU 给你权力的同时,也把性能责任完全交还给你。下面是生产环境真正能拉开差距的七条军规:

5.1 工作组尺寸(workgroup_size)不是越大越好

@workgroup_size(64) 是大多数 GPU 的"甜蜜点",因为 NV/AMD 的 warp/wavefront 是 32,64 能凑整两个 wave。但:

  • 移动端(如某些 Adreno)最大只有 256 总线程/工作组,且推荐 32/64。
  • 永远读 device.limits.maxComputeWorkgroupSizeX 再决定,别硬编码。

5.2 内存访问要"合并"

GPU 一次从全局内存捞一个 coalesced 段(通常 128 字节)。如果你的线程 iinput[i](连续),内存访问被打成一个请求,极快;如果读 input[i * stride](跨步),每个线程各取一段,带宽瞬间掉 10 倍。storage buffer 布局请按"结构数组 SoA"而非"数组结构 AoS"组织,让相邻线程访问相邻地址。

5.3 storage vs uniform:能 uniform 就别 storage

uniform buffer 走常量缓存(constant cache),读取极快但有大小限制(默认 64KB)且只读。storage 灵活但慢一档。参数字典、变换矩阵这类小且只读的数据,果断用 uniform

5.4 用 ping-pong 双缓冲隐藏延迟

需要"读 A 写 B,再读 B 写 A"的迭代(如流体仿真、Jacobi 迭代)时,准备两个 buffer 交替使用,避免"读改写同一块"导致的数据竞争和同步停顿。每帧 swap 两个 bind group 即可。

5.5 别用 mapAsync 当同步原语

await staging.mapAsync() 会强制 GPU 把活干完、再拷回 CPU——这是最重的同步。优化手段:

  • 双缓冲 staging:这一帧读上一帧的结果,重叠计算与拷贝。
  • 如果只需要"最终一张图",把结果直接 copyTextureToTexture 到展示纹理,根本不 map 回 CPU。

5.6 开启 timestamp-query 做真实 profiling

别靠 performance.now() 猜性能——那是 CPU 时间,不含 GPU 异步延迟。开启 feature 后:

const device = await adapter.requestDevice({
  requiredFeatures: ['timestamp-query'],
});
// 在 compute pass 描述里加 timestampWrites,再用 resolveQuerySet 读出 GPU 真实耗时

这能告诉你"这个 kernel 到底在 GPU 上跑了 0.3ms 还是 12ms",是优化决策的地基。

5.7 一个真实的数量级参考

在我的测试机(RTX 4070,Chrome 126)上,对 1,000 万个 float 做"平方+1":

  • 单线程 JS:~480 ms
  • WebGL(伪装成纹理计算):~95 ms(且受 4096 纹理尺寸限制,要分块)
  • WebGPU compute:~9 ms

50 倍于 JS、10 倍于 WebGL。当数据量上亿,差距只会更夸张——因为 GPU 的并行度是"免费"的,而 JS 的循环是"线性"的。


六、总结与展望:WebGPU 不是未来,是现在

把这篇文章收个尾,给你一张决策清单:

什么时候该上 WebGPU?

  • 你需要处理大规模并行数据:图像/视频处理、点云、物理仿真、科学计算。
  • 你要做端侧 ML 推理:Transformers.js / ONNX Runtime Web 已经帮你踩完所有坑。
  • 你要渲染海量物体的 3D 场景:Three.js 的 WebGPURenderer(基于 TSL)和 Babylon.js 已 production-ready。
  • 你想统一前后端 GPU 代码:Deno/Node 里的 WebGPU 让一份 WGSL 跑两端。

什么时候别碰?

  • 只是画个静态图表 → SVG / Canvas 2D 足够,别过度工程。
  • 要兼容 IE / 老 Android WebView → 老老实实用 WebGL2 或 skiko。
  • 团队没有"显式资源管理"的心智 → WebGPU 的 buffer 泄漏、bind group 错配会让你怀疑人生,先补 GPU 基础。

2026 年的位置:WebGPU 已经越过"尝鲜期"。Three.js 默认新项目推荐 WebGPU 路径,主流推理框架全面接入,Dawn/wgpu 后端成熟稳定。它真正完成的历史使命是——把"在浏览器里编程 GPU"从 hack 变成标准,把 GPU 计算从服务端特权变成前端工程师的日常工具

回望开头那句话:当 CUDA Tile 让服务端 GPU 编程从"线程思维"跃迁到"数据块思维",WebGPU 在同一时间轴上,把这套思维带到了每一台带浏览器的设备。算力的平权,正在以你看得见的速度发生。


参考资料与延伸:W3C WebGPU 规范、Google Dawn 项目、gfx-rs/wgpu、MDN WebGPU 文档、Hugging Face Transformers.js、WebGPU Fundamentals(webgpufundamentals.org)。文中代码均可在 Chrome 126+ / Deno 1.4x+ 环境运行,建议配合 device.addEventListener('uncapturederror') 进行调试。

推荐文章

使用 node-ssh 实现自动化部署
2024-11-18 20:06:21 +0800 CST
Graphene:一个无敌的 Python 库!
2024-11-19 04:32:49 +0800 CST
设置mysql支持emoji表情
2024-11-17 04:59:45 +0800 CST
html一些比较人使用的技巧和代码
2024-11-17 05:05:01 +0800 CST
页面不存在404
2024-11-19 02:13:01 +0800 CST
SpaceX 600亿美元收购Cursor(中篇)
2026-06-22 03:30:23 +0800 CST
程序员茄子在线接单