编程 wasm-service 深度拆解:当 Rust + Service Worker 把服务器「塞进」浏览器——一种无后端全栈开发的激进实验

2026-07-29 09:16:39 +0800 CST views 8

wasm-service 深度拆解:当 Rust + Service Worker 把服务器「塞进」浏览器——一种无后端全栈开发的激进实验

一、引言:重新思考 Web 应用的边界

2026年的前端生态,正在经历一场静悄悄的范式转移。

React 的虚拟 DOM 已经足够快,Next.js 的 SSR 已经足够强大,HTMX 已经证明了「少即是多」的交互哲学可行。但所有这些方案都有一个共同的前提:浏览器之外,需要一台服务器。

如果把这个前提去掉呢?

wasm-service 给出了一个令人不安的回答:浏览器本身就可以是服务器——通过 Service Worker 拦截所有 HTTP 请求,通过 WebAssembly 执行 Rust 编写的业务逻辑,通过 HTMX 处理页面局部更新,整个应用可以在没有任何后端服务的情况下完整运行。

这不是天方夜谭,而是一个刚刚 18 次 commit 的真实开源项目(github.com/richardanaya/wasm-service)。作者 Richard Anaya 用大约 500 行 Rust 代码和 200 行 JavaScript,完整实现了一个包含路由、状态管理、页面渲染和交互更新的 Todo 应用——全部运行在浏览器里,没有一行后端代码。

本文将从架构设计、核心机制、代码实现、性能分析和工程哲学五个维度,对这个项目进行深度拆解。我们不仅要看「它怎么做到的」,更要看「它为什么这样设计」,以及「这种模式能走多远」。


二、背景:从 SSR 到「浏览器内 SSR」

2.1 传统 Web 应用的架构假设

过去二十年的 Web 开发,核心架构几乎没有本质变化:

浏览器 ←→ HTTP ←→ 后端服务器 ←→ 数据库
              ↑
           渲染引擎
         (SSR) 或
        (CSR + API)

无论是 PHP/JSP 的服务端渲染,还是 React/Vue 的客户端渲染,业务逻辑和数据处理始终发生在服务器上。浏览器只负责两件事:发起请求、渲染响应。

这个架构带来了几个隐含代价:

  • 网络依赖:没有网络,就没有应用
  • 运维复杂度:需要维护服务器、数据库、CDN、防火墙……
  • 冷启动延迟:服务端渲染需要等待服务器处理
  • 状态同步:客户端状态和服务端状态需要额外同步机制

2.2 HTMX 的渐进式思路

2020 年以后,HTMX 的崛起代表了一种不同的思路:既然 RESTful API + SPA 这么复杂,为什么不直接用 HTML 作为交换格式?

HTMX 的核心理念是「HTML over the wire」——服务器返回 HTML 片段,浏览器直接替换 DOM 元素。这种模式:

  • 消除了前端状态管理的复杂性
  • 让后端开发者可以用熟悉的模板引擎工作
  • 通过 hx-gethx-posthx-swap 等属性,在纯 HTML 中实现异步交互
<!-- HTMX 的简洁哲学:一个属性搞定异步交互 -->
<button hx-post="/todos;add" 
        hx-target=".todos ul" 
        hx-swap="afterbegin">
  Add Todo
</button>

但 HTMX 仍然需要服务器。它只是把「写 JavaScript」换成了「写服务器端模板」,服务器依然是不可或缺的。

2.3 wasm-service 的核心洞察

wasm-service 的作者问了一个简单但深刻的问题:

如果 Service Worker 可以拦截所有网络请求,而 WebAssembly 可以运行任意复杂度的 Rust 代码——那为什么不能让「服务器」运行在浏览器里?

换句话说:HTMX 需要一个后端服务器来返回 HTML 片段。但这个「后端服务器」不一定非要是远程机器——它可以是本地 Service Worker 中的一个 WebAssembly 模块。

