深入解讀新一代全棧框架 Fresh

大家好,我是三元。今天給大家介紹一個新的框架 Fresh,由 Deno 作者出品,在最近發佈了 1.0 的正式版本,宣佈支持了生產環境,並且在 Github 上熱度也比較高,現在是時候給大家詳細地介紹一下這個方案了。接下來會從框架定位上手體驗優劣勢評估源碼實現這幾個方面來給大家深入解讀 Fresh 框架。

框架定位

首先,從定位上來看,Fresh 屬於 Web 全棧開發框架。是不是對於這個詞非常眼熟呢?相信你已經想到了,像現在大名鼎鼎的 Next.js 以及新出的 Remix 都是走的這個路線。那麼作爲 Next.js 和 Remix 的競品, Fresh 有哪些值得一提的亮點,或者說有哪些差異點呢?主要包括如下的幾個方面:

首先,Fresh 基於 Deno 運行時,由 Deno 原班人馬開發,享有 Deno 一系列工具鏈和生態的優勢,比如內置的測試工具、支持 http import 等等。

其次是渲染性能方面,Fresh 整體採用 Islands SSR 架構 (之前介紹的 Astro 也是類似),實現了客戶端按需 Hydration,有一定的渲染性能優勢。

當然,還有一個比較出色的點是構建層做到了 Bundle-less,即應用代碼不需要打包即可直接部署上線,後文會介紹這部分的具體實現。

最後,不同於 Next.js 和 Remix,Fresh 的前端渲染層由 Preact 完成,包括 Islands 架構的實現也是基於 Preact,且不支持其它前端框架。

上手體驗

在使用 Fresh 之前,需要在機器上先安裝 Deno:

如何沒有安裝的話可以先去 Deno 官方安裝一下: https://deno.land/。

接下來可以輸入如下的命令初始化項目:

deno run -A -r https://fresh.deno.dev my-project

項目的工程化腳本在 deno.json 文件中:

{
  "tasks"{
    // -A 表示允許 Deno 讀取環境變量
    "start""deno run -A --watch=static/,routes/ dev.ts"
  },
  "importMap""./import_map.json"
}

接下來你可以執行deno task start 命令啓動項目:

終端裏面顯示 Fresh 從文件目錄中掃描出了 3 個路由和 1 個 island 組件,我們可以來觀察一下項目的目錄結構:

.
├── README.md
├── components
│   └── Button.tsx
├── deno.json
├── dev.ts
├── fresh.gen.ts
├── import_map.json
├── islands
│   └── Counter.tsx
├── main.ts
├── routes
│   ├── [name].tsx
│   ├── api
│   │   └── joke.ts
│   └── index.tsx
├── static
│   ├── favicon.ico
│   └── logo.svg
└── utils
    └── twind.ts

你可以關注 routesislands 兩個目錄,[name].tsxapi/joke.tsindex.tsx 分別對應三個路由,而 islands 目錄下的每個文件則對應一個 island 組件。

而開發者並不需要手寫路由文件,Fresh 可以自動地生成服務端的路由到文件的映射關係。很明顯 Fresh 實現了約定式路由的功能,跟 Next.js 類似。

每個 island 組件需要有一個 default 導出,用來將組件暴露出去,使用比較簡單,就不展開介紹了。而路由組件則更加靈活,既可以作爲一個 API 服務,也可以作爲一個組件進行渲染。接下來,我們以腳手架項目的幾個文件示例來分析一下。

首先是 api/joke.ts 文件,這個文件的作用是提供服務端的數據接口,並不承載任何的前端渲染邏輯,你只需要在這個文件裏面編寫一個 handler 函數即可,如下代碼所示:

// api/joke.ts
import { HandlerContext } from "$fresh/server.ts";

const JOKES = [
  // 省略具體內容
];

export const handler = (_req: Request, _ctx: HandlerContext)Response ={
  // 隨機返回一個 joke 字符串
  return new Response(body);
};

當你訪問/api/joke 路由時,可以拿到 handler 返回的數據:

接下來是index.tsx[name].tsx 兩個文件,第一個文件對應根路由即/,訪問效果如下:

後者則爲動態路由,可以拿到路由傳參進行渲染:

export default function Greet(props: PageProps) {
  return <div>Hello {props.params.name}</div>;
}

訪問效果如下:

同時,你也可以在路由組件同時編寫前端組件和 handler 函數,如下代碼所示:

// 修改 [name].tsx 的內容如下
/** @jsx h */
import { h } from "preact";
import { HandlerContext, PageProps } from "$fresh/server.ts";

export function handler(req: Request, ctx: HandlerContext) {
  const title = "一些標題數據";
  return ctx.render({ title });
}

export default function Greet(props: PageProps) {
  return <div>獲取數據: {props.data.title}</div>;
}

從 handler 的第二個參數 (ctx 對象) 中,我們可以取出 render 方法,傳入組件需要的數據,手動調用完成渲染。效果如下:

以上我們就體驗了 Fresh 的幾個核心的功能,包括項目初始化路由組件開發服務端接口開發組件數據獲取以及約定式路由,相信從中你也能體會到 Fresh 的簡單與強大了。

優劣勢分析

那麼,就如 Fresh 官網所說,Fresh 能否成爲下一代 Web 全棧框架呢?

我們不妨來盤點一下 Fresh 的優勢和不足。

使用 Fresh 的優勢可以總結如下:

而劣勢也比較明顯,包含如下的幾個方面:

一方面 Fresh 能解決的問題,如 Hydration 性能問題,其它的框架也能解決 (Astro),並且比它做的更好,另一方面 Fresh 的部分劣勢也比較致命,況且 Deno 如今也很難做到真正地普及,所以我認爲 Fresh 並不是一個未來能夠大範圍流行的 Web 框架,但對於 Deno 和 Preact 的用戶而言,我認爲 Fresh 足以撼動 Next.js 這類框架原有的地位。

源碼實現

Fresh 的內部實現並不算特別複雜,雖然說我們並一定用的上 Fresh,但我覺得 Fresh 的代碼還是值得一讀的,從中可以學習到不少東西。

Github 地址: https://github.com/denoland/fresh

你可以先去倉庫 examples/counter 查看示例項目,通過 deno task start 命令啓動。入口文件爲dev.ts,其中會調用 Fresh 進行路由文件和 islands 文件的蒐集,生成 Manifest 信息。

接下來進入核心環節——創建 Server,具體邏輯在server/mod.ts中:

export async function start(
  routes: Manifest,
  opts: StartOptions = {},
) {
  const ctx = await ServerContext.fromManifest(routes, opts);
  await serve(ctx.handler(), opts);
}

fromManifest爲一個工廠方法,目的是根據之前掃描到的 Manifest 信息生成服務端上下文對象 (ServerContext),因此 Server 的實現核心也就在於 ServerContext:

class ServerContext {
  static async fromManifest(
    manifest: Manifest,
    opts: FreshOptions,
  ) {
    // 省略中間的處理邏輯
    return new ServerContext()
  }
}

fromManifest 實際上就是進一步處理 (normalize) manifest 信息,生成 Route 對象和 Island 對象,以供 ServerContext 的實例初始化。

接下來,Fresh 會調用 ServerContext 的 handler 方法,交給標準庫 http/server 的 serve 方法進行調用。因此,handler 方法也是整個服務端的核心實現,其中有兩大主要的實現部分:

前者不是本文的重點,感興趣的同學可以在看完文章後繼續研究。這裏我們主要關注頁面渲染的邏輯是如何實現的,#handlers()方法中定義了幾乎所有路由的處理邏輯,包括路由組件渲染404 組件渲染Error 組件渲染靜態資源加載等等邏輯,我們可以把目光集中在路由組件渲染中,主要是這段邏輯:

for (const [method, handler] of Object.entries(route.handler)) {
  routes[`${method}@${route.pattern}`] = (req, ctx, params) =>
    handler(req, {
      ...ctx,
      params,
      render: createRender(req, params),
      renderNotFound: createUnknownRender(req, {}),
    });
}

而在路由對象normalize的過程 (即fromManifest 方法) 中,route.handler 的默認實現爲:

let { handler } = (module as RouteModule);
handler ??= {};
if (
  component &&
  typeof handler === "object" && handler.GET === undefined
) {
  // 劃重點!
  handler.GET = (_req, { render }) => render();
}
const route: Route = {
  pattern,
  url,
  name,
  component,
  handler,
  csp: Boolean(config?.csp ?? false),
};

因此,對於路由組件的處理最後都會進入 render 函數中,我們不妨來看看 render 函數是如何被創建的:

// 簡化後的代碼
const genRender = (route, status) ={
  return async (req, params, error) ={
    return async(data) ={
      // 執行渲染邏輯
      const resp = await internalRender();
      const [body] = resp;
      return new Response(body);
    }
  }
}
const createRender = genRender(route, Status.OK);

生成 render 函數這塊邏輯個人認爲比較抽象,需要靜下心來理清各個函數的調用順序,理解難度並不大。我們還是把關注點放到核心的渲染邏輯上,主要是 internalRender 函數的實現:

import { render as internalRender } from "./render.tsx";

你可以去 render.tsx 進一步閱讀,這個文件主要做了如下的事情:

值得注意的是客戶端 hydrate 方法的實現,傳統的 SSR 一般都是直接對根節點調用 hydrate,而在 Islands 架構中,Fresh 對每個 Island 進行獨立渲染,實現如下:

hydrate 方法名也可以叫 revive

export function revive(islands: Record<string, ComponentType>, props: any[]) {
  function walk(node: Node | null) {
    // 1. 獲取註釋節點信息,解析出 Island 的 id
    const tag = node!.nodeType === 8 &&
      ((node as Comment).data.match(/^\s*frsh-(.*)\s*$/) || [])[1];
    let endNode: Node | null = null;
    if (tag) {
      const startNode = node!;
      const children = [];
      const parent = node!.parentNode;
      // 拿到當前 Island 節點的所有子節點
      while ((node = node!.nextSibling) && node.nodeType !== 8) {
        children.push(node);
      }
      startNode.parentNode!.removeChild(startNode); // remove start tag node

      const [id, n] = tag.split(":");
      // 2. 單獨渲染 Island 組件
      render(
        h(islands[id], props[Number(n)]),
        htmlElement
      );
      endNode = node;
    }
    // 3. 繼續遍歷 DOM 樹,直到找到所有的 Island 節點
    const sib = node!.nextSibling;
    const fc = node!.firstChild;
    if (endNode) {
      endNode.parentNode?.removeChild(endNode); // remove end tag node
    }

    if (sib) walk(sib);
    if (fc) walk(fc);
  }
  walk(document.body);
}

至此,服務端和客戶端渲染的過程都完成了,回頭看整個過程,爲什麼說 Fresh 的構建過程是 Bundle-less 的呢?

我們不妨關注一下 Islands 組件是如何加載到客戶端的。

首先,服務端通過攔截 vnode 實現可以感知到項目中用到了哪些 Island 組件,比如 Counter 組件,那麼服務端就會注入對應的 import 代碼,並掛在到全局,通過 <script type="module"> 的方式注入到 HTML 中。

瀏覽器執行這些代碼時,會給服務端發起/islands/Counter的請求,服務端接收到請求,對 Counter 組件進行實時編譯打包,然後將結果返回給瀏覽器,這樣瀏覽器就能拿到 Esbuild 的編譯產物並執行了。

所以這個過程是完全發生在運行時的,也就是說,我們不需要在一開始啓動項目的時候就打包完所有的組件,而是在運行時做到按需構建,並且得益於 Esbuild 極快的構建速度,一般能達到毫秒級別的構建速度,對於服務來說運行時的壓力並不大。

小結

以上就是本文的全部內容,分別從框架定位上手體驗優劣勢評估源碼實現來介紹瞭如今比較火的 Fresh 框架。

最後需要跟大家說明的是,Fresh 中關於 Islands 架構的實現是基於 Preact 的,我本人也借鑑了 Fresh 的思路,通過攔截 React.createElement 方法在 React 當中也實現了 Islands 架構,代碼放在了 react-islands倉庫中 (地址: https://github.com/sanyuan0704/react-islands),代碼不多,相當於 Fresh 的簡化版,感興趣的小夥伴可以拉下來看看~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/8qNI4a-3P2KId9WRAnz2dw