编程 Wasmtime 47 默认启用 WASM GC 与异常:高阶语言进军 WebAssembly 的最后一道运行时枷锁被打破

2026-07-23 02:15:08 +0800 CST views 7

Wasmtime 47 默认启用 WASM GC 与异常:高阶语言进军 WebAssembly 的最后一道运行时枷锁被打破

2026 年 7 月 22 日,Bytecode Alliance 放出信号很强的版本:Wasmtime 47 把 WASM GC异常处理(exceptions) 从“实验开关”变成了默认开启的能力。对 WebAssembly 生态来说,这不是单纯多支持两个提案,而是意味着 Java、Kotlin、Go、C#、Swift 这类自带对象模型和异常机制的高阶语言,终于可以不再往 .wasm 包里塞一整套沉重的垃圾回收器,就能自然地把代码跑进 WASM。本文从“为什么需要 WASM GC”讲起,拆解提案原理、Wasmtime 在线性内存上长出的 Cheney 复制收集器、再到 Rust 宿主实战与性能权衡,聊聊这场运行时革命的真正分量。


一、背景:WASM 的“托管语言困境”

WebAssembly 最早是为 C / C++ / Rust 这类“线性内存 + 手动/确定性内存管理”的语言设计的。它的抽象非常朴素:

  • 一块连续的线性内存(memory),字节寻址;
  • 栈式虚拟机指令(i32.addf64.mullocal.get……);
  • 没有对象、没有引用、没有类型化的堆,更没有“垃圾回收”这个概念。

这对 Rust 来说几乎是天作之合:Rust 的所有权模型在编译期就解决了内存安全,运行时不需要 GC;编译到 WASM 后,线性内存就是它唯一需要关心的堆。所以你看,wasm-pack、wasmedge、Spin、 Fermyon 这些生态跑得飞起,本质上是因为 Rust 和 WASM 的内存哲学是同构的

但问题来了:世界上绝大多数业务代码并不在 Rust 里。它们散落在 Java、Kotlin、Go、C#、Swift、Dart 这些语言里——而这些语言有一个共同特征:它们有对象、有引用、有子类型、有异常,而且依赖一个垃圾回收器来管理对象生命周期

当这些语言想进入 WASM 时,WASM 的“朴素”就成了障碍。在 WASM GC 提案出现之前,社区的解法只有一种:把 GC 自己编译进 wasm

  • Kotlin/Wasm 早期版本、AssemblyScript(自带一个紧凑的基于 mark-sweep 的 GC)、Go 的 wasm 后端、Unity 的 C# 裁剪运行时……无一不是把整套内存管理逻辑搬进 .wasm
  • 代价极其明显:二进制体积暴涨(GC 本身要占几十到上百 KB,还不算元数据)、正常路径(happy path)出现额外开销(每次分配都要走语言自带分配器,而不是 WASM 的线性内存)、以及最致命的——每种语言都得自己造一遍 GC 轮子,工程复杂度直接劝退中小团队。

换句话说,在 WASM GC 之前,高阶语言进 WASM 的本质是“自带运行时包袱移民”。你不是在用 WASM 的运行时,而是把自家运行时整个打包空运过去。这与 WASM “小巧、安全、可移植、秒级启动”的卖点背道而驰。

WASM GC 提案要解决的,正是这件事:让 WASM 运行时本身提供对象类型和垃圾回收,语言只负责描述“我的对象长什么样”,生命周期交给 WASM 虚拟机。


二、核心概念:WASM GC 提案到底加了什么

WASM GC 不是给 WASM 塞一个具体的垃圾回收器实现,而是给 WASM 的类型系统加了一组“结构化引用类型”,并规定运行时必须能管理这些类型的生命周期。语言编译器和运行时在此基础上各显神通。

2.1 两类 GC 对象类型

提案在 valuetype 里新增了:

  • structtype:类似语言里的结构体 / 对象。字段可以是任意 WASM 值类型(含其他 GC 引用),例如:

    (type $point (struct
      (field $x f64)
      (field $y f64)))
    
  • arraytype:定长或运行时长度可变的数组,元素同样可以是任何值类型:

    (type $vec (array (mut f64)))
    

