从 JavaScript 到 WGSL:用渐变渲染理解 GPU 编程思维

本文通过一个真实的渐变渲染案例,帮助习惯 JavaScript/TypeScript 的 CPU 侧程序员快速建立 GPU Shader 编程的心智模型。

引言:为什么要学 Shader?

作为前端或后端开发者,我们习惯了"顺序执行"的编程思维------代码从上到下一行行执行,循环遍历数组,逐个处理数据。但当你需要渲染成千上万个像素时,这种方式太慢了。

GPU 的核心优势是并行:它可以同时处理数千个像素,每个像素独立运行相同的代码(Shader)。理解这一点,是从 CPU 编程迁移到 GPU 编程的关键。


1. 思维转换:从"循环"到"并行"

JavaScript 思维(CPU)

假设你要给一个 200×200 的矩形填充渐变色,在 JS 中你可能会这样写:

ini 复制代码
// CPU 思维:顺序遍历每个像素
for (let y = 0; y < 200; y++) {
    for (let x = 0; x < 200; x++) {
        const t = x / 200;  // 计算渐变位置 [0, 1]
        const color = interpolateColor(startColor, endColor, t);
        setPixel(x, y, color);
    }
}

这段代码需要执行 40,000 次 循环,每次调用 setPixel

WGSL 思维(GPU)

在 Shader 中,你不需要写循环。GPU 会自动为每个像素启动一个独立的"线程",每个线程只负责计算自己那一个像素的颜色:

less 复制代码
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
    // 我是谁?GPU 告诉我我正在处理的像素坐标
    let localPos = input.localPos;  // 比如 (100, 50)
    
    // 计算我这个像素的渐变位置
    let t = localPos.x / 200.0;
    
    // 返回我这个像素应该显示的颜色
    return mix(startColor, endColor, t);
}

💡 mix 函数详解

mix(a, b, t) 是 GPU 内置的线性插值函数 ,等价于 a * (1 - t) + b * t

JavaScript 等价实现

css 复制代码
function mix(a, b, t) {
    return a * (1 - t) + b * t;
}
// 当 t=0 时返回 a,t=1 时返回 b,t=0.5 时返回 a 和 b 的中间值

mix 的妙用:替代 if 语句

在 GPU 中,if 分支会导致性能问题(后文详述)。mix 可以优雅地替代某些条件判断:

scss 复制代码
// ❌ 有分支的写法
if (isHovered) { color = hoverColor; } else { color = normalColor; }

// ✅ 无分支的写法(isHovered 为 0.0 或 1.0)
color = mix(normalColor, hoverColor, f32(isHovered));

核心区别

对比项 JavaScript (CPU) WGSL (GPU)
执行模式 一个线程,循环 40,000 次 40,000 个线程,每个执行 1 次
数据访问 可以访问任意像素 只知道"我自己"的坐标
返回值 调用 setPixel return 颜色值

💡 类比:想象你是工厂里的一个工人(GPU 线程),你只负责给传送带上经过你面前的那一个产品上色。你不知道也不关心其他工人在干什么,你只需要知道"我这个产品应该是什么颜色"。


2. Shader 的两个阶段:Vertex 和 Fragment

GPU 渲染管线主要分两步,对应两种 Shader:

2.1 Vertex Shader(顶点着色器)------ "形状在哪里?"

ini 复制代码
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
    var output: VertexOutput;
    
    // 1. 将顶点从模型空间变换到屏幕空间
    let pos = uniforms.transform * vec3<f32>(input.position, 1.0);
    output.position = vec4<f32>(pos.xy, 0.0, 1.0);
    
    // 2. 把原始坐标传给 Fragment Shader
    output.localPos = input.position;
    
    return output;
}

职责 :处理几何图形的顶点(三角形的三个角),决定它们在屏幕上的位置。

类比:如果把渲染比作"填色游戏",Vertex Shader 就是"画轮廓线"的步骤。

为什么需要空间变换?

你可能会问: "为什么不能直接用顶点坐标画图?" 答案是:可以,但只能画固定位置、固定大小的图形。

举个例子 :假设你定义了一个 100×100 的正方形,顶点坐标是 (0,0), (100,0), (100,100), (0,100)

