鸽了一年,不重要,下一篇解释

现在几乎现代框架 SSR 默认都是用 node,难道不能用别的?原因很简单,同构,有天然适合的执行/运行时环境。拿 React 举例子,SSR 分两个步骤:

  1. 静态部分先用服务器渲染一遍,最基本的都是拿入口过一遍 renderToString
  2. 运行时水合,也就是不用 render 而是 hydrate,因为已经不需要在运行时创建节点,只需要绑定

所以问题就很清晰:默认或者常规手段的 hydraterenderToString 都是 JS 函数

当 QuickJS 刚出现在我的眼前的时候,特别是有人给 QuickJS 提供 rust 绑定的时候,我就在想一个问题:这 B 是不是可以直接拿来做 SSR ?

Why QuickJS

毕竟 rust 早就有 v8 绑定,deno 用的就是

你说得对,但是 QuickJS 是由著名神仙 Fabrice Bellard 所打造的一个小型并且可嵌入的 Javascript 引擎,它支持ES2020规范,包括模块,异步生成器和代理器。在这里,你可以用任何现代的 JavaScript 语言片段在几乎任何语言环境中运行,在自由的环境中邂逅不同的语言和技术栈,同时它还支持 Decimal 提案和运算符重载提案……

其实就是不挑

前端

流水账,主要是踩坑流程

前端部分自不必多说,玩了两年半的 solidjs,现在只要不是上班都是用这款(其实上班能夹带私货的时候也用)

solid-ssr 提供 SSR 各种形态的 demo,但他们都有一个特点:使用非常原生的 rollup 插件作为演示[1],那我正经用也不用这个啊,那不行,就得按 vite 工作流来。

so

pnpm dlx degit solidjs/templates/ts

首先 vite.config.ts 有两处调整:

  • solid 插件激活 ssr,这在处理 ssr 输出时有用
  • noExternal,后面讲为什么

改完之后大概是这样

export default defineConfig({
  plugins: [solidPlugin({ ssr: true })],
  build: {
    target: "esnext",
  },
  ssr: {
    noExternal: true,
  },
});

package.json 新增一个 script,不加也行

{
  "type": "module",
  "scripts": {
    "build": "vite build --outDir ./dist/client",
    "build:server": "vite build --ssr ./src/entry-server.tsx --outDir ./dist/server",
  }
}

这里把输出路径都有点调整,目的是把客户端产物和服务端产物分开,不这么做也行。但入口是肯定分开的,即使这很基操不需要写下来

按照 vite 建议的做法,客户端入口从 index.tsx 改为 entry-client.tsx,则服务端入口为 entry-server.tsx,内容每个框架都差不多,按照前端框架的建议做即可

记得客户端真实入口 index.html 里的 script 路径也要改!

过一下 build,能看到 dist 下两组目录,下一步

服务端

Web Framework 不用挑,用的最多的(可能也是两年半),可能也是目前来说依然最虎的 actix-web,需要花心思的是找 binding

首先用的是 theduke/quickjs-rs,在品鉴这个项目的时候还顺便搜到另一个项目 galvez/fast-vue-ssr,这是一个类似的操作:通过 QuickJS SSR Vue,可以读读看

Node outperforms QuickJS by a wide margin. Especially with enough cores and memory. However, QuickJS is very small and has very low memory consumption, so running it threaded in a Rust shell makes it possible to have very high throughput using very few resources in comparison.

顺便品鉴完这个项目之后,开始缝合。不出意外的话马上要出意外了

坑1:

unsupported keyword: export

这里可以讲讲为什么上面要 noExternal ,原因很简答,vite 默认你是会用 node 的,加上 type 是 module,结果就是编译后的服务端入口依然有 importexport

所以目的很简单:把所有用到的 js 都打成一个文件。并且这里还需要一个操作:去掉最后一行,也就是 export 部分

默认情况下是在 QuickJS global 环境下 eval 程序,直接当成 REPL 来用就完事了

坑2:

找不到 setTimeout

一番查找,setTimeout 由 QuickJS 内置 module: os 提供,按道理来说 setTimeout 应该能在 globalThis 直接调用

没关系,直接 eval 进去吧

坑3:

不存在 os

这你妈你说你妈呢? 这怎么可能啊

只见 Issue 区赫然有这么一行

Ok,寄

Finish

本来看着是 star 最多的库

算了,还得是直接绑定来得稳定,虽然比较麻烦,相当于换个语言直接使用框架。但高级封装至少比低级封装容易使用

于是改用这个高级封装:DelSkayn/rquickjs,这个就非常纯,需要直接生吃 Document

简单验证

let test = ctx.eval("let a = 1; let b = 2; a + b").unwrap();
assert!(3, test);

发现没问题,直接搬

let result = context.with(|ctx| {
    let _a: String = ctx.eval_file("ssr/dist/server/entry-server.js").unwrap();
    let result: Object = ctx.eval(r#"render()"#).unwrap();

    let head: String = result.get("head").unwrap();
    let body: String = result.get("body").unwrap();
    let mut map = HashMap::<String, String>::new();

    map.insert("body".to_string(), body);
    map.insert("head".to_string(), head);

    map
});

这里转一次 HashMap 的原因是:Object 的类型没有声明,直接 return 出去有静态检查问题,rust 不允许

但因为是概念验证,先转成已知类型出去顶着用

编译没问题,继续搬

let template = read_to_string("ssr/dist/client/index.html").unwrap();

let html = template
    .replace("<!--app-head-->", result.get("head").unwrap())
    .replace("<!--app-body-->", result.get("body").unwrap());

打印 html,确认内容就是一个完整的 html 文件。成了!

那直接上 server-side,简简单单写个 get

#[get("/")]
async fn hello() -> impl Responder {
    let html = make_html();
    HttpResponse::Ok()
        .status(StatusCode::OK)
        .content_type("text/html")
        .body(html)
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            // 静态文件映射
            .service(Files::new("/assets", "ssr/dist/client/assets"))
            .service(hello)
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

浏览器输入一个令人熟悉的 localhost:8080,正常运行,JS 绑定也一切正常

代码会发布在 colmugx/quickjs-ssr

最后

这里只是一个简单验证,SSR 的 real world 肯定不是这么两步就能完成,否则就不需要像 next.jsSolidStart 这样的上层框架

并且,除了服务端需要考虑线程池等等这些问题之外,拥有 JIT 的 v8 可能性能上会更优

但我的目的也很简单,我要的不是 SSR,而是两个需求

  • ayaka 提供一个静态页面生成的方式,但默认还是像 saika 那样的运行时 fetch
  • kazusa 提供脚本执行环境,不过这是个 optional,主要维护还是 lua 执行环境,这与既定用户群有关

别问为什么又来一个 ayaka,问就是灵感来源 2nthony/saika,而不叫sakuya的原因仅是因为那会在玩原神银趴

最后,这可能是第一篇来自于这个主题 3.5 版本的文章,想给用 hexo 的这段时间一个完美的休止符,然后去开发有新设计风格的 nlvi 4

但是重构一半,交互上还是有缺陷,只不过说不影响阅读


  1. solidjs 的 vite 插件来源于 rollup 插件的上层封装 ↩︎