此外还有三个关键的基础引用类型:

  • i31ref:一个能装下 31 位有符号整数的、不经过 GC 堆的轻量引用(高 1 位用于 tag,避免和指针混淆)。它让你能在“需要引用类型但只想传个小整数”的场景里省掉一次堆分配;
  • eqref:所有“可比较(能 ref.eq)”的引用类型的父类型,包括 struct、array、i31ref 等;
  • structref / arrayref:分别是所有 struct / array 类型的父类型,用于在不知道具体子类型时持有引用。

2.2 子类型与“深度子类型”

这是 WASM GC 真正优雅的地方。GC 类型支持子类型(subtyping),而且是基于结构的(structural),不是名义的(nominal)。

(struct (field f64 f64))          ; 父类型:两个 f64
  <:
(struct (field f64 f64) (field i32))  ; 子类型:多一个 i32 字段,且前两个字段类型一致

子类型关系遵循“声明位置不变、字段只增不减、协变/逆变规则一致”的原则。这为语言的对象继承、接口实现提供了底层支撑——比如 Kotlin 的某个子类实例,在 WASM 层可以直接当作父类 eqref 传给一个只认父类的函数。

WASM 还区分了深度子类型(depth subtyping)声明子类型(decl subtyping),让数组、可变字段在协变/逆变上能做到既灵活又安全。具体规则属于规范细节,工程上你只需要知道:WASM GC 的类型系统足以表达主流 OO 语言的继承与泛型约束,不需要语言在 WASM 之上再用 i32 索引手写一套“虚表 + 偏移”的仿真层。

2.3 操作 GC 对象的指令

提案给了一组读写 GC 对象的标准指令,我列最常用的几个:

