深入解讀新一代全棧框架 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
你可以關注 routes
和 islands
兩個目錄,[name].tsx
、api/joke.ts
和 index.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 的優勢可以總結如下:
-
享受 Deno 帶來的開發優勢,從安裝依賴、開發、測試、部署直接使用 Deno 的工具鏈,降低工程化的成本;
-
基於 Island 架構,帶來更小的客戶端運行時開銷,渲染性能更好;
-
無需打包即可開發、部署應用,帶來更少的構建成本,更加輕量;
而劣勢也比較明顯,包含如下的幾個方面:
-
僅支持 Preact 框架,不支持 React,這一點是比較致命的;
-
由於架構的原因,開發階段沒有 HMR 的能力,只能 page reload;
-
對於 Island 組件,必須要放到 islands 目錄,對於比較複雜的應用而言,心智負擔會比較重,而 Astro 在這一方面要做的更優雅一些,通過組件指令即可指定 island 組件,如
<Component client:load />
。
一方面 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 方法也是整個服務端的核心實現,其中有兩大主要的實現部分:
-
中間件機制的實現,也就是實現洋蔥模型,具體邏輯在私有方法
#composeMiddlewares
中; -
頁面渲染邏輯的實現,在私有方法
#handlers()
中。
前者不是本文的重點,感興趣的同學可以在看完文章後繼續研究。這裏我們主要關注頁面渲染的邏輯是如何實現的,#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
進一步閱讀,這個文件主要做了如下的事情:
-
記錄項目中聲明的所有 Islands 組件。
-
攔截 Preact 中 vnode 的創建邏輯,目的是爲了匹配之前記錄的 Island 組件,如果能匹配上,則記錄 Island 組件的 props 信息,並將組件用 的註釋標籤來包裹,id 值爲 Island 的 id,數字爲該 Island 的 props 在全局 props 列表中的位置,方便 hydrate 的時候能夠找到對應組件的 props。
-
調用 Preact 的 renderToString 方法將組件渲染爲 HTML 字符串。
-
向 HTML 中注入客戶端 hydrate 的邏輯。
-
拼接完整的 HTML,返回給前端。
值得注意的是客戶端 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