头图

大家好,我卡颂。

从全球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.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方法。

如果我们还想定义一个执行时机类似getStaticPropsgetXXXData方法,就不行了。

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

中粒度

我们可以在此基础上修改,改变拆分的粒度。

首先,我们需要改变之前约定的前/后端代码拆分方式,不再通过具体的方法名(比如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组件是在前端执行,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.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

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

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

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

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

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

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


卡颂
3k 声望16.7k 粉丝