跳转至

Refactor My Quiz App

花了几天把 NekoQuiz 完全重构,从 Axum + Yew 到 Axum + Leptos,从 CSR 到 SSR with hydration。

远在这次重构之前,我就已经没有了使用它的机会,也许这是最后一次大更新。(尽管我不愿意那么想,这样的想法会成为项目被“遗弃”的第一步)

至少能确定的是,已经到了进行总结的最佳时刻。

NekoQuiz 和猫咪问答

NekoQuiz 来源于 USTC Hackergame 的题目“猫咪问答”,它由一系列问题组成,要求选手仅通过 网络信息检索 找到答案。

例如:

Q:不严格遵循协议规范的操作着实令人生厌,好在 IETF 于 2021 年成立了 Protocol Police 以监督并惩戒所有违背 RFC 文档的行为个体。假如你发现了某位同学可能违反了协议规范,根据 Protocol Police 相关文档中规定的举报方法,你应该将你的举报信发往何处?

A:与去年的鸽子题一脉相承的搞笑 RFC 题。搜索 "RFC protocol police",优秀的搜索引擎可以给出正确的 RFC 编号:rfc8962。全文搜索 "report",可以找到:

6.  Reporting Offenses
   Send all your reports of possible violations and all tips about
   wrongdoing to /dev/null.  The Protocol Police are listening and will
   take care of it.

所以答案为 /dev/null

其他题目也很有趣,如果你对它们感兴趣的话:

信息检索能力是最基础也最宝贵的计算机技能,尤其在这个被 AI 笼罩着模糊阴影的 202X 年。

NekoQuiz 是个不太有趣的项目

USTC 猫咪问答的形式非常简单,一个页面,几个表单框,一个提交按钮。印象中前端用了 Bootstrap,后端是 Node.js。程序上相当普通,甚至可以说“无聊”。

而作为复刻项目的 NekoQuiz 乍一看不是这样:Tailwind CSS、WASM、TOML 配置、热重载(曾经)。实际上却是没差,这些花哨的词汇对 Quiz App 来说帮助不大,它们的出现是因为个人意愿超出了实际需求:

  1. 校内的 CTF 比赛需要信息检索题(这是委婉的说法,确切来说是我的一厢情愿)
  2. 我想用 Rust 写 Web App

我摸索着学习 Web 开发中相对新潮的事物,把它们应用进来,可能算是技术栈上狭义的“进步”,但我绝对不认为这很必要和有趣。

老一套

Rust 的 Web 开发体验很奇妙,总的来说体感不错。

最初我使用 Yew 作为前端,它和 React 的概念很像,#[function_component] 对应 React 的函数组件,use_reducer_eq 类似 React 的 useReducer。在组件挂载时通过 use_effect_with 获取 Quiz 数据,这和 React 的 useEffect 几乎一模一样。

网络请求使用 gloo_net::Request::get 发送,整个异步操作被包装在 wasm_bindgen_futures::spawn_local() 中执行。

pub async fn get_quiz() -> Result<Quiz, QuizError> {
    let response = Request::get("/api/quiz").send().await?;
    Ok(response.json::<Quiz>().await?)
}

impl AppContext {
    pub fn get_quiz(&self) {
        let state = self.state.clone();
        spawn_local(async move {
            match get_quiz().await {
                // ...
            }
        });
    }
}

#[function_component(App)]
pub fn app() -> Html {
    let state = use_reducer_eq(AppState::default);
    let context = AppContext::new(state.clone());

    {
        let context = context.clone();
        use_effect_with((), move |()| {
            context.get_quiz();
        });
    }

    // ...
}

前端代码通过 Trunk 编译为 WASM 和相关静态资源,并在后端的 Axum 路由中作为静态文件服务挂载。这种情况下,Axum 既充当 API 服务器又充当静态文件服务器:

pub async fn get_quiz(State(state): State<Arc<AppState>>) -> Json<Quiz> {
    Json(Quiz {
        title: state.title().await,
        questions: state.questions().await,
        version: state.version().await,
    })
}