场景 不用变换 用变换矩阵
移动到 (200, 150) 重新计算 4 个顶点坐标 只需修改矩阵的平移分量
放大 2 倍 重新计算 4 个顶点坐标 只需修改矩阵的缩放分量
旋转 45° 三角函数重算所有顶点 只需修改矩阵的旋转分量
同时移动+缩放+旋转 代码爆炸 💥 矩阵相乘,一行搞定

JavaScript 类比

arduino 复制代码
// ❌ 不用变换:每次都要重新算坐标
function drawSquare(x, y, size, rotation) {
    const cos = Math.cos(rotation), sin = Math.sin(rotation);
    const points = [
        [x + 0 * cos - 0 * sin, y + 0 * sin + 0 * cos],
        [x + size * cos - 0 * sin, y + size * sin + 0 * cos],
        // ... 太复杂了
    ];
}

// ✅ 用变换矩阵:顶点数据不变,只改矩阵
const vertices = [[0,0], [100,0], [100,100], [0,100]];  // 永远不变
const transform = mat3.multiply(translate, rotate, scale);  // 组合变换

核心优势

  1. 顶点数据可复用 ------ 同一个正方形的顶点数据可以被缓存,画 1000 个正方形只需要传 1000 个不同的矩阵
  2. 变换可组合 ------ 父子节点的变换通过矩阵乘法自动传递
  3. GPU 友好 ------ 矩阵乘法是 GPU 最擅长的运算

2.2 Fragment Shader(片元着色器)------ "像素是什么颜色?"

less 复制代码
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
    // 根据位置计算颜色
    if (uniforms.paintType == 0u) {
        return uniforms.color;  // 纯色填充
    }
    
    // 渐变填充...
    let gradPos = (uniforms.gradientTransform * vec3<f32>(input.localPos, 1.0)).xy;
    let t = gradPos.x;
    return interpolateGradient(t);
}

职责 :对三角形内部的每个像素计算颜色。这是 GPU 并行威力最大的地方。

类比:Fragment Shader 就是"填色"步骤,为轮廓线内的每个格子决定颜色。


3. 数据传递:CPU 如何与 GPU 通信?

在 JavaScript 中,函数之间通过参数和返回值通信。但 GPU 是一个独立的硬件,数据需要显式地"打包发送"。

3.1 Uniform:全局只读数据

less 复制代码
struct Uniforms {
    transform: mat3x3<f32>,      // 变换矩阵
    color: vec4<f32>,            // 颜色
    gradientTransform: mat3x3<f32>,
    paintType: u32,              // 0: 纯色, 1: 线性渐变, 2: 径向渐变
    stopCount: u32,
    stops: array<GradientStop, 8>,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;

特点

  • 所有线程共享同一份数据 (所有像素看到的 uniforms.color 是一样的)
  • 只读------Shader 不能修改它

JavaScript 对应概念:类似于"全局常量"或"配置对象"。

3.2 CPU 侧打包数据(TypeScript)

scss 复制代码
// 创建一个 ArrayBuffer,按照 GPU 要求的内存布局填充数据
const uniformData = new Float32Array(96);  // 384 bytes

// 填充变换矩阵 (mat3x3 在 GPU 中占 48 bytes)
uniformData.set(transformMatrix, 0);

// 填充颜色 (vec4 占 16 bytes)
uniformData.set([r, g, b, a], 12);

// 上传到 GPU
device.queue.writeBuffer(uniformBuffer, 0, uniformData);

⚠️ 大坑预警:内存对齐 (std140)

GPU 对数据布局有严格要求。vec3 不是 12 字节,而是 16 字节mat3x3 不是 36 字节,而是 48 字节

这是 CPU 程序员最容易踩的坑。详见后文"调试技巧"。


4. 渐变实现:完整案例解析

让我们用一个真实的渐变渲染来串联上述知识。

4.1 数据结构

rust 复制代码
struct GradientStop {
    color: vec4<f32>,    // RGBA 颜色
    position: f32,       // 位置 [0, 1]
    _pad0: f32,          // 填充对齐
    _pad1: f32,
    _pad2: f32,
}

为什么要 _pad 因为 GPU 要求结构体按 16 字节对齐。vec4 是 16 字节,f32 是 4 字节,总共 20 字节,需要填充到 32 字节。

为什么 GPU 要求 16 字节对齐?

这是硬件架构决定的,主要原因有三:

  1. 内存读取效率:GPU 的内存控制器按 16 字节(128 位)为单位读取数据。如果数据跨越两个 16 字节边界,需要两次内存访问,性能直接减半。
  2. SIMD 指令集:GPU 使用 SIMD(单指令多数据)架构,一条指令同时处理 4 个 float(正好 16 字节)。对齐的数据可以直接加载到寄存器,不对齐则需要额外的移位操作。
  3. 缓存行优化:GPU 缓存行通常是 128 字节或 256 字节。16 字节对齐确保数据不会横跨缓存行,避免缓存失效。

JavaScript 类比

arduino 复制代码
// 想象你有一个只能每次搬 4 瓶水的托盘(16 字节)
// ❌ 不对齐:3 瓶水放第一托盘,1 瓶放第二托盘 → 搬 4 瓶要跑两趟
// ✅ 对齐:4 瓶水放一个托盘,空位用泡沫填充 → 一趟搞定

std140 布局规则速记

类型 实际大小 对齐到 说明
f32 4 4
vec2 8 8
vec3 12 16 浪费 4 字节
vec4 16 16
struct 字段之和 最大字段对齐 × 整数倍 向上取整

4.2 坐标变换

从像素坐标到渐变参数 t 的转换是渐变的核心:

ini 复制代码
// 将像素坐标 (0~200) 变换到渐变空间 (0~1)
let gradPos = (uniforms.gradientTransform * vec3<f32>(input.localPos, 1.0)).xy;

// 线性渐变:水平方向的 x 坐标就是 t
let t = gradPos.x;

为什么需要渐变空间变换?

问题 :假设一个 200×100 的矩形,像素坐标范围是 x: 0~200, y: 0~100。如何计算每个像素的渐变位置 t

最简单的方法t = x / 200,即 x=0 时 t=0(起始色),x=200 时 t=1(结束色)。

但这只能实现从左到右的水平渐变。如果设计师想要:

  • 从上到下的垂直渐变?
  • 45° 斜向渐变?
  • 从中心向外的径向渐变?

答案就是空间变换矩阵。通过矩阵,我们可以把任意方向的渐变统一为"从左到右"的计算:

渐变类型 变换矩阵的作用 最终 t 的计算
水平(左→右) 归一化 x: x/width t = gradPos.x
垂直(上→下) 旋转 90° + 归一化 t = gradPos.x(原来的 y 变成了 x)
45° 斜向 旋转 45° + 归一化 t = gradPos.x
镜像渐变 缩放 x 为 2 倍 + 偏移 t = abs(gradPos.x) 或矩阵实现

JavaScript 类比理解

javascript 复制代码
// 不用矩阵:每种渐变写一套逻辑
function getGradientT_Horizontal(x, y, w, h) { return x / w; }
function getGradientT_Vertical(x, y, w, h) { return y / h; }
function getGradientT_Diagonal(x, y, w, h) { 
    // 45° 对角线...复杂的三角函数计算
}

// 用矩阵:统一为一套逻辑
function getGradientT(x, y, matrix) {
    const [gx, gy] = applyMatrix(matrix, [x, y]);
    return gx;  // 所有渐变都取变换后的 x
}

镜像渐变怎么做?

镜像渐变(如 红→蓝→红)可以通过两种方式实现:

方法 1:修改 Shader 逻辑

ini 复制代码
// 将 t 从 [0, 1] 映射为 [0, 1, 0](三角波)
let t_mirror = 1.0 - abs(gradPos.x * 2.0 - 1.0);

方法 2:通过矩阵实现(更灵活)

css 复制代码
// CPU 侧:构造一个"折叠"矩阵
// 将 [0, 0.5] 映射到 [0, 1],[0.5, 1] 映射到 [1, 0]

这样设计的优势

