大家好,我卡颂。

从寰球web倒退角度看,框架竞争曾经从第一阶段的前端框架之争(比方VueReactAngular等),过渡到第二阶段的全栈框架之争(比方NextNuxtRemix等)。

这里为什么说寰球,是因为国内web倒退方向次要是更关闭的小程序生态

在第一阶段的前端框架之争中,不论争执的主题是性能还是应用体验,最终都会落实到框架底层实现上。

不同框架底层实现的区别,能够概括为更新粒度的区别,比方:

  • Svelte更新粒度最细,粒度对应到每个状态
  • Vue更新粒度中等,粒度对应到每个组件
  • React更新粒度最粗,粒度对应到整个利用

那么,进入第二阶段的全栈框架之争后,最终会落实到什么的竞争上呢?

我认为,会落实到业务逻辑的拆分粒度上,这也是各大全栈框架将来会卷的方向。

本文会从实现原理的角度聊聊业务逻辑的拆分粒度。

欢送退出人类高质量前端交换群,带飞

逻辑拆分意味着什么

性能永远是最硬核的指标。在前端框架期间,性能通常指前端的运行时性能

为了优化性能,框架们都在优化各自的运行时流程,比方:

  • 更好的虚构DOM算法
  • 更优良的AOT编译时技术

web中,最根底,也是最重要的性能指标之一是FCP(First Contentful Paint 首次内容绘制),他测量了页面从开始加载到页面内容的任何局部在屏幕上实现渲染的工夫。

对于传统前端框架,因为渲染页面须要实现4个步骤:

  1. 加载HTML
  2. 加载框架运行时代码
  3. 加载业务代码
  4. 渲染页面(此时统计FCP

框架可能优化的,只有步骤2、3,所以FCP指标不会特地好。

SSR的呈现改善了这一状况。对于传统的SSR,须要实现:

  1. 加载带内容的HTML(此时统计FCP
  2. 加载框架运行时代码
  3. 加载业务代码
  4. 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.tsxexport 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办法。

如果咱们还想定义一个执行机会相似getStaticPropsgetXXXData办法,就不行了。

所以,通过这种形式拆分前/后端逻辑,属于比拟粗的粒度。

中粒度

咱们能够在此基础上批改,扭转拆分的粒度。

首先,咱们须要扭转之前约定的前/后端代码拆分形式,不再通过具体的办法名(比方getStaticProps)显式拆分,而是按需拆分办法。

批改后的调用形式如下:

// 批改后的 hello.tsxexport 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组件是在前端执行,getStaticPropsgetXXXData是后端办法,如果不做任何解决,这两个办法会随着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办法注册后端办法(比方IDID_1getStaticProps)。

因为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申请对应的后端逻辑,比方:

  • 发动idID_1的申请,后端会执行getStaticProps并返回后果
  • 发动idID_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>  );}

编译后的代码能够在后端间接执行(并拜访数据库)。对于前端,咱们再打包一个bundletree-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.jsimport {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.jsimport {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

在粗粒度与中粒度之间,还存在一种计划 —— 将组件作为划分粒度的单元,这就是ReactServer Component

划分粒度的实质,也是性能的衡量 —— 如果将尽可能多的逻辑放到后端,那么前端页面须要加载的JS代码(逻辑对应的代码)就越少,那么前端花在加载JS资源上的工夫就越少。

然而另一方面,如果划分的粒度太细(比方中或细粒度),可能意味着:

  • 更大的后端运行时压力(毕竟很多本来前端执行的逻辑放到了后端)
  • 升高局部前端交互的响应速度(有些前端交互还得先去后端申请回交互对应代码再执行)

所以,具体什么粒度才是最合适的,还有待开发者与框架作者一起摸索。

将来,这也会是全栈框架一个主见的竞争方向。