不使用 JavaScript 的客户端路由

2025-05-28

不使用 JavaScript 的客户端路由

我上次写关于SolidJS技术创新的文章已经有一段时间了。自从我们通过流式 SSR 在服务器上添加 Suspense 以来,已经过去了两年。而早在 2019 年,我们首次引入 Suspense 用于数据获取和并发渲染,时间就更久远了。

虽然 React 引入了这些概念,但将它们实现为一个细粒度的响应式系统却是另一回事。这需要一点想象力以及完全不同的、避免差异的解决方案。

这与我们最近的探索有着类似的感受。受到React 服务器组件以及MarkoAstro等 Island 解决方案的启发,Solid 迈出了Partial Hydration的第一步。(底部有对比


固体启动

图片描述

自从 Solid 1.0 发布以来,我一直忙得不可开交。一边要控制未解决的问题,一边又要完成更多待采纳的方案,我感觉自己真的精疲力竭。所有的事情都表明需要一个 SSR 元框架,而这项工作我在 1.0 发布之前就已经开始着手了。

社区挺身而出提供帮助。但最终,为了推出测试版,我成了阻碍者。尼基尔·萨拉夫是个永不停歇的人,最近刚接触到Fresh,他想试试能不能直接把 Islands 添加到 SolidStart 中。

为了专注于发布,我同意了,但告诉他要设定时间限制,因为第二天我需要他的帮助。第二天,他给我展示了一个演示,他不仅添加了Islands,重现了Fresh的体验,还添加了客户端路由。


意外岛屿

图片描述

这个演示虽然粗糙,但令人印象深刻。他借鉴了我的一个 Hackernews 演示,并重新实现了递归岛。什么是递归岛……那就是你把岛投影到岛中的时候:

function MyServerComponent(props) {
  return <>{ props.data && 
    <MyClientIsland>
      <MyServerComponent data={props.data.childData} />
    </MyClientIsland>
  }</>
}
Enter fullscreen mode Exit fullscreen mode

你为什么要这么做?如果能将服务器渲染的内容与交互功能结合起来,同时又不至于完全丢失整个子树的低级 JavaScript,那就太好了。

然而,Islands 有一个规则,你不能导入和使用仅用于服务器的组件。原因是你不希望客户端能够将状态传递给它们。为什么?因为如果客户端可以传递状态,那么它们就需要能够更新,而由于我们的想法是不将这段 JavaScript 发送到浏览器,所以这行不通。幸运的是,Islands 很好props.children地执行了这条边界。(假设你禁止跨 Island 边界传递渲染函数/渲染 props)。

function MyClientIsland() {
  const [state, setState] = createSignal();

  // can't pass props to the children
  return <div>{props.children}</div>
}
Enter fullscreen mode Exit fullscreen mode

他怎么能这么快就做出这个 demo?嗯,纯属偶然。Solid 的 Hyption 机制是将层级 ID 与 DOM 中实例化的模板进行匹配。它们看起来像这样:

<div data-hk="0-0-1-0-2" />
Enter fullscreen mode Exit fullscreen mode

每个模板都会增加一个计数,每个嵌套组件都会增加一位数字。这对于我们的单遍数据融合至关重要。毕竟,JSX 可以按任意顺序创建,并且 Suspense 边界可以随时解析。

但是在给定的深度下,所有 ID 都将按照相同的顺序分配给客户端或服务器。

function Component() {
  const anotherDiv = <div data-hk="1" /> 
  return <div data-hk="2">{anotherDiv}</div>
}

// output
<div data-hk="2">
  <div data-hk="1" />
</div>
Enter fullscreen mode Exit fullscreen mode

此外,我还添加了一个<NoHydration>组件来隐藏这些 ID,这样我们就可以跳过在头部添加链接和样式表等资源的步骤。这些东西只需在服务器上运行,无需在浏览器中运行。

另外,在进行 Solid 与 Astro 的集成时,我添加了一种机制来为水合根设置前缀,以防止不相关岛屿的这些 ID 重复。

我之前从来没想过,我们可以把我们自己的 ID 作为前缀传入。而且由于它只会附加在末尾,所以我们可以从页面的任意位置开始对服务器渲染的 Solid 页面进行 hydrate 操作。这样,<NoHydration>我们就可以随时停止 hydrate 操作,将子页面隔离为仅服务器渲染。


混合路由

图片描述

尽管 Islands 和 Partial Hydration 有很多好处,但为了避免加载所有 JavaScript,您不需要在浏览器中加载这些代码。当您需要在客户端渲染页面时,您需要加载渲染下一个页面所需的所有代码。

虽然已经使用Turbo等技术来获取和替换 HTML,而无需完全重新加载页面,但人们注意到这常常让人感觉笨拙。

但不久前我们有个想法,可以采用嵌套路由,只替换 HTML 部分代码。早在三月份,Ryan Turnquist(Solid Router 的联合创始人)就制作了这个演示。虽然演示效果不太直观,但它证明了我们仅用 1.3kb 的 JavaScript 就能实现这种功能。

诀窍在于,通过点击事件的事件委托,我们可以在不刷新页面的情况下触发客户端路由器。之后,我们可以使用 AJAX 请求下一页并传递上一页,服务器会根据路由定义准确地知道需要渲染页面的哪些嵌套部分。客户端路由器可以使用返回的 HTML 来替换内容。


完成图片

最初的演示很粗糙,但展现了很大的潜力。它仍然存在服务器端内容的重复数据问题,这是我们需要在核心中解决的问题。因此,我们添加了检测功能,用于检测页面服务器端部分下创建了 Solid Resource 的情况。我们知道,如果触发数据获取的操作只能在服务器上进行,就无需将其全部序列化。Islands 已经对传入的 props 进行了序列化。

我们还借此机会创建了一种机制,通过hydrate调用传递反应上下文,从而允许上下文在由服务器内容分隔的岛屿之间的浏览器中工作。

有了这些,我们就可以进行递归 Hackernews 评论演示了:

但我们忽略了一件事。切换 HTML 对于新的导航来说当然没问题,但如果需要刷新页面的某个部分怎么办?你肯定不想丢失客户端状态、输入焦点等等……Nikhil 开发了一个可以实现这些功能的版本。但最终我们还是使用了Micromorph ,一个由 Nate Moore( Astro的开发者)编写的轻量级 DOM diff 工具

至此,我们完成了 Taste 电影应用 demo 的移植,其大小仅为 13kb,仅用 JS 编写。(感谢 Addy Osmani 的悉心指导,以及 Nikhil、David 和 Solid 社区几位成员(dev-rb、Muhammad Zaki、Paolo Ricciuti 等)的辛勤工作。)

搜索页面尤其展现了在不丢失客户端状态的情况下进行重新加载的功能。即使需要更新整个嵌套面板,输入内容也不会丢失焦点。

Solid Movies 演示版
以及Github上的演示

只是为了让你了解一下这有多小。这是在两个电影列表页面之间导航的全部 JavaScript,然后通过客户端路由从https://tastejs.com/movies/导航到各个框架中的电影。

框架 演示 尺寸
下一个 https://next-movies-zeta.vercel.app/ 190kb
Nuxt https://movies.nuxt.space/ 90.8kb
角度 https://angular-movies-a12d3.web.app/ 121kb
SvelteKit https://sveltekit-movies.netlify.app/ 34.8kb
点亮 https://lit-movies.netlify.app/ 108kb
SolidStart(实验性) https://solid-movies.app 13.2kb

注意:只有 Solid 演示使用了服务器渲染的部分内容,因此比较起来可能有点不对等。但重点是强调大小差异。其他框架也在开发类似的解决方案,例如Next中的 RSC 和Qwik中的容器,但这些是目前可用的演示。

Qwik 演示最初是其中的一部分,但他们从客户端导航(SPA)更改为服务器(MPA),这使得它不适合进行这种比较。


结论

我们用这种方式开发的应用越多,我对这项技术就越兴奋。它在各个方面都感觉像一个单页应用,但体积却小得多。说实话,每次打开“网络”标签页,我都会感到惊讶。

我们仍在努力将其从实验阶段中移除,并巩固 API。服务器渲染方面还有更多优化空间,但我们认为这已经具备了一种新型架构的所有条件。这非常棒。

在此关注此功能的进展

文章来源:https://dev.to/this-is-learning/client-side-routing-without-the-javascript-3k1i
PREV
征服 JavaScript Hydration
NEXT
构建 JavaScript 框架来征服电子商务