async fn run() -> AppResult<()> {
    // ...
    Router::new()
        .nest_service("/", ServeDir::new(serve_dir))
        .route("/api/quiz", get(get_quiz))
    // ...
}

本质上是前后端分离的 CSR (client-side rendering) 模式。

和新方法

放出重构的代码之前,先聊聊 Web 技术的历史。

PHP 是 SSR 吗?

哪怕时间倒退几年,SSR (server-side rendering) 也不是一个新概念,JSP 和 PHP 都是 SSR(至少字面意义上),只是当时并没有 SSR 的说法——没什么东西是 CSR 的。

博学多闻 [容易:成功]:传统的渲染方式中,由服务端渲染完整的 HTML 给浏览器,每次的交互都依靠表单提交并带来页面刷新。是的,体验并不好,对挑剔的现代人来说。

天人感应 [困难:成功]:夜风从半开的窗户挤进房间,带着远方工厂的废气,漆黑的角落中方形玻璃投射出冷白色的光芒。一位少年蜷缩在椅子里,他习惯了在手指下落的瞬间眨眼,黑暗与纯白重合,等待凌乱的色彩重新覆盖玻璃。指纹在塑料表面留下油腻的轨迹,这些轨迹将比他的童年存在更久,而网络无限。

定义虽如此,我相信 SSR 这个称呼对于 JSP / PHP 是没有意义的,就如同 @Huli 所说

就像是如果我們把「飲料」定義為「可以喝的液體」,那你能不能說酸辣湯也是一種飲料?照定義來看沒有問題,但當有人問你「最喜歡喝的飲料是什麼?」的時候,你會說酸辣湯嗎?應該不會,而我們也不會把酸辣湯稱之為是飲料。

同理,雖然 SSR 字面上的意思是那樣,PHP 這種傳統 server 輸出內容的方案也可以稱之為 SSR,但你不會這樣叫它。SSR 更適合拿來指涉的是「用來解決 SPA 問題的 server 端解決方案」。

SPA 与 CSR

XMLHttpRequest 技术让开发者可以在客户端动态生成页面内容,在不重新加载整个页面的情况下更新显示内容。Gmail 在 2004 年展示了这种无刷新交互的强大威力——收发邮件、切换标签、搜索联系人,页面从未刷新过。

2005 年,Jesse James Garrett 将这种使用 XMLHttpRequest 的交互模式命名为 AJAX,这个概念迅速普及。随后 jQuery 统治了 DOM 操作的世界,Backbone.js 为混乱的 JavaScript 代码带来结构,接着 Angular、React、Vue 登场,SPA (Single Page Application) 成为了 Web 开发的主流范式。

SPA 的核心理念简单粗暴:服务端只提供数据,前端负责所有的渲染工作。用户首次访问时下载一个相对较大的 JavaScript bundle,然后享受近乎原生应用的交互体验。

这种模式有了一个专门的术语:CSR (Client-Side Rendering),即客户端渲染。

这确实解决了传统渲染方式的问题——每次点击都要等待页面刷新。但新的问题也随之而来:

  • 空白的首屏:JavaScript 执行之前,用户看到的只是一个空白页面或者简陋的 loading 动画。初版 NekoQuiz 就收到了这样的抱怨。
  • 不利于 SEO:搜索引擎爬虫看到的是一段“空白”的 HTML 和 JavaScript 链接而不是实际内容

逻辑思维 [中等:成功]:这听起来像是在用一个问题解决另一个问题。

是的,也许技术发展就是这样,每个解决方案都会带来新的问题,或者说根本没有 Solution 只有 Workaround。

第一版 NekoQuiz 从 SPA + CSR 起步,那第一版 USTC 猫咪问答呢?

很可能是 PHP 的:Hackergame2018 ustcquiz 源代码

现代 SSR 的回归——水合(Hydration)