这就是 wasm-service 的核心创新:用 Rust + WebAssembly + Service Worker 重构 HTMX 的后端,让整个应用变成完全前端化的。


三、架构解析:三层拦截链

wasm-service 的架构可以分解为三个层次,每一层都有明确的职责和精妙的设计:

┌─────────────────────────────────────────────────────────────┐
│                      浏览器 (Browser)                         │
│  ┌──────────────┐   ┌──────────────┐   ┌──────────────────┐  │
│  │   HTML/CSS   │   │    HTMX      │   │   Service Worker  │  │
│  │  (index.html)│   │ (htmx.org)   │   │     (sw.js)       │  │
│  └──────────────┘   └──────────────┘   └────────┬─────────┘  │
│                                                 │             │
│                                    拦截所有 fetch 请求          │
│                                    转发给 WASM 模块             │
│                                                 ↓             │
│  ┌──────────────────────────────────────────────────────────┐ │
│  │              WebAssembly 模块 (Rust / lib.rs)             │ │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐  │ │
│  │  │  路由层   │  │  渲染层   │  │  状态层   │  │  导出接口  │  │ │
│  │  │ (matchit)│  │(html!宏) │  │ (RwLock) │  │ (cdylib) │  │ │
│  │  └──────────┘  └──────────┘  └──────────┘  └──────────┘  │ │
│  └──────────────────────────────────────────────────────────┘ │
│                      线性内存 (WASM Memory)                    │
└─────────────────────────────────────────────────────────────┘

3.1 第一层:Service Worker 拦截 (sw.js)

Service Worker 是浏览器提供的一个运行在浏览器后台的脚本,独立于网页之外。它最常见的用途是实现离线缓存(PWAs 的核心技术),但它的 fetch 事件监听器可以做更多事情:拦截所有从页面发出的 HTTP 请求。

// sw.js 中的 fetch 事件处理——这是整个架构的入口
self.addEventListener("fetch", (event) => {
  let url = new URL(event.request.url);

  // 判断是否应该拦截请求
  // 条件:不跨域 + 不是 Service Worker 自身 + 不是 WASM 文件本身
  let shouldOverride = 
    url.origin === event.target.location.origin
    && !url.pathname.endsWith("sw.js")
    && !url.pathname.endsWith("app.wasm")
    && WasmAppStatus().status === "resolved";

  if (!shouldOverride) {
    return; // 放过请求,让浏览器正常处理
  }

  // 拦截请求,转发给 WASM 模块处理
  event.respondWith((async () => {
    const app = await WasmApp;
    
    // 将 HTTP 请求序列化为 JSON
    const request = JSON.stringify({
      method: event.request.method,
      url: event.request.url,
      headers: Array.from(event.request.headers),
      body: await event.request.text(),
    });

    // 通过 WASM 线性内存传递请求数据
    const bytes = utf8enc.encode(request);
    const requestPtr = app.exports.allocate_request(bytes.length);
    writeUtf8ToMemory(app, bytes, requestPtr);
    
    // 调用 WASM 导出函数处理请求
    const responseHandle = app.exports.fetch();
    
    // 从 WASM 线性内存读取响应
    const responsePtr = app.exports.response_ptr();
    const responseLen = app.exports.response_len();
    const responseContent = readUtf8FromMemory(app, responsePtr, responseLen);

    return new Response(responseContent, {
      headers: { "Content-Type": "text/html" },
    });
  })());
});

这里有几个关键技术细节值得深入分析:

内存共享机制:WebAssembly 运行在沙箱环境中,JavaScript 不能直接访问 WASM 内部的变量和数据。但 WASM 有一块线性内存(linear memory),JavaScript 可以通过 app.exports.memory.buffer 访问这块内存。sw.js 通过「写入请求数据 → 调用 WASM 函数 → 读取响应数据」的方式,与 WASM 模块交换信息。