指令作用
struct.new $t / struct.new_default $t在 GC 堆上分配并初始化一个 struct
struct.get $t $field / struct.set $t $field读 / 写 struct 字段
array.new $t / array.new_fixed $t / array.new_data $t分配数组
array.get $t / array.set $t读 / 写数组元素
ref.cast $t / ref.test $t把引用向下转型(失败抛陷阱)/ 仅测试是否能转型
br_on_cast $t若引用能转型为目标类型则跳转(常用于类型分发)
ref.eq比较两个引用是否指向同一对象(类似 == 而非 .equals()
ref.as_non_null把可空引用断言为非空

注意一个关键差异:ref.eq 比较的是引用同一性(identity),不是值相等。这正好对应 Java 的 ==、Kotlin 的 ===。值相等的 equals 由语言自己实现,WASM 不介入——这把“对象相等性语义”的所有权还给了语言,运行时只管内存,非常克制。

2.4 一个最小可运行的 WASM GC 模块(WAT)

下面这段 WAT 定义了一个 point 对象,并提供“构造”和“求到原点距离”两个导出函数。它完全用 WASM GC 的类型与指令写成,不依赖任何语言自带 GC:

(module
  ;; 定义一个 GC 对象类型:二维点
  (type $point (struct
    (field $x f64)
    (field $y f64)))

  ;; make(x, y) -> (ref $point)
  (func $make (param $x f64) (param $y f64) (result (ref $point))
    (struct.new $point
      (local.get $x)
      (local.get $y)))

  ;; dist(p) -> sqrt(x^2 + y^2)
  (func $dist (param $p (ref $point)) (result f64)
    (f64.sqrt
      (f64.add
        (f64.mul
          (struct.get $point $x (local.get $p))
          (struct.get $point $x (local.get $p)))
        (f64.mul
          (struct.get $point $y (local.get $p))
          (struct.get $point $y (local.get $p))))))

  (export "make" (func $make))
  (export "dist" (func $dist)))

wasm-tools 或任意支持 GC 的 toolchain 把它汇编成 .wasm,在 Wasmtime 47 里就能直接跑——而 .wasm没有一行 GC 实现代码,回收 point 的全部职责都在 Wasmtime 运行时里。


三、异常提案:把 try/catch 交还给运行时

高阶语言除了对象,还有第二大特征:异常。而在 WASM GC 之前,WASM 连“异常”都没有一等公民地位。

3.1 旧世界的三种 workaround

  1. 返回值携带错误位:每个函数返回 (result i32) 实际是 (result 正常值, 错误码),调用方必须手动 if (err) ...。Go 风格的 (_, err) := f()、早期很多 wasm 绑定都这么干。代价是正常路径被污染——哪怕不抛错,每次调用都要检查错误位;
  2. 靠 JS 异常桥接:在浏览器里把 WASM 的 trap 翻译成 JS 异常。但 trap 不是异常,它是“虚拟机认为程序出错直接中止”,你没法 catch 后继续跑,而且脱离了 JS 宿主就失效;
  3. setjmp/longjmp 仿真:在 wasm 里用表 + 栈帧编号模拟,丑陋且慢。

3.2 异常提案长什么样

异常提案引入了 tag(异常标签,类似异常类型)和一组控制流指令:

  • throw $tag:抛出一个异常;
  • try_table + catch $tag / catch_ref $tag:进入受保护的代码区,命中对应 tag 时跳到 handler;
  • rethrow:在 handler 里重新抛出。

概念上,它就是把 try { ... } catch (E e) { ... } 下沉到了 WASM 指令层。语言编译器不再需要为每个函数多塞一个错误位,运行时直接维护异常控制栈。

一个示意性的 WAT(不同 toolchain 文本语法略有差异,这里表达语义):

(module
  (tag $overflow)            ;; 定义一个异常标签

  (func $checked_add (param $a i32) (param $b i32) (result i32)
    (try_table (catch $overflow)
      ;; body
      (i32.add (local.get $a) (local.get $b))
      ;; 简化示意:实际溢出检测需额外指令
    )
    ;; handler:捕获后返回 -1
    (drop)                   ;; 弹出一个 exnref
    (i32.const -1)))

  (export "checked_add" (func $checked_add))

要点是:异常成为 WASM 的一等控制流原语后,Kotlin 的 try/catch、Java 的 try-catch、C# 的 try/catch 可以 1:1 映射到 WASM,不再有“错误位税”。正常路径干干净净,只有真正抛错才付出代价——这正是零成本抽象(zero-cost abstraction)该有的样子。

Wasmtime 47 把异常和 GC 一起默认开启,意味着这两条能力现在都是“生产可用”状态,而不是“你敢用我就敢编译”的预览特性。


四、架构分析:Wasmtime 47 如何在线性内存上“长”出一个 GC

这是全文最硬核、也最体现工程功底的一节。Wasmtime 团队做了一件很聪明的事:没有把 GC 做成一套脱离现有引擎的特例,而是把 GC 堆建在 WebAssembly 的线性内存之上,复用 Wasmtime 已有的安全、可移植与虚拟内存护栏能力。

4.1 为什么不另起炉灶

WASM 运行时本身已经有了一块受控的线性内存、一套边界检查、以及(在 Wasmtime 里)基于虚拟内存保护的“guard pages”机制。如果 GC 堆另起一套独立内存,就要重新解决隔离、越界、可移植、与宿主交互等一系列问题。

Wasmtime 的选择是:GC 对象直接分配在线性内存的某个区域里,由运行时统一管理这块区域的对象布局、分配与回收。这样一来:

  • GC 堆和其他 WASM 内存共享同一套地址空间与保护机制;
  • 不需要为 GC 单独开辟系统内存,移植到新平台时自动继承 Wasmtime 的移植性;
  • 对象的引用就是线性内存里的地址(带 GC 元数据),和 WASM 的指针模型天然契合。

4.2 Cheney 半空间复制收集器

Wasmtime 的 GC 实现采用的是经典的 Cheney 半空间复制(semispace copying)算法。理解它,先理解“半空间”:

  • 把 GC 堆分成大小相等的两块:From 空间To 空间
  • 分配时只在 From 空间里用一根“分配指针(bump pointer)”顺序推进——ptr += size 就行,分配是 O(1) 且无需找空闲块,这是复制收集器最大的性能甜点;
  • 当 From 空间满,触发 GC:从一组“根(roots,比如 WASM 栈上的引用、全局变量里的引用)”出发,把存活对象复制到 To 空间,并在原对象上留一个“转发指针(forwarding pointer)”指向新位置;
  • 复制过程中遇到对象里引用的其他对象,递归/迭代地复制,直到所有可达对象都搬完;
  • 最后交换 From / To 的角色,整块旧空间一次性回收——没有碎片整理这一步,因为复制本身就是整理

Cheney 算法的精妙在于它用一个简单的“扫描指针 + 待复制队列”就完成了广度优先式的复制,不需要递归爆栈,也不需要复杂的空闲链表。对 WASM 这种“对象小、存活率低、分配极频繁”的场景,复制收集器的吞吐优势非常明显。

4.3 写屏障、根枚举与栈协作

复制收集器的正确性依赖三件事,Wasmtime 都做了:

  1. 根枚举(root scanning):GC 必须知道 WASM 栈上、全局变量里哪些槽位是 GC 引用。Wasmtime 在编译期就为每个函数的栈帧布局标注了“哪些位置是 GC ref”,GC 触发时按图索骥;
  2. 写屏障(write barrier):因为复制发生在 GC 时,而 mutator(运行的 WASM 代码)在两次 GC 之间会修改对象字段。对于“分代/增量”变体需要写屏障来记录跨代指针;Wasmtime 当前实现以单次全堆复制为主,但架构上预留了增量与并发的演进空间;
  3. 与引擎栈的协作:GC 不能在不一致的状态下运行。Wasmtime 会在安全的“安全点(safepoint)”暂停 WASM 执行、完成根枚举,再开始复制。因为 WASM 指令本身是确定的、可中断的,做安全点比在原生线程里插桩要干净得多。

4.4 为什么“默认开启”比“支持”重要得多

提案支持(feature flag on)和默认开启(default on)是两回事:

  • 支持阶段,编译器、工具链、宿主之间互相观望,谁都不敢第一个吃螃蟹,生态是碎片化的;
  • 默认开启意味着:任何用 Wasmtime 47 运行的 WASM,只要包含 GC 类型,无需任何开关即可获得 GC 能力。这等于给所有下游语言编译器发了“可以放心生成 GC 代码”的信号。

rustcc 的日报评价得很到位:这是“多年工程收口后的里程碑,而不是实验性开关”。配合 Wasmtime 针对 WASM GC 扩展过的 fuzzing 基建(对 GC 类型、子类型转换、转发指针、边界做海量模糊测试),默认开启才敢说是“生产可用”。对做运行时、编译器、Wasm 平台的人来说,这条线的长期影响比单次版本号大得多。


五、代码实战:用 Rust 宿主运行一个 WASM GC 模块

光讲原理不够,我们把它跑起来。下面所有代码在 Wasmtime 47 + Rust 环境下可运行。

5.1 准备环境

# 安装 wasmtime(二进制,或作为 Rust 依赖)
curl https://wasmtime.dev/install.sh -sSf | bash

# 如果用 Rust 宿主调用,添加依赖
cargo add wasmtime@47
cargo add anyhow

把第二章的 WAT 保存为 gc_point.wat,用 wasm-tools 汇编:

# 安装 wasm-tools
cargo install wasm-tools
wasm-tools parse gc_point.wat -o gc_point.wasm

5.2 Rust 宿主:加载并调用 GC 模块

这里的关键认知:GC 是模块内部的事,宿主调用的导出函数完全可以只用标量(f64/i32)通信。Wasmtime 47 默认开启 GC,我们显式声明 config.wasm_gc(true) 纯粹是为了表意清晰。

use wasmtime::*;

fn main() -> anyhow::Result<()> {
    // 1) 配置引擎:Wasmtime 47 起 GC 默认开启,这里显式声明以表意
    let mut config = Config::new();
    config.wasm_gc(true);
    let engine = Engine::new(&config)?;

    // 2) 编译模块(GC 类型会被校验并纳入引擎的 GC 路径)
    let module = Module::from_file(&engine, "gc_point.wasm")?;

    // 3) 创建 store 与实例
    let mut store = Store::new(&engine, ());
    let instance = Instance::new(&mut store, &module, &[])?;

    // 4) 取导出函数。注意:GC 对象在宿主侧通常不必直接跨越边界,
    //    我们用“构造 + 计算”两个标量接口组合调用。
    let make = instance.get_typed_func::<(f64, f64), u32>(&mut store, "make")?;
    let dist = instance.get_typed_func::<u32, f64>(&mut store, "dist")?;

    // 5) 调用:在 WASM 内部,point 是分配在 GC 堆上的对象;
    //    这里为了示例简单,把引用以 u32 handle 形式传回(真实场景可用 StructRef/externref)。
    let handle = make.call(&mut store, (3.0, 4.0))?;
    let d = dist.call(&mut store, handle)?;

    println!("distance(3,4) = {}", d); // 输出 5.0
    Ok(())
}

工程提示:如果你确实需要把 GC 引用作为一等值跨过宿主边界,Wasmtime 提供了 wasmtime::gc::StructRef / AnyGcRef 以及 ExternRef 等类型来桥接。多数业务场景下,用 WIT 组件模型定义清晰的接口(见 5.4)比直接搬运原始引用更安全、更易演进。

5.3 验证“没有自带 GC”这件事

我们可以反过来证明 GC 不在 .wasm 里:

# 看导出的函数与数据段大小
wasm-tools print gc_point.wasm | head -40

# 对比:同一个 point 逻辑,如果用“自带 mark-sweep GC”的语言编译,
# 你会看到大量 GC 相关的函数、元数据段和额外的内存常驻结构。

gc_point.wasm 里只有我们写的 make / dist 逻辑,没有任何 gc_alloc / sweep / mark 之类的符号——因为那些活儿 Wasmtime 运行时替我们干了。二进制体积的差距,在复杂业务里会被放大成几十到上百 KB 的鸿沟。

5.4 进阶:用组件模型定义 GC 友好的接口

WASM GC 通常和 组件模型(Component Model)+ WIT 搭配使用,让多语言模块有统一、类型安全的接口。定义一个几何组件:

// geo.wit
package my:geo@0.1.0;

interface geo {
  record point {
    x: float64,
    y: float64,
  }
  distance: func(p: point) -> float64;
}

world app {
  export geo;
}

然后可以用 Kotlin/Wasm(其 wasm 目标已基于 WASM GC)、Go 的 wasm 后端、或手写 GC 模块来实现 geo,再用 wasm-tools component new 封装成组件,最后在 Rust 宿主里用 wasmtime::component::Linker 调用。组件模型负责把 WIT 的 record point 在底层映射成 WASM GC 的 structtype,你完全不需要手写字段偏移和序列化——这正是“一份数据、多语言视图”的工程落地,和 RobustMQ 那种“一份数据、多协议视图”的设计哲学异曲同工。


六、性能与工程权衡:到底赢了什么,又付出了什么

Wasmtime 47 的日报把收益归纳为三点:二进制体积、正常路径运行开销、语言接入工程复杂度。我们逐一拆开看。

6.1 二进制体积:从“自带 GC”到“零 GC”

方案wasm 内 GC 代码典型体积增量
语言自带 mark-sweep / bump GC有,且随功能膨胀数十~数百 KB
WASM GC(Wasmtime 管理)≈ 0

对边缘函数、插件、浏览器内小模块这类“体积敏感”场景,这往往意味着 首屏/冷启动从「先下载并初始化 GC」变成「直接运行」

6.2 正常路径开销:分配 O(1) vs 自带分配器

自带 GC 的语言,每次 new 都要走自己的分配器(空闲链表查找、锁、分代判断……)。而 WASM GC 的复制收集器是 bump pointer 顺序分配,一次 struct.new 约等于“指针加 size”。在对象分配密集的业务(序列化、DOM 树、AST、游戏实体)里,这个差异会被放大成肉眼可见的吞吐差距。

当然,复制收集器不是免费的午餐:它的代价在 GC 暂停(stop-the-world 把存活对象搬一遍)。但因为对象是“小且短命”的,且半空间复制天然没有碎片,实际暂停时间通常很短、且可预测——对 p99 延迟敏感的场景(实时通信、流媒体、游戏)反而友好。

6.3 语言接入复杂度:从“造轮子”到“描述类型”

最被低估的收益是工程复杂度。以前一门语言要支持 WASM,得先实现或移植一个 GC——这是以“人月”计的工作量。WASM GC 之后,语言编译器只需要:

  1. 把对象翻译成 structtype / arraytype
  2. 把字段访问翻译成 struct.get/set
  3. 把子类型翻译成 WASM 的子类型声明;
  4. 剩下的回收、安全、移植,全部交给 Wasmtime。

这就是为什么你会看到 Kotlin/Wasm、Java 的 wasm 探索、Go、C#、Swift 在 2024–2026 年间密集拥抱 WASM GC——不是因为它们突然爱上了 WASM,而是 WASM GC 把“进 WASM”的成本从“重写运行时”降到了“改代码生成后端”

6.4 什么时候不该用

诚实地说,WASM GC 不是银弹:

  • 如果你的代码本来就是 Rust/C,用线性内存 + 所有权就够了,引入 GC 类型反而多一层抽象
  • 如果你的对象极大、存活极久(大数组、长生命周期缓存),复制收集器的“全堆搬运”会有搬移成本,需要评估;
  • 如果宿主不是 Wasmtime / 不支持 GC 提案(旧版运行时、部分浏览器版本滞后),模块跑不起来——所以“默认开启”的扩散对生态很关键。

七、更大的图景:WASM GC 解锁了什么

把视野拉高,Wasmtime 47 默认启用 GC,真正解锁的是三类过去很难做好的场景。

7.1 多语言 WASM 成为现实

Kotlin 写业务逻辑、Rust 写高性能内核、Go 写并发编排、C# 复用存量代码——它们现在可以编译成同一个 WASM 运行时里的不同模块,通过组件模型互操作,而不必各自扛一套运行时。这对“渐进式把单体的一部分换语言”是质变。

7.2 安全的插件化与沙箱

这是笔者认为最值得关注的落地方向。想想这些需求:

  • 数据库 UDF / 触发器:用户上传一段处理逻辑,数据库在沙箱里跑,崩了不影响主进程,还带 GC 自动回收临时对象;
  • AI Agent 执行不可信代码:MCP 这类协议让 Agent 调用工具,如果工具是一段 WASM GC 模块,运行时能限制它的内存、CPU、系统调用,比直接 exec 安全一个数量级;
  • 边缘函数 / 多租户 SaaS:每个租户的自定义脚本跑在独立 WASM 实例里,计费按指令数,回收自动完成。

WASM 的“默认拒绝系统调用 + 显式能力授权(WASI)”模型,叠加 GC 之后,终于能承载带对象模型的高阶语言写的插件,而不再只能是 C/Rust 的“裸逻辑”。

7.3 与 WASI、组件模型的协同

WASM GC 不是孤岛。它的价值在和 WASI(系统接口)、组件模型(接口与组合)、Wasmtime 的虚拟化护栏三者叠加时最大化:GC 管内存生命周期,WASI 管能力边界,组件模型管接口契约。这正是 Bytecode Alliance 一直在推的“可组合、可移植、安全的计算单元”愿景的最后一公里。


八、总结与展望

回到开头的判断:Wasmtime 47 默认启用 WASM GC 与异常,表面是“多支持两个提案”,实质是 WebAssembly 从「系统语言运行时」进化为「通用语言运行时」的分水岭

  • 语言设计者:进 WASM 的门槛从“重写 GC”降到“改后端”;
  • 应用开发者:高阶语言终于能轻装进 WASM,二进制更小、启动更快、异常更自然;
  • 平台构建者:安全的多语言插件、沙箱、边缘计算有了统一的底座。

当然,路还没走完。GC 的并发/增量变体、与 WASI 线程的协作、各浏览器对 GC/异常提案的跟进节奏、以及工具链(wasm-tools、各语言编译器)的成熟度,都还需要时间收敛。但“默认开启”这四个字,意味着这件事已经过了“要不要做”的争论,进入了“怎么做更好”的工程迭代阶段。

如果你正在做插件系统、数据库扩展、AI Agent 工具执行,或者单纯想把一部分 Java/Kotlin/Go 业务搬进边缘环境——现在,是认真评估 WASM GC 的好时机。


参考资料

  • Bytecode Alliance — Wasmtime 47: WASM GC and Exceptions now enabled by default(https://bytecodealliance.org/articles/wasmtime-gc)
  • WASM GC 提案规范(structtype / arraytype / 子类型 / 指令)
  • WASM 异常提案规范(tag / try_table / catch / throw)
  • Rust 中文社区日报 2026-07-22(Wasmtime 47、syn 3.0.0、append-only 数据库 mmap 长文)
  • wasm-tools 工具链文档(parse / component new / print)

本文所有代码与原理均可在 Wasmtime 47 + 对应 toolchain 环境验证;异常提案的 WAT 文本语法因工具链版本可能略有差异,语义一致。

复制全文 生成海报 Wasmtime WASM GC WebAssembly Rust 字节码联盟

推荐文章

Vue 3 路由守卫详解与实战
2024-11-17 04:39:17 +0800 CST
如何优化网页的 SEO 架构
2024-11-18 14:32:08 +0800 CST
Vue 3 中的 Watch 实现及最佳实践
2024-11-18 22:18:40 +0800 CST
服务器购买推荐
2024-11-18 23:48:02 +0800 CST
程序员茄子在线接单