CSR 带来的问题让开发者们开始怀念服务器渲染的好处:首屏内容立即可见、SEO 友好、JavaScript 失效时页面依然可见。

但谁也不想回到 PHP + jQuery 的石器时代,于是许多框架提出 SSR 的方案,试图结合二者的优点:首次访问时服务器渲染完整的 HTML,后续交互则切换到客户端渲染模式

用户体验变成了这样:

  1. 服务器端:生成包含实际内容的 HTML
  2. 浏览器:立即显示服务器渲染的内容(用户可以看到页面了)
  3. JavaScript 加载完成:页面“水合”,变为可交互的 SPA

这种方式有效地避开了 CSR 的问题。

水合(Hydration),来自化学反应名称,非常形象的取名。也有人把它比喻成“三体人模型”:

  • 喝水(Render):服务端执行完整的渲染逻辑,生成包含实际内容的 HTML。
  • 脱水(Dehydration):将应用状态序列化并嵌入 HTML,移除交互逻辑,输出静态但完整的页面。
  • 水合(Hydration):客户端 JavaScript 接管静态 HTML,恢复应用状态和交互能力。

代价很明显,“浸泡”(水合)阶段需要耗时,页面要过一段时间才可交互,TTI (Time To Interactive) 上升了。

同构(Isomorphic)

我不想纠结 Isomorphic(同构)和 Universal(通用)的命名问题,它们更多处在 JavaScript 语境下。

Leptos 官方倾向使用 Isomorphic 一词,我认为没什么问题。Leptos 实现了 Universal 的效果——同一套 Rust 代码可以编译为服务器二进制和客户端 WASM,但最终的目标还是构建出 Isomorphic 应用。

使用 Leptos 可以同时做到这几点:使用相同的语言,共享相同的类型,甚至在相同的文件中

这是我为 NekoQuiz 重构的最大原因,毕竟 CSR 的 SEO 和白屏问题对 CTF 题不值一提,我只是对在多个独立的地方维护代码感到烦躁了。

Leptos 通过条件编译和 Server Functions 实现同构:#[server] 宏标记的函数只在服务端编译和执行,但客户端可以像调用本地函数一样调用它们,Leptos 会自动生成相应的网络请求代码。

以简化版的 NekoQuiz 源码为例:

#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
    use crate::app::*;
    console_error_panic_hook::set_once();
    leptos::mount::hydrate_body(App);
}
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
    // ...
    let app = Router::new()
        .leptos_routes(&leptos_options, routes, || shell(leptos_options))
        .with_state(leptos_options);
    // ...
    axum::serve(listener, app).await?;
    // ...
}


#[cfg(not(feature = "ssr"))]
pub fn main() {
    // no client-side main function
    // unless we want this to work with e.g., Trunk for pure client-side testing
    // see lib.rs for hydration function instead
}
#[server(GetQuiz, prefix = "/api", endpoint = "quiz", input = GetUrl)]
pub async fn get_quiz() -> Result<Quiz, ServerFnError> {
    let config = expect_context::<Arc<Config>>();

    Ok(Quiz {
        title: config.general.title.clone(),
        questions: config.questions.clone(),
    })
}

#[component]
fn HomePage() -> impl IntoView {
    let quiz_resource = Resource::new(|| (), |_| async { get_quiz().await });
    // ...
}
  • 使用 hydrate 特性时,编译出 WASM 模块,包含水合逻辑和 server functions 的客户端存根。
  • 使用 ssr 特性时,编译出服务端可执行程序,包含完整的渲染逻辑和 server functions 实现。

相比之前 Yew + Axum 的方案,消除了手动维护 API 接口和类型定义的重复工作,我也可以不用再写分步编译、打包的脚本了。

后话

逻辑思维 [困难:失败]:但……这意味着什么?历史视角并没有让你真正看清任何事物的发展,混乱的时代下你只是追随潮流。

标新立异 [中等:成功]:大家都热衷于潮流,你需要比一些人*更潮流*。

Web 技术在螺旋。

参考