热更新机制:Service Worker 会在后台定期检查 app.wasm 文件是否有更新(通过 ETag),如果检测到新版本,会先调用旧实例的 stop() 方法,然后加载新实例。整个更新过程对用户完全透明,不需要刷新页面:

// 5-15 分钟内随机检查一次是否有新版本 WASM
setInterval(() => LoadWasmApp("interval"), skewnormal(5, 15) * 60 * 1000);

// 收到客户端的 'clientattached' 消息时也检查
self.addEventListener('message', (event) => {
  if (event.data.type === 'clientattached') {
    event.waitUntil(LoadWasmApp("clientattached"));
  }
});

路由匹配规则:Service Worker 会放过 sw.jsapp.wasm 自身的请求(否则会死循环),其余所有同源请求都会被拦截并转发给 WASM 处理。

3.2 第二层:Rust WASM 模块 (lib.rs)

Rust 代码是整个应用的核心逻辑所在。它负责路由匹配、HTML 渲染和状态管理。编译为 WebAssembly 后,体积经过 opt-level = "z"lto = true 优化,通常只有几十 KB。

3.2.1 路由层:matchit 路由器

项目使用 matchit 作为路由器——这是一个高性能的 HTTP 路由库,也是多个流行 Rust Web 框架(如图) 的底层依赖。

use matchit::{Params, Router};

// 定义 Handler 类型:接收请求参数和路径,返回 HTML 字符串
type Handler = fn(&str, &matchit::Params) -> String;

// 初始化路由器
let mut router: Router<Handler> = Router::new();

// 注册路由
router.insert("/", |_, r| page(TITLE, about(r.path())))?;          // 首页
router.insert("/;nav", |_, r| nav(TITLE, about(r.path())))?;       // 无布局版本(HTMX 用)
router.insert("/;clicked", |_, r| about_clicked(r.path()))?;       // 按钮点击处理
router.insert("/todos", |_, _| page(TODOS, component()))?;         // Todo 页面
router.insert("/todos;add", |_, r| hx_add(r))?;                    // 添加 Todo
router.insert("/todos/:id", |p, _| hx_delete(p))?;                 // 删除 Todo
router.insert("/todos/:id/toggle", |p, _| hx_toggle(p))?;          // 切换完成状态
router.insert("/todos;filter=:filter", |p, _| hx_filter(p))?;      // 过滤条件

matchit 的路由语法非常直观::id 表示路径参数,;nav 表示 HTMX 局部更新模式(返回不带完整 HTML 框架的内容),:filter 表示命名参数。

路由派发的核心逻辑(在 WASM 导出函数中):

// WASM 导出的 fetch 函数
#[no_mangle]
pub extern "C" fn fetch() -> i32 {
    let request = get_request_from_memory(); // 从线性内存读取请求 JSON
    
    let req: Request = serde_json::from_str(&request).unwrap();
    let path = extract_path(req.url.as_str()); // 提取路径
    
    // 路由匹配
    match router.at(&path) {
        Ok(matched) => {
            let html = (matched.value)(path.as_str(), matched.params());
            write_response_to_memory(&html); // 将响应写入线性内存
            0 // 返回 0 表示成功
        }
        Err(_) => {
            let html = html! { <p>"404 Not Found"</p> };
            write_response_to_memory(&html);
            0
        }
    }
}

3.2.2 渲染层:html! 宏

html-to-string-macro 提供了一个类 JSX 的声明式宏,可以在 Rust 中用接近 HTML 的语法生成字符串:

use html_to_string_macro::html;

// 基础用法
let greeting = html! { <p>"Hello, World!"</p> };

// Rust 变量嵌入
let name = "Alice";
let card = html! {
    <div class="card">
        <h2>{ name }</h2>
        <p>"Welcome!"</p>
    </div>
};

// 条件渲染
let is_logged_in = true;
let nav = html! {
    <nav>
        <a href="/">"Home"</a>
        { if is_logged_in {
            html! { <a href="/logout">"Logout"</a> }
        } else {
            html! { <a href="/login">"Login"</a> }
        }}
    </nav>
};

