?未來(lái)全??蚣軙?huì)卷的方向
大家好,我卡頌。
從全球web發(fā)展角度看,框架競(jìng)爭(zhēng)已經(jīng)從第一階段的前端框架之爭(zhēng)(比如Vue、React、Angular等),過(guò)渡到第二階段的全??蚣苤疇?zhēng)(比如Next、Nuxt、Remix等)。
這里為什么說(shuō)全球,是因?yàn)閲?guó)內(nèi)web發(fā)展方向主要是更封閉的小程序生態(tài)
在第一階段的前端框架之爭(zhēng)中,不管爭(zhēng)論的主題是「性能」還是「使用體驗(yàn)」,最終都會(huì)落實(shí)到框架底層實(shí)現(xiàn)上。
不同框架底層實(shí)現(xiàn)的區(qū)別,可以概括為「更新粒度的區(qū)別」,比如:
- Svelte更新粒度最細(xì),粒度對(duì)應(yīng)到每個(gè)狀態(tài)
- Vue更新粒度中等,粒度對(duì)應(yīng)到每個(gè)組件
- React更新粒度最粗,粒度對(duì)應(yīng)到整個(gè)應(yīng)用
那么,進(jìn)入第二階段的全??蚣苤疇?zhēng)后,最終會(huì)落實(shí)到什么的競(jìng)爭(zhēng)上呢?
我認(rèn)為,會(huì)落實(shí)到「業(yè)務(wù)邏輯的拆分粒度」上,這也是各大全??蚣芪磥?lái)會(huì)卷的方向。
本文會(huì)從「實(shí)現(xiàn)原理」的角度聊聊業(yè)務(wù)邏輯的拆分粒度。
邏輯拆分意味著什么
「性能」永遠(yuǎn)是最硬核的指標(biāo)。在前端框架時(shí)期,性能通常指「前端的運(yùn)行時(shí)性能」。
為了優(yōu)化性能,框架們都在優(yōu)化各自的運(yùn)行時(shí)流程,比如:
- 更好的虛擬DOM算法。
- 更優(yōu)秀的AOT編譯時(shí)技術(shù)。
在web中,最基礎(chǔ),也是最重要的性能指標(biāo)之一是FCP(First Contentful Paint 首次內(nèi)容繪制),他測(cè)量了頁(yè)面從開(kāi)始加載到頁(yè)面內(nèi)容的任何部分在屏幕上完成渲染的時(shí)間。
對(duì)于傳統(tǒng)前端框架,由于渲染頁(yè)面需要完成4個(gè)步驟:
- 加載HTML。
- 加載框架運(yùn)行時(shí)代碼。
- 加載業(yè)務(wù)代碼。
- 渲染頁(yè)面(此時(shí)統(tǒng)計(jì)FCP)。
框架能夠優(yōu)化的,只有步驟2、3,所以FCP指標(biāo)不會(huì)特別好。
SSR的出現(xiàn)改善了這一情況。對(duì)于傳統(tǒng)的SSR,需要完成:
- 加載帶內(nèi)容的HTML(此時(shí)統(tǒng)計(jì)FCP)。
- 加載框架運(yùn)行時(shí)代碼。
- 加載業(yè)務(wù)代碼。
- hydrate頁(yè)面。
在第一步就能統(tǒng)計(jì)FCP,所以FCP指標(biāo)優(yōu)化空間更大。
除此之外,SSR還有其他優(yōu)勢(shì)(比如更好的SEO支持),這就是近幾年全??蚣苁⑿械囊淮笤?。
既然大家都是全??蚣埽遣煌蚣茉撊绾瓮怀鲎约旱奶攸c(diǎn)呢?
我們會(huì)發(fā)現(xiàn),在SSR場(chǎng)景下,業(yè)務(wù)代碼既可以寫(xiě)在前端,也能寫(xiě)在后端。按照業(yè)務(wù)代碼在后端的比例從0~100%來(lái)看:
- 0%邏輯在后端,對(duì)應(yīng)純前端框架渲染的應(yīng)用。
- 100%邏輯在后端,對(duì)應(yīng)PHP時(shí)代純后端渲染的頁(yè)面。
合理調(diào)整框架的這個(gè)比例,就能做到差異化競(jìng)爭(zhēng)。
按照這個(gè)思路改進(jìn)框架,就需要回答一個(gè)問(wèn)題:一段業(yè)務(wù)邏輯,到底應(yīng)該放在前端還是后端呢?
這就是本文開(kāi)篇說(shuō)的「邏輯拆分」問(wèn)題。我們可以用「邏輯拆分的粒度」區(qū)分不同的全??蚣堋?/p>
下述內(nèi)容參考了文章wtf-is-code-extraction。
粗粒度
在Next.js中,文件路徑與后端路由一一對(duì)應(yīng),比如文件路徑pages/posts/hello.tsx就對(duì)應(yīng)了路由http(s)://域名/posts/hello。
開(kāi)發(fā)者可以在hello.tsx文件中同時(shí)書(shū)寫(xiě)前端、后端邏輯,比如如下代碼中:
- Post組件對(duì)應(yīng)代碼會(huì)在前端執(zhí)行,用于渲染組件視圖。
- getStaticProps方法會(huì)在代碼編譯時(shí)在后端執(zhí)行,執(zhí)行的結(jié)果會(huì)在Post組件渲染時(shí)作為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>
);
}
通過(guò)以上方式,在同一個(gè)文件中(hello.tsx),就能拆分出前端邏輯(Post組件邏輯)與后端邏輯(getStaticProps方法)。
雖然以上方式可以分離前端/后端邏輯,但一個(gè)組件文件只能定義一個(gè)getStaticProps方法。
如果我們還想定義一個(gè)執(zhí)行時(shí)機(jī)類似getStaticProps的getXXXData方法,就不行了。
所以,通過(guò)這種方式拆分前/后端邏輯,屬于比較粗的粒度。
中粒度
我們可以在此基礎(chǔ)上修改,改變拆分的粒度。
首先,我們需要改變之前約定的「前/后端代碼拆分方式」,不再通過(guò)具體的方法名(比如getStaticProps)顯式拆分,而是按需拆分方法。
修改后的調(diào)用方式如下:
// 修改后的 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>
);
}
現(xiàn)在,我們可以增加多個(gè)后端方法了,比如下面的getXXXData:
export async function getXXXData() {
// ...省略
}
export default function Post() {
const postData = getStaticProps();
const xxxData = getXXXData();
// ...省略
}
但是,Post組件是在前端執(zhí)行,getStaticProps、getXXXData是后端方法,如果不做任何處理,這兩個(gè)方法會(huì)隨著Post組件代碼一起打包到前端bundle文件中,如何將他們分離開(kāi)呢?
這時(shí)候,我們需要借助編譯技術(shù),上述代碼經(jīng)編譯后會(huì)變?yōu)轭愃葡旅娴拇a:
// 編譯后代碼
/*#__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();
// ...省略
}
讓我們來(lái)解釋下其中的細(xì)節(jié)。
首先,這段編譯后代碼可以直接在后端執(zhí)行,執(zhí)行時(shí)會(huì)通過(guò)框架提供的SERVER_REGISTER方法注冊(cè)后端方法(比如ID為ID_1的getStaticProps)。
由于SERVER_REGISTER方法前加了/*#__PURE__*/標(biāo)記,這個(gè)文件在打包客戶端bundle時(shí),SERVER_REGISTER會(huì)被tree-shaking掉。
也就是說(shuō),打包后的客戶端代碼類似如下:
export const method1 = SERVER_PROXY('ID_1');
export const method2 = SERVER_PROXY('ID_2');
export const MyComponent = () => {
const postData = method1();
const xxxData = method2();
// ...省略
}
當(dāng)以上客戶端代碼執(zhí)行時(shí),在前端,SERVER_PROXY方法會(huì)根據(jù)id請(qǐng)求對(duì)應(yīng)的后端邏輯,比如:
- 發(fā)起id為ID_1的請(qǐng)求,后端會(huì)執(zhí)行g(shù)etStaticProps并返回結(jié)果。
- 發(fā)起id為ID_2的請(qǐng)求,后端會(huì)執(zhí)行g(shù)etXXXData并返回結(jié)果。
實(shí)際上,通過(guò)這種方式,可以將任何函數(shù)作用域內(nèi)的邏輯從前端移到后端。
比如在下面的代碼中,我們?cè)诎粹o的點(diǎn)擊回調(diào)中訪問(wèn)了數(shù)據(jù)庫(kù)并做后續(xù)處理:
export function Button() {
return (
<button onClick={async () => {
// 訪問(wèn)數(shù)據(jù)庫(kù)
const post = await db.posts.find('xxx');
// ...后續(xù)處理
}}>
請(qǐng)求數(shù)據(jù)
</button>
);
}
這個(gè)「按鈕點(diǎn)擊邏輯」顯然無(wú)法在前端執(zhí)行(前端不能直接訪問(wèn)數(shù)據(jù)庫(kù))。但我們可以通過(guò)上述方式將代碼編譯為下面的形式:
import {SERVER_REGISTER, SERVER_PROXY} from 'xxx-framework';
/*#__PURE__*/ SERVER_REGISTER('ID_123', () => {
// 訪問(wèn)數(shù)據(jù)庫(kù)
const post = await db.posts.find('xxx');
// ...后續(xù)處理
});
export function Button() {
return (
<button onClick={async () => {
await SERVER_PROXY('ID_123');
})}>
請(qǐng)求數(shù)據(jù)
</button>
);
}
編譯后的代碼可以在后端直接執(zhí)行(并訪問(wèn)數(shù)據(jù)庫(kù))。對(duì)于前端,我們?cè)俅虬粋€(gè)bundle
(tree-shaking
掉后端代碼),類似下面這樣:
import {SERVER_PROXY} from 'xxx-framework';
export function Button() {
return (
<button onClick={async () => {
await SERVER_PROXY('ID_123');
})}>
請(qǐng)求數(shù)據(jù)
</button>
);
}
相比于粗粒度的邏輯分離方式(文件級(jí)別粒度),這種方式的粒度更細(xì)(函數(shù)級(jí)別粒度)。
細(xì)粒度
中粒度的方式有個(gè)缺點(diǎn) —— 分離的方法中不能存在客戶端狀態(tài)。比如下面的例子,點(diǎn)擊回調(diào)依賴了id狀態(tài):
export function Button() {
const [id] = useStore();
return (
<button onClick={async () => {
const post = await db.posts.find(id);
// ...后續(xù)處理
}}>
click
</button>
);
}
如果遵循之前的分離方式,后端取不到id的值:
import {SERVER_REGISTER, SERVER_PROXY} from 'xxx-framework';
/*#__PURE__*/ SERVER_REGISTER('ID_123', () => {
// 獲取不到id的值
const post = await db.posts.find(id);
// ...后續(xù)處理
});
export function Button() {
const [id] = useStore();
return (
<button onClick={async () => {
await SERVER_PROXY('ID_123');
})}>
請(qǐng)求數(shù)據(jù)
</button>
);
}
為了解決這個(gè)問(wèn)題,我們需要進(jìn)一步降低邏輯分離的粒度,使粒度達(dá)到狀態(tài)級(jí)。
首先,相比于中粒度中將內(nèi)聯(lián)方法提取到模塊頂層(并標(biāo)記/*#__PURE__*/)的方式,我們可以將方法提取到新文件中。
對(duì)于如下代碼,如果想將onClick回調(diào)提取為后端方法:
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>
);
}
這種方式比中粒度中提到的分離方式更靈活,因?yàn)椋?/p>
- 省去了標(biāo)記/*#__PURE__*/。
- 省去了先在后端注冊(cè)方法(SERVER_REGISTER)。
當(dāng)考慮前端狀態(tài)時(shí),可以將狀態(tài)作為參數(shù)一并傳給SERVER_PROXY。
比如對(duì)于上面提過(guò)的代碼:
export function Button() {
const [id] = useStore();
return (
<button onClick={async () => {
const post = await db.posts.find(id);
// ...后續(xù)處理
}}>
click
</button>
);
}
會(huì)編譯為單獨(dú)的文件:
// hash1.js
import {lazyLexicalScope} from 'xxx-framework';
export const id1 = () => {
const [id] = lazyLexicalScope();
const post = await db.posts.find(id);
// ...后續(xù)處理
};
與前端代碼:
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]參數(shù)在后端方法中可以通過(guò)lazyLexicalScope方法獲取。
通過(guò)這種方式,可以做到狀態(tài)級(jí)別的邏輯分離。
總結(jié)
類似前端框架的更新粒度,全??蚣芤泊嬖诓煌6龋@就是邏輯分離粒度。
按照邏輯分離到后端的粒度劃分:
- 粗粒度:以文件作為前/后端邏輯分離的粒度,比如Next.js。
- 中粒度:以方法作為前/后端邏輯分離的粒度。
- 細(xì)粒度:以狀態(tài)作為前/后端邏輯分離的粒度,比如Qwik。
在粗粒度與中粒度之間,還存在一種方案 —— 將組件作為劃分粒度的單元,這就是React的Server Component。
「劃分粒度」的本質(zhì),也是性能的權(quán)衡 —— 如果將盡可能多的邏輯放到后端,那么前端頁(yè)面需要加載的JS代碼(邏輯對(duì)應(yīng)的代碼)就越少,那么前端花在加載JS資源上的時(shí)間就越少。
但是另一方面,如果劃分的粒度太細(xì)(比如中或細(xì)粒度),可能意味著:
- 更大的后端運(yùn)行時(shí)壓力(畢竟很多原本前端執(zhí)行的邏輯放到了后端)。
- 降低部分前端交互的響應(yīng)速度(有些前端交互還得先去后端請(qǐng)求回交互對(duì)應(yīng)代碼再執(zhí)行)。
所以,具體什么粒度才是最合適的,還有待開(kāi)發(fā)者與框架作者一起探索。
未來(lái),這也會(huì)是全棧框架一個(gè)主意的競(jìng)爭(zhēng)方向。