  1. Shader 代码简洁 ------ 无论什么方向的渐变,Shader 里永远是 t = gradPos.x
  2. 设计工具友好 ------ Figma 导出的 gradientTransform 可以直接使用
  3. 可组合 ------ 旋转、缩放、镜像可以通过矩阵乘法任意组合

4.3 颜色插值

ini 复制代码
// 线性渐变取 x,径向渐变取距离
var t: f32 = 0.0;
if (uniforms.paintType == 1u) {
    t = gradPos.x;                // 线性:只看水平位置
} else if (uniforms.paintType == 2u) {
    t = length(gradPos);          // 径向:计算到中心的距离
}

为什么线性渐变取 x,不取 y?

因为我们已经通过 gradientTransform 把任意方向的渐变都"旋转"成了水平方向

渐变方向 矩阵变换后的效果 t 的计算
从左到右 x: 0→1, y: 不变 t = x
从上到下 原来的 y 变成新的 x t = x(实际是原来的 y)
45° 对角 对角线方向变成新的 x t = x(实际是对角距离)

如果同时用 x 和 y 会怎样?

ini 复制代码
// 实验:不同的 t 计算方式
t = gradPos.x;                    // 水平渐变
t = gradPos.y;                    // 垂直渐变
t = (gradPos.x + gradPos.y) / 2.0; // 45° 对角渐变(简化版)
t = length(gradPos);              // 径向渐变(圆形)
t = max(abs(gradPos.x), abs(gradPos.y)); // "方形"径向渐变
t = gradPos.x * gradPos.y;        // 双曲线渐变(艺术效果)

JavaScript 可视化理解

ini 复制代码
// 想象一个 10×10 的网格,计算每个格子的 t 值
for (let y = 0; y < 10; y++) {
    let row = '';
    for (let x = 0; x < 10; x++) {
        const t_horizontal = x / 9;                    // 0, 0.11, 0.22, ... 1
        const t_radial = Math.sqrt(x*x + y*y) / 12.7;  // 圆形扩散
        row += t_horizontal.toFixed(1) + ' ';
    }
    console.log(row);
}
ini 复制代码
// 在 stops 数组中找到 t 所在的区间,进行线性插值
for (var i: u32 = 0u; i < 7u; i = i + 1u) {
    if (i >= lastIdx) { break; }
    
    let s0 = uniforms.stops[i];
    let s1 = uniforms.stops[i+1];
    
    if (t >= s0.position && t <= s1.position) {
        let factor = (t - s0.position) / (s1.position - s0.position);
        return mix(s0.color, s1.color, factor);  // GPU 内置的线性插值
    }
}

JavaScript 等价代码

ini 复制代码
function interpolate(t, stops) {
    for (let i = 0; i < stops.length - 1; i++) {
        if (t >= stops[i].position && t <= stops[i+1].position) {
            const factor = (t - stops[i].position) / (stops[i+1].position - stops[i].position);
            return lerpColor(stops[i].color, stops[i+1].color, factor);
        }
    }
}

GPU 中的控制语句

WGSL 支持常见的控制语句,但性能特性与 JavaScript 完全不同

JavaScript WGSL 说明
if (cond) { A } else { B } if (cond) { A } else { B } 语法相同,但性能代价大
for (let i = 0; i < n; i++) for (var i: u32 = 0u; i < n; i = i + 1u) 需要显式类型
while (cond) { } while (cond) { } 相同
switch (x) { case 1: ... } switch (x) { case 1: { ... } default: { } } 每个分支必须有 {}
break / continue break / continue 相同
return return 相同

⚠️ 为什么 if 在 GPU 中代价大?

GPU 的并行模型要求同一组线程(Warp/Wave)执行相同的指令。当遇到分支时:

scss 复制代码
if (condition) {
    A();  // 部分线程执行这里
} else {
    B();  // 部分线程执行这里
}

实际发生的是:所有线程都执行 A 和 B,但结果被掩码丢弃 。这叫做 Thread Divergence(线程分化)

性能优化替代方案

scss 复制代码
// ❌ 分支写法(两组线程各等待对方)
if (isHovered) { 
    color = hoverColor; 
} else { 
    color = normalColor; 
}

// ✅ 无分支写法(所有线程同时完成)
color = mix(normalColor, hoverColor, f32(isHovered));

// ✅ step 函数(阶跃函数,常用于边界判断)
// step(edge, x): x < edge 返回 0.0,否则返回 1.0
let mask = step(0.5, t);  // t < 0.5 时 mask=0,否则 mask=1
color = mix(colorA, colorB, mask);

// ✅ clamp + smoothstep(平滑过渡)
let t_clamped = clamp(t, 0.0, 1.0);  // 限制 t 在 [0, 1] 范围
let t_smooth = smoothstep(0.0, 1.0, t);  // 平滑的 S 曲线插值

何时可以用 if