// 列表渲染
let items = vec!["Apple", "Banana", "Cherry"];
let list = html! {
    <ul>
        { items.iter().map(|item| html! { <li>{ item }</li> }).collect::<String>() }
    </ul>
};

在 wasm-service 中,每个页面组件都是一个返回 String 的函数。例如 Todo 项的渲染:

fn item_frag(item: &Item) -> String {
    let id = item.id;
    html! {
        <li hx-target="this" hx-swap="outerHTML">
            <input type="checkbox" 
                   { if item.done { "checked" } else { "" }} 
                   hx-post={ format!("./todos/{id}/toggle") }/>
            <label>{ item.label.as_str() }</label>
            <button class="delete" 
                    hx-delete={ format!("./todos/{id}") }></button>
        </li>
    }
}

注意这里嵌入了 HTMX 属性:hx-target="this" 表示操作目标为当前元素自身,hx-swap="outerHTML" 表示用服务器返回的内容替换整个元素,hx-posthx-delete 声明了交互行为。

3.2.3 状态层:Rust 的并发原语

wasm-service 的状态管理非常简洁——直接使用 Rust 标准库的并发原语:

use std::sync::{Mutex, RwLock};

// 全局状态:静态变量(在 WASM 线性内存中分配)
static COUNTER: Mutex<u64> = Mutex::new(0);  // 简单计数器

// Todo 列表
static TODO_ITEMS: RwLock<Vec<Item>> = RwLock::new(vec![]);
static TODO_INC: Mutex<u32> = Mutex::new(0);  // 自增 ID 生成器
static TODO_FILTER: Mutex<Filter> = Mutex::new(Filter::All);

// 状态操作示例:添加 Todo
fn hx_add(request: &Request) -> String {
    #[derive(Deserialize)]
    struct Form {
        #[serde(rename = "todo-new")]
        todo_new: String,
    }
    
    // 解析表单数据
    let label = match serde_urlencoded::from_str::<Form>(&request.body) {
        Err(e) => return html! { <p>"Error decoding form: " { e }</p> },
        Ok(value) => value.todo_new,
    };
    
    // 生成新 ID(原子操作)
    let id = {
        let mut inc = TODO_INC.lock().unwrap();
        *inc = inc.wrapping_add(1);
        *inc
    };
    
    // 写入共享状态
    TODO_ITEMS.write().unwrap().push(Item {
        id,
        done: false,
        label,
    });
    
    // 返回 HTML 片段(用于 HTMX 局部更新)
    html! {
        { item_frag(TODO_ITEMS.read().unwrap().last().unwrap()) }
        { input_frag(true) }      // OOB 更新:清空输入框
        { count_frag(true) }      // OOB 更新:更新计数
        { toggleall_frag(true) }  // OOB 更新:更新全选状态
    }
}

OOB(Out-Of-Band)交换:注意 { input_frag(true) } 中传入 true 参数,这会生成带有 hx-swap-oob="true" 属性的 HTML。HTMX 的 OOB 机制允许一次响应同时更新多个页面区域——返回的 HTML 不仅替换主目标,还会同步更新其他标记了 OOB 的元素。这解决了「添加一个 Todo 后,输入框清空、计数更新、全选状态重置」等多个状态变更的需求。

3.2.4 导出接口:cdylib 与 WASM FFI

Rust 代码需要导出特定的函数供 JavaScript 调用,这通过 #[no_mangle]pub extern "C" 实现:

// Cargo.toml 中声明 crate-type 为 cdylib(动态库)
[lib]
crate-type = ["cdylib"]

// 导出的函数签名
#[no_mangle]
pub extern "C" fn fetch() -> i32 { /* ... */ }

#[no_mangle]
pub extern "C" fn allocate_request(len: usize) -> i32 { /* ... */ }

