共计 5838 个字符,预计需要花费 15 分钟才能阅读完成。
大家好,我卡颂。
从寰球 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
资源上的工夫就越少。
然而另一方面,如果划分的粒度太细(比方中或细粒度),可能意味着:
- 更大的后端运行时压力(毕竟很多本来前端执行的逻辑放到了后端)
- 升高局部前端交互的响应速度(有些前端交互还得先去后端申请回交互对应代码再执行)
所以,具体什么粒度才是最合适的,还有待开发者与框架作者一起摸索。
将来,这也会是全栈框架一个主见的竞争方向。