  • 条件对所有像素相同(如 if (uniforms.paintType == 1u))------ 无分化,放心用
  • 分支内代码很短 ------ 分化代价可接受
  • 无法用数学替代的复杂逻辑 ------ 只能用 if

5. GPU 编程的"反直觉"特性

5.1 没有 console.log

在 Shader 中,你不能打印日志。这是最让 CPU 程序员抓狂的地方。

调试核心思路 :将关键变量的值分支执行情况 编码成可识别的颜色输出到屏幕上。

调试技巧:用颜色编码变量值

less 复制代码
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
    // ===== 技巧 1:输出坐标值 =====
    // 将坐标映射为颜色,检查坐标是否正确
    // return vec4<f32>(input.localPos.x / 200.0, input.localPos.y / 200.0, 0.0, 1.0);
    // 预期:左上角黑色,右下角黄色,形成渐变
    
    // ===== 技巧 2:检查分支执行 =====
    // 用不同颜色标记代码是否进入了某个分支
    // if (uniforms.paintType == 0u) { return vec4<f32>(1.0, 0.0, 0.0, 1.0); }  // 纯色 → 红
    // if (uniforms.paintType == 1u) { return vec4<f32>(0.0, 1.0, 0.0, 1.0); }  // 线性 → 绿
    // if (uniforms.paintType == 2u) { return vec4<f32>(0.0, 0.0, 1.0, 1.0); }  // 径向 → 蓝
    
    // ===== 技巧 3:检查变量范围 =====
    // 将 t 值输出为灰度,检查是否在 [0, 1] 范围内
    // let t = gradPos.x;
    // return vec4<f32>(t, t, t, 1.0);  // 预期:从黑到白的渐变
    // 如果全白/全黑 → t 超出范围,坐标变换有问题
    
    // ===== 技巧 4:二分法定位问题 =====
    // 在代码中间插入 return,逐步缩小问题范围
    // return vec4<f32>(1.0, 0.0, 0.0, 1.0);  // 红色检查点
    // ... 后续代码
    
    // 正常逻辑...
}
输出颜色 含义
纯红色 几何体正确绘制,或进入了"纯色"分支
纯绿色 进入了"线性渐变"分支
纯蓝色 进入了"径向渐变"分支
从黑到白渐变 t 值从 0 到 1 正常变化
全白 t 值始终 ≥ 1,坐标变换可能缩放错误
全黑 t 值始终 ≤ 0,坐标变换可能偏移错误
紫色(红+蓝) 进入了未知分支或错误路径

5.2 数据传输:理解"批量"与"Draw Call"

在 JavaScript 中,你可以随时修改变量:

ini 复制代码
color = 'red';
draw();
color = 'blue';
draw();

在 GPU 编程中,这涉及两个层面的理解:

层面 1:单次 Draw Call 内的数据共享

一次 draw() 调用会绘制一个(或一批)图形。在这次绘制中,所有像素共享同一份 Uniform 数据

如果你想画两个不同颜色的矩形,最简单的方式是:

scss 复制代码
// 方式 1:两次 Draw Call(伪代码)
setUniform({ color: 'red' });   // 内部调用 device.queue.writeBuffer()
draw(rect1Vertices);            // 第一次绘制

setUniform({ color: 'blue' });  // 再次调用 device.queue.writeBuffer()
draw(rect2Vertices);            // 第二次绘制

💡 setUniform 的本质

setUniform 不是 WebGPU 的原生 API,而是对以下操作的封装:

arduino 复制代码
function setUniform(data) {
    // 1. 将 JS 对象转换为二进制数据(按 std140 对齐)
    const buffer = packToFloat32Array(data);
    
    // 2. 通过 WebGPU API 将数据从 CPU 内存拷贝到 GPU 内存
    device.queue.writeBuffer(uniformBuffer, 0, buffer);
}

device.queue.writeBuffer() 是真正触发 CPU→GPU 数据传输的 API。每次调用都会产生一定的开销(内存拷贝 + 驱动调用),这就是为什么要尽量减少调用次数。

层面 2:批量绘制优化

多个图形当然可以批量发送! 这正是性能优化的关键。常见方案:

方案 A:Dynamic Uniform Buffer(动态偏移)

ini 复制代码
// 将所有图形的数据打包到一个大 Buffer
const bigBuffer = new Float32Array([
    ...rect1Transform, ...rect1Color,  // 偏移 0
    ...rect2Transform, ...rect2Color,  // 偏移 384
    ...rect3Transform, ...rect3Color,  // 偏移 768
]);
device.queue.writeBuffer(uniformBuffer, 0, bigBuffer);

// 一次性发送,通过偏移切换数据
for (let i = 0; i < 3; i++) {
    passEncoder.setBindGroup(0, bindGroup, [i * 384]);  // 动态偏移
    passEncoder.draw(...);
}

方案 B:实例化渲染(Instancing)------ 终极批量

javascript 复制代码
// 将变换矩阵放入 Storage Buffer
const transforms = new Float32Array([...所有图形的矩阵]);

// 一次 Draw Call 绘制 1000 个图形!
passEncoder.draw(vertexCount, instanceCount: 1000);
less 复制代码
// Shader 中通过 instance_index 获取自己的数据
@vertex
fn vs_main(@builtin(instance_index) instanceIdx: u32, ...) {
    let myTransform = transforms[instanceIdx];
}

性能对比

方式 1000 个矩形的 Draw Call 数 适用场景
朴素方式 1000 原型开发
Dynamic Uniform 1000(但切换更快) 不同形状、不同材质
Instancing 1 大量相同形状

5.3 矩阵是"列主序"

JavaScript 思维(行主序):

csharp 复制代码
const matrix = [
    [a, b, c],  // 第一行
    [d, e, f],  // 第二行
    [g, h, i],  // 第三行
];

GPU/WebGPU 思维(列主序):

arduino 复制代码
const buffer = new Float32Array([
    a, d, g,  // 第一列
    b, e, h,  // 第二列
    c, f, i,  // 第三列
]);

🔥 这是最常见的坑:如果你的图形位置完全错误或消失,80% 是矩阵存储顺序的问题。


6. 快速参考:类型对照表

JavaScript WGSL 大小(字节) 对齐要求
number f32 4 4
number (整数) u32 / i32 4 4
[x, y] vec2<f32> 8 8
[x, y, z] vec3<f32> 12 16 ⚠️
[r, g, b, a] vec4<f32> 16 16
3x3 矩阵 mat3x3<f32> 36 48 ⚠️
4x4 矩阵 mat4x4<f32> 64 64

7. 总结:心智模型迁移清单

CPU 思维 GPU 思维
循环遍历所有像素 每个像素独立运行相同代码
console.log 调试 用颜色输出变量值
随意访问全局变量 数据打包成 Buffer 发送
结构体大小 = 字段大小之和 必须考虑对齐(16 字节边界)
行主序矩阵 列主序矩阵
if/else 分支随意写 分支会降低性能(所有线程等待)

掌握这些核心差异,你就能从 JavaScript 程序员平滑过渡到 Shader 开发者。接下来,建议你动手修改 shader.wgsl 中的代码,用"颜色调试法"亲身体验 GPU 编程的独特魅力!

更多精彩内容可关注风起的博客,微信公众号:听风说图

相关推荐
醉城夜风~13 分钟前
CSS元素显示模式(display)
前端·css
AI大模型-小华1 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子3 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅3 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni4 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学5 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
赵庆明老师5 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
wc885 小时前
微软EDGE浏览器功能学习
前端·学习·edge
Csvn6 小时前
🎯 原生 `<dialog>` 元素:终于可以扔掉一半的自定义弹窗组件了?
前端