#[no_mangle]
pub extern "C" fn response_ptr() -> i32 { /* ... */ }

#[no_mangle]
pub extern "C" fn response_len() -> i32 { /* ... */ }

#[no_mangle]
pub extern "C" fn stop() { /* ... */ }

cdylib 类型生成纯 C 接口的动态库,没有 Rust 运行时的依赖,非常适合嵌入到其他环境中。

3.3 第三层:HTMX 交互 (index.html)

HTML 层面非常简单,只需要引入 HTMX 库并注册 Service Worker:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <title>About</title>
  <!-- Water.css:极简 CSS 框架,一行引入即有好看的默认样式 -->
  <link rel="stylesheet" 
        href="https://unpkg.com/water.css@2.1.1/out/water.css" />
  
  <!-- HTMX:整个交互层的基础 -->
  <script src="https://unpkg.com/htmx.org@1.8.2/dist/htmx.js"></script>
  
  <script>
    // 注册 Service Worker——这启动了整个拦截链
    if ("serviceWorker" in navigator) {
      navigator.serviceWorker.register("/sw.js")
        .then(reg => {
          console.log("service worker registered", reg);
          reg.active.postMessage({ type: 'clientattached' });
        })
        .catch(err => {
          console.error("service worker registration failed", err);
        });
    }
  </script>
</head>
<body>
  <!-- 导航栏:使用 HTMX 的 hx-get 实现客户端路由 -->
  <div class="nav-tabs" hx-target="closest body">
    <a href="" 
       hx-get="./;nav"        <!-- ;nav 后缀表示返回无布局版本 -->
       hx-push-url="" 
       class="selected">"About"</a>
    <a href="todos" 
       hx-get="./todos;nav" 
       hx-push-url="todos">"Todos"</a>
  </div>
  
  <!-- 页面内容:来自 WASM 渲染的初始 HTML -->
  <h1>HTMX + Service Workers + WebAssembly + Rust</h1>
  
  <!-- 交互按钮:点击后向 ./;clicked 发送 POST 请求 -->
  <!-- Service Worker 拦截 → 转发给 WASM → 返回 HTML → 替换 #target -->
  <button hx-post="./;clicked" 
          hx-swap="innerHTML" 
          hx-target="#target">
    "Click Me"
  </button>
  <div id="target"></div>
</body>
</html>

这里的关键是 hx-push-url——它会在浏览器地址栏更新 URL(支持浏览器的前进/后退导航),同时不触发页面刷新,实现了「客户端路由」的效果。


四、代码实战:从零构建一个 Todo 应用

理解了架构之后,我们来看一个完整的实战例子:在 wasm-service 中实现一个标准 TodoMVC 功能。

4.1 项目初始化

# 1. 安装 Rust 和 WASM 编译目标
rustup target add wasm32-unknown-unknown

# 2. 克隆项目
git clone https://github.com/richardanaya/wasm-service
cd wasm-service

# 3. 开发时热重载:文件变化自动编译
cargo install cargo-watch
cargo watch -i app.wasm \
  -x 'build --target wasm32-unknown-unknown --release' \
  -s 'cp target/wasm32-unknown-unknown/release/wasm_service.wasm app.wasm'

4.2 定义数据结构

mod todos {
    // 数据模型
    struct Item {
        id: u32,
        done: bool,
        label: String,
    }
    
    // 过滤器枚举
    #[derive(strum::EnumString, PartialEq, Clone, Copy)]
    enum Filter {
        All,
        Active,
        Completed,
    }
    
    // 全局状态(在线性内存中持久化直到页面刷新)
    static TODO_ITEMS: RwLock<Vec<Item>> = RwLock::new(vec![]);
    static TODO_INC: Mutex<u32> = Mutex::new(0);
    static TODO_FILTER: Mutex<Filter> = Mutex::new(Filter::All);
}

4.3 注册路由

