?未來全棧框架會卷的方向
大家好,我卡頌。
從全球web發展角度看,框架競爭已經從第一階段的前端框架之爭(比如Vue、React、Angular等),過渡到第二階段的全棧框架之爭(比如Next、Nuxt、Remix等)。
這里為什么說全球,是因為國內web發展方向主要是更封閉的小程序生態
在第一階段的前端框架之爭中,不管爭論的主題是「性能」還是「使用體驗」,最終都會落實到框架底層實現上。
不同框架底層實現的區別,可以概括為「更新粒度的區別」,比如:
- Svelte更新粒度最細,粒度對應到每個狀態
- Vue更新粒度中等,粒度對應到每個組件
- React更新粒度最粗,粒度對應到整個應用
那么,進入第二階段的全棧框架之爭后,最終會落實到什么的競爭上呢?
我認為,會落實到「業務邏輯的拆分粒度」上,這也是各大全棧框架未來會卷的方向。
本文會從「實現原理」的角度聊聊業務邏輯的拆分粒度。
邏輯拆分意味著什么
「性能」永遠是最硬核的指標。在前端框架時期,性能通常指「前端的運行時性能」。
為了優化性能,框架們都在優化各自的運行時流程,比如:
- 更好的虛擬DOM算法。
- 更優秀的AOT編譯時技術。
在web中,最基礎,也是最重要的性能指標之一是FCP(First Contentful Paint 首次內容繪制),他測量了頁面從開始加載到頁面內容的任何部分在屏幕上完成渲染的時間。
對于傳統前端框架,由于渲染頁面需要完成4個步驟:
- 加載HTML。
- 加載框架運行時代碼。
- 加載業務代碼。
- 渲染頁面(此時統計FCP)。
框架能夠優化的,只有步驟2、3,所以FCP指標不會特別好。
SSR的出現改善了這一情況。對于傳統的SSR,需要完成:
- 加載帶內容的HTML(此時統計FCP)。
- 加載框架運行時代碼。
- 加載業務代碼。
- hydrate頁面。
在第一步就能統計FCP,所以FCP指標優化空間更大。
除此之外,SSR還有其他優勢(比如更好的SEO支持),這就是近幾年全棧框架盛行的一大原因。
既然大家都是全棧框架,那不同框架該如何突出自己的特點呢?
我們會發現,在SSR場景下,業務代碼既可以寫在前端,也能寫在后端。按照業務代碼在后端的比例從0~100%來看:
- 0%邏輯在后端,對應純前端框架渲染的應用。
- 100%邏輯在后端,對應PHP時代純后端渲染的頁面。
合理調整框架的這個比例,就能做到差異化競爭。
按照這個思路改進框架,就需要回答一個問題:一段業務邏輯,到底應該放在前端還是后端呢?
這就是本文開篇說的「邏輯拆分」問題。我們可以用「邏輯拆分的粒度」區分不同的全棧框架。
下述內容參考了文章wtf-is-code-extraction。
粗粒度
在Next.js中,文件路徑與后端路由一一對應,比如文件路徑pages/posts/hello.tsx就對應了路由http(s)://域名/posts/hello。
開發者可以在hello.tsx文件中同時書寫前端、后端邏輯,比如如下代碼中:
- Post組件對應代碼會在前端執行,用于渲染組件視圖。
- getStaticProps方法會在代碼編譯時在后端執行,執行的結果會在Post組件渲染時作為props傳遞給它。
// hello.tsx
export async function getStaticProps() {
const postData = await getPostData();
return {
props: {
postData,
},
};
}
export default function Post({ postData }) {
return (
<Layout>
{postData.title}
<br />
{postData.id}
<br />
{postData.date}
</Layout>
);
}
通過以上方式,在同一個文件中(hello.tsx),就能拆分出前端邏輯(Post組件邏輯)與后端邏輯(getStaticProps方法)。
雖然以上方式可以分離前端/后端邏輯,但一個組件文件只能定義一個getStaticProps方法。
如果我們還想定義一個執行時機類似getStaticProps的getXXXData方法,就不行了。
所以,通過這種方式拆分前/后端邏輯,屬于比較粗的粒度。
中粒度
我們可以在此基礎上修改,改變拆分的粒度。
首先,我們需要改變之前約定的「前/后端代碼拆分方式」,不再通過具體的方法名(比如getStaticProps)顯式拆分,而是按需拆分方法。
修改后的調用方式如下:
// 修改后的 hello.tsx
export async function getStaticProps() {
const postData = await getPostData();
return {
props: {
postData,
},
};
}
export default function Post() {
const postData = getStaticProps();
return (
<Layout>
{postData.title}
<br />
{postData.id}
<br />
{postData.date}
</Layout>
);
}
現在,我們可以增加多個后端方法了,比如下面的getXXXData:
export async function getXXXData() {
// ...省略
}
export default function Post() {
const postData = getStaticProps();
const xxxData = getXXXData();
// ...省略
}
但是,Post組件是在前端執行,getStaticProps、getXXXData是后端方法,如果不做任何處理,這兩個方法會隨著Post組件代碼一起打包到前端bundle文件中,如何將他們分離開呢?
這時候,我們需要借助編譯技術,上述代碼經編譯后會變為類似下面的代碼:
// 編譯后代碼
/*#__PURE__*/ SERVER_REGISTER('ID_1', getStaticProps);
/*#__PURE__*/ SERVER_REGISTER('ID_2', getXXXData);
export const method1 = SERVER_PROXY('ID_1');
export const method2 = SERVER_PROXY('ID_2');
export const MyComponent = () => {
const postData = method1();
const xxxData = method2();
// ...省略
}
讓我們來解釋下其中的細節。
首先,這段編譯后代碼可以直接在后端執行,執行時會通過框架提供的SERVER_REGISTER方法注冊后端方法(比如ID為ID_1的getStaticProps)。
由于SERVER_REGISTER方法前加了/*#__PURE__*/標記,這個文件在打包客戶端bundle時,SERVER_REGISTER會被tree-shaking掉。
也就是說,打包后的客戶端代碼類似如下:
export const method1 = SERVER_PROXY('ID_1');
export const method2 = SERVER_PROXY('ID_2');
export const MyComponent = () => {
const postData = method1();
const xxxData = method2();
// ...省略
}
當以上客戶端代碼執行時,在前端,SERVER_PROXY方法會根據id請求對應的后端邏輯,比如:
- 發起id為ID_1的請求,后端會執行getStaticProps并返回結果。
- 發起id為ID_2的請求,后端會執行getXXXData并返回結果。
實際上,通過這種方式,可以將任何函數作用域內的邏輯從前端移到后端。
比如在下面的代碼中,我們在按鈕的點擊回調中訪問了數據庫并做后續處理:
export function Button() {
return (
<button onClick={async () => {
// 訪問數據庫
const post = await db.posts.find('xxx');
// ...后續處理
}}>
請求數據
</button>
);
}
這個「按鈕點擊邏輯」顯然無法在前端執行(前端不能直接訪問數據庫)。但我們可以通過上述方式將代碼編譯為下面的形式:
import {SERVER_REGISTER, SERVER_PROXY} from 'xxx-framework';
/*#__PURE__*/ SERVER_REGISTER('ID_123', () => {
// 訪問數據庫
const post = await db.posts.find('xxx');
// ...后續處理
});
export function Button() {
return (
<button onClick={async () => {
await SERVER_PROXY('ID_123');
})}>
請求數據
</button>
);
}
編譯后的代碼可以在后端直接執行(并訪問數據庫)。對于前端,我們再打包一個bundle
(tree-shaking
掉后端代碼),類似下面這樣:
import {SERVER_PROXY} from 'xxx-framework';
export function Button() {
return (
<button onClick={async () => {
await SERVER_PROXY('ID_123');
})}>
請求數據
</button>
);
}
相比于粗粒度的邏輯分離方式(文件級別粒度),這種方式的粒度更細(函數級別粒度)。
細粒度
中粒度的方式有個缺點 —— 分離的方法中不能存在客戶端狀態。比如下面的例子,點擊回調依賴了id狀態:
export function Button() {
const [id] = useStore();
return (
<button onClick={async () => {
const post = await db.posts.find(id);
// ...后續處理
}}>
click
</button>
);
}
如果遵循之前的分離方式,后端取不到id的值:
import {SERVER_REGISTER, SERVER_PROXY} from 'xxx-framework';
/*#__PURE__*/ SERVER_REGISTER('ID_123', () => {
// 獲取不到id的值
const post = await db.posts.find(id);
// ...后續處理
});
export function Button() {
const [id] = useStore();
return (
<button onClick={async () => {
await SERVER_PROXY('ID_123');
})}>
請求數據
</button>
);
}
為了解決這個問題,我們需要進一步降低邏輯分離的粒度,使粒度達到狀態級。
首先,相比于中粒度中將內聯方法提取到模塊頂層(并標記/*#__PURE__*/)的方式,我們可以將方法提取到新文件中。
對于如下代碼,如果想將onClick回調提取為后端方法:
import {callXXX} from 'xxx';
export function() {
return (
<button onClick={() => callXXX()}>
click
</button>
);
}
可以將其提取到新文件中:
// hash1.js
import {callXXX} from 'xxx';
export const id1 = () => callXXX();
原文件則編譯為:
import {SERVER_PROXY} from 'xxx-framework';
export function() {
return (
<button onClick={async () => SERVER_PROXY('./hash1.js', 'id1')}>
click
</button>
);
}
這種方式比中粒度中提到的分離方式更靈活,因為:
- 省去了標記/*#__PURE__*/。
- 省去了先在后端注冊方法(SERVER_REGISTER)。
當考慮前端狀態時,可以將狀態作為參數一并傳給SERVER_PROXY。
比如對于上面提過的代碼:
export function Button() {
const [id] = useStore();
return (
<button onClick={async () => {
const post = await db.posts.find(id);
// ...后續處理
}}>
click
</button>
);
}
會編譯為單獨的文件:
// hash1.js
import {lazyLexicalScope} from 'xxx-framework';
export const id1 = () => {
const [id] = lazyLexicalScope();
const post = await db.posts.find(id);
// ...后續處理
};
與前端代碼:
import {SERVER_PROXY} from 'xxx-framework';
export function Button() {
const [id] = useStore();
return (
<button onClick={async () => SERVER_PROXY('./hash1.js', 'id1', [id])}>
click
</button>
);
}
其中前端傳入的[id]參數在后端方法中可以通過lazyLexicalScope方法獲取。
通過這種方式,可以做到狀態級別的邏輯分離。
總結
類似前端框架的更新粒度,全棧框架也存在不同粒度,這就是邏輯分離粒度。
按照邏輯分離到后端的粒度劃分:
- 粗粒度:以文件作為前/后端邏輯分離的粒度,比如Next.js。
- 中粒度:以方法作為前/后端邏輯分離的粒度。
- 細粒度:以狀態作為前/后端邏輯分離的粒度,比如Qwik。
在粗粒度與中粒度之間,還存在一種方案 —— 將組件作為劃分粒度的單元,這就是React的Server Component。
「劃分粒度」的本質,也是性能的權衡 —— 如果將盡可能多的邏輯放到后端,那么前端頁面需要加載的JS代碼(邏輯對應的代碼)就越少,那么前端花在加載JS資源上的時間就越少。
但是另一方面,如果劃分的粒度太細(比如中或細粒度),可能意味著:
- 更大的后端運行時壓力(畢竟很多原本前端執行的邏輯放到了后端)。
- 降低部分前端交互的響應速度(有些前端交互還得先去后端請求回交互對應代碼再執行)。
所以,具體什么粒度才是最合適的,還有待開發者與框架作者一起探索。
未來,這也會是全棧框架一個主意的競爭方向。