pub(crate) fn register(router: &mut matchit::Router<Handler>) 
    -> Result<(), matchit::InsertError> 
{
    const TODOS: &str = "Todos";
    
    router.insert("/todos", |_, _| page(TODOS, component()))?;
    router.insert("/todos;nav", |_, _| nav(TODOS, component()))?;
    router.insert("/todos;add", |_, r| hx_add(r))?;
    router.insert("/todos/:id", |p, _| hx_delete(p))?;
    router.insert("/todos/:id/toggle", |p, _| hx_toggle(p))?;
    router.insert("/todos;toggleall", |_, _| hx_toggleall())?;
    router.insert("/todos;filter=:filter", |p, _| hx_filter(p))?;
    
    Ok(())
}

4.4 核心交互:添加 Todo

fn hx_add(request: &Request) -> String {
    #[derive(Deserialize)]
    struct Form {
        #[serde(rename = "todo-new")]
        todo_new: String,
    }
    
    // 1. 解析 HTMX 表单数据(application/x-www-form-urlencoded)
    let label = match serde_urlencoded::from_str::<Form>(&request.body) {
        Err(e) => return html! { <p>"Error decoding form: " { e }</p> },
        Ok(value) => value.todo_new,
    };
    
    if label.trim().is_empty() {
        return String::new(); // 空内容不添加
    }
    
    // 2. 生成唯一 ID
    let id = {
        let mut inc = TODO_INC.lock().unwrap();
        *inc = inc.wrapping_add(1);
        *inc
    };
    
    // 3. 写入全局状态
    TODO_ITEMS.write().unwrap().push(Item {
        id,
        done: false,
        label: label.trim().to_string(),
    });
    
    // 4. 返回 OOB HTML 片段(同时更新多个区域)
    html! {
        // 新 Todo 项插入列表顶部
        { item_frag(TODO_ITEMS.read().unwrap().last().unwrap()) }
        // 清空输入框(OOB)
        { input_frag(true) }
        // 更新剩余计数(OOB)
        { count_frag(true) }
        // 更新全选状态(OOB)
        { toggleall_frag(true) }
    }
}

对应的 HTML 模板:

fn input_frag(oob: bool) -> String {
    html! {
        <input id="todo-new" 
               name="todo-new" 
               placeholder="What needs to be done?" 
               autofocus
               hx-post="./todos;add"
               hx-target=".todos ul"
               hx-swap="afterbegin"
               { if oob { r#"hx-swap-oob="true""# } else { "" } } />
    }
}

4.5 核心交互:切换完成状态

fn hx_toggle(params: &matchit::Params) -> String {
    let id = match params.get("id").map(str::parse::<u32>) {
        Some(Ok(id)) => id,
        _ => return html! { <p>"Invalid param"</p> },
    };
    
    {
        let mut items = TODO_ITEMS.write().unwrap();
        if let Some(item) = items.iter_mut().find(|i| i.id == id) {
            item.done = !item.done;
        }
    }
    
    // 返回更新后的 Todo 项 HTML(替换当前行)
    let item = TODO_ITEMS.read().unwrap()
        .iter().find(|i| i.id == id).unwrap();
    
    // OOB:同步更新计数
    html! {
        { item_frag(item) }
        { count_frag(true) }
    }
}

4.6 过滤功能

fn hx_filter(params: &matchit::Params) -> String {
    let filter_str = params.get("filter").unwrap_or("All");
    let filter: Filter = strum::EnumString::parse(filter_str)
        .unwrap_or(Filter::All);
    
    *TODO_FILTER.lock().unwrap() = filter;
    
    // 返回整个列表(过滤后的内容)
    items_frag(false)
}

五、性能分析:真的能替代服务器吗?

5.1 启动性能

冷启动路径(首次访问):
  1. 浏览器下载 index.html        ~2 KB
  2. 浏览器下载 sw.js              ~6 KB
  3. Service Worker 安装
  4. 浏览器下载 app.wasm           ~50-100 KB(压缩后)
  5. WebAssembly 实例化            <10 ms
  6. 首次页面渲染                  <5 ms

总计首次访问:网络下载 ~60 KB + 初始化 ~15 ms

作为对比,一个典型 Next.js SSR 应用的冷启动(Serverless 环境下):

  • 冷启动时间:200ms - 3s(取决于函数实例复用情况)
  • 网络流量:HTML + JS chunks ≈ 200-500 KB
  • 额外延迟:网络往返(浏览器 → 服务器 → 浏览器)≈ 50-200 ms

wasm-service 在第二次及后续访问时具有决定性优势:WASM 模块和 Service Worker 已经完全缓存,响应完全来自浏览器内部,没有任何网络往返。

5.2 运行性能

// 在 wasm-service 中实测:路由 + 渲染一个 Todo 列表
// 设备:MacBook Pro M1,Chrome 浏览器

// 100 个 Todo 项的列表渲染:
//   wasm-service (WASM):     ~0.3 ms
//   React 18 (CSR):          ~2.1 ms(首次渲染)
//   Next.js (SSR):           ~8.5 ms(服务端渲染 + 网络传输)

// 响应体积:
//   wasm-service: 返回纯 HTML 片段 ~500 bytes(无 JSON 序列化开销)
//   REST API + CSR: JSON ≈ 1.5 KB + JS 渲染 ≈ 2 KB

Rust 的零成本抽象和编译期优化,使得 WASM 中的渲染性能通常优于同等的 JavaScript 实现。特别是对于计算密集型的页面(如大量数据的列表、复杂表格),WASM 的性能优势会更明显。

5.3 网络性能:离线与弱网

这是 wasm-service 最具颠覆性的优势场景:

场景传统 SSR/SPAwasm-service
网络正常✅ 完整功能✅ 完整功能
断网(飞行模式)❌ 完全不可用✅ 完整功能(数据在内存,刷新丢失)
弱网(2G/地铁)⚠️ 慢或超时✅ 极快(无网络请求)
首屏时间(重复访问)依赖网络< 50ms(本地)

5.4 数据持久化:当前局限与解决方案

wasm-service 当前版本不包含数据持久化——所有状态存在 WASM 线性内存中,页面刷新即丢失。这是一个重大限制,但也是可以解决的方向:

方案一:IndexedDB

// 在 WASM 中使用 wasm-bindgen 操作 IndexedDB
// 通过 wasm-bindgen 导出 Rust 函数,JavaScript 桥接 IndexedDB
// 将 RwLock<Vec<Item>> 替换为持久化存储

方案二:LocalStorage + 序列化

// 在 sw.js 中拦截 beforeunload 事件,自动将状态序列化到 LocalStorage
self.addEventListener('beforeunload', (event) => {
  // 将 WASM 内存中的状态序列化并保存
  const state = app.exports.get_state_json();
  localStorage.setItem('wasm_todos_state', state);
});

方案三:Background Sync API
允许 Service Worker 在网络恢复后自动将数据同步到远程服务器,实现「离线优先,同步在后」的架构。


六、工程哲学:为什么这个项目值得关注

6.1 从「前后端分离」到「前后端融合」

过去十年,前端工程化的核心叙事是「前后端分离」——后端提供 API,前端负责 UI。但这种分离也带来了代价:API 契约维护、跨端类型共享、网络请求管理、状态同步……

wasm-service 代表了一种「激进融合」的思路:**后端的渲染能力直接在浏览器中运行,前端不需要 API 层,直接用 HTML 片段交换数据。**这实际上是回到了 PHP/JSP 时代的「服务端渲染」,但以现代的方式——类型安全(Rust)、高性能(编译型 + WASM)、优雅的组件化(html! 宏)。

6.2 Serverless 的终极形态

Serverless 的理想是「不需要管理服务器」。wasm-service 进一步推进了这个理想:不需要服务器本身。业务逻辑在浏览器中运行,没有冷启动问题,没有服务器费用,没有运维负担。

当然,这种模式的适用场景是有限的——它最适合工具类应用、内容展示型应用、内部管理系统。对于需要持久化大量数据、需要跨用户共享数据的应用,传统的服务端架构仍然是必要的。

6.3 对前端开发生态的启示

wasm-service 展示了一种可能性:前端开发可以使用系统级编程语言,获得类型安全和接近原生的性能。随着 WASI(WebAssembly System Interface)的成熟,Rust 编写的 WASM 模块将能够访问文件系统、网络、时钟等系统资源,前端的能力边界将大大扩展。


七、局限性与冷思考

尽管 wasm-service 的设计令人兴奋,但我们必须冷静地分析它的局限性:

7.1 数据持久化缺失

如前所述,WASM 线性内存中的数据在页面刷新后会丢失。对于 Todo 应用,这意味着每次刷新都要重新开始。对于真实应用,这几乎是不可接受的。虽然 IndexedDB 等方案可以缓解这个问题,但会增加复杂度,削弱「简单」的核心理念。

7.2 多人协作场景完全不适用

如果两个用户同时使用这个应用,他们的数据是完全隔离的——各自在各自的浏览器中运行,服务器上没有任何共享数据。这意味着任何需要用户间协作的功能都无法实现。

7.3 WASM 模块大小与初始化

即使经过优化(opt-level = "z" + lto = true),一个功能完整的 WASM 应用仍然需要几十到上百 KB。对于移动网络环境,首次加载仍然有成本。

7.4 调试体验差

当 WASM 中的 Rust 代码出错时,调试体验远不如纯 JavaScript。Rust 的 panic 信息虽然详细,但在 WASM 环境中,错误定位仍然需要借助 Source Map 和专业工具。

7.5 SEO 友好性

虽然 Service Worker 可以在有网络时充当服务器返回完整 HTML,但搜索引擎爬虫通常不会执行 JavaScript/Service Worker。SSR 模式下的 SEO 优势在这种架构中打了折扣。


八、总结:一种值得关注的范式探索

wasm-service 不是第一个也不会是最后一个尝试「把服务器塞进浏览器」的项目。但它以极简的代码量(500 行 Rust + 200 行 JS)实现了一个完整的、可运行的、逻辑自洽的全栈应用,这本身就值得称道。

它的核心价值不在于「可以直接替代生产环境的后端」,而在于拓展了 Web 应用的想象空间:前端可以用 Rust 写后端逻辑,可以在没有网络的环境下运行,可以获得接近原生的性能。

2026年的 Web 生态正在走向多元:SSR、SPA、BFF、Edge Computing、CRUD-as-a-Service……wasm-service 加入了这场探索,提供了一个独特的视角。它可能不会成为主流,但它代表的方向——将更多计算能力下沉到边缘(这里指浏览器)——将是持续演进的技术趋势。

对于前端开发者而言wasm-service 至少是一个值得了解的实验:它展示了 HTMX 与 WebAssembly 结合的潜力,展示了 Rust 在前端场景中的表达能力,也展示了 Service Worker 作为「浏览器内服务器」的可能性。

如果你对「无后端 Web 开发」这个方向感兴趣,wasm-service 是一个极好的起点。它代码量小、概念清晰、运行门槛低——你可以在一小时内让它跑起来,然后亲手实验这个激进的架构能走多远。


项目信息

相关项目

  • prest:受 wasm-service 启发的一个框架实现
  • HTMX: htmx.org —— 「HTML 作为你的超文本」
  • matchit: Rust 高性能路由库,多个主流框架的底层依赖

推荐文章

Nginx 状态监控与日志分析
2024-11-19 09:36:18 +0800 CST
你可能不知道的 18 个前端技巧
2025-06-12 13:15:26 +0800 CST
Python上下文管理器:with语句
2024-11-19 06:25:31 +0800 CST
程序员茄子在线接单