译:代码提取

原文:https://www.builder.io/blog/wtf-is-code-extraction
作者:MIŠKO HEVERY
译者:ChatGPT 4 Turbo

编者注:一些收获,1)代码提取有三种等级,导出提取、函数提取和闭包提取,大部分应该只做到第一等级,包括 Umi,这篇文章真是相见恨晚,准备去翻下 Qwik City 的源码了,2)不清楚 Next.js 和 Remix 的方案是哪种,3)JavaScript 的语法太灵活,总感觉这个方案还是会遇到没考虑到的边界场景。

我们是全栈开发者!这意味着我们既编写客户端代码,也编写服务器端代码。但是我们应该把服务器和客户端的代码放在哪里呢?传统的观点认为我们应该将它们放在不同的文件中。

除了这些,情况并不简单;我们还有既在服务器上也在客户端上运行的代码。毕竟,我们进行服务器端渲染(SSR),所以我们大部分客户端代码也在服务器上运行。

我想挑战传统观念,并说服你相信将服务器和客户端代码放在一起是一个现有趋势,并且这样做更好。我们称之为:“代码共置”(code collocation)。

Next.js、Remix、SolidJS:这已经在发生了

将服务器和客户端代码放在一起的想法并不新鲜,这已经在发生了。

观察上面的 NextJS 代码。注意 getStaticProps 函数。 getStaticProps 仅在服务器上执行,而导出的 default 组件在客户端执行(作为 SSR/SSG 的一部分也在服务器上执行)。

因为大部分代码都在两个位置执行,我认为将其分离到不同文件中没有太大意义;相反,NextJS 在这里所做的提供了更好的开发者体验。

将服务器代码与客户端代码分离

NextJS 只是众多框架中的一个,大多数元框架都有基于 export 提取代码的机制。

但我们有一个问题需要解决。我们需要向服务器提供代码,同时也要向客户端提供代码,而目前服务器代码无法访问 DOM API,客户端代码也无法读取服务器的依赖项,如数据库。因此,需要有一种方法来分离这些代码。

代码分离并创建服务器和客户端代码包的行为称为代码提取。从最基础到高级的三种不同策略包括:

  • 导出提取
  • 函数提取
  • 闭包提取

我们来深入了解一下。

导出提取

导出提取是一种通过依赖包树摇动行为,从客户端包中移除服务器代码的方法。

export const wellKnownName = () => {
  // server-only code.
  // There is NO reference to `wellKnownName` anywhere
  // in the client code.
}

export const ComponentA = () => {
  // client (and sometimes server) code.
  // This code is imported from other locations in the codebase.
}

一个树摇器从根部(你的应用程序的 main() 函数)开始,然后递归地遍历从 main() 方法开始的引用。任何可达的内容都会被放入包中。任何不可达的内容都会被丢弃。

ComponentA 可以从 main() 方法访问,因此会保留在客户端包中。(如果不是这样,你为什么要在代码库中保留它呢?)

另一方面, wellKnownName 无法从 main() 访问,因此从包中移除。我在这个例子中将其命名为 wellKnownName 的原因是导出的名称并非随意的。这是框架预期的名称,并且可以使用反射调用它,这就是我们称之为众所周知的导出(well-known-export)的原因。

如果我们在打包代码库时开启了摇树优化,最终会得到一个客户端包。另一方面,如果我们在打包代码库时关闭了摇树优化,最终会包含所有随后在服务器上使用的代码。

数据传递和类型问题

常见的导出提取模式是框架使用它来为路由加载数据。服务器函数生成客户端组件消费的数据。因此,一个更贴近的例子看起来像这样:

export const wellKnownLoader = () => {
  return {data: "some-data"};
}

export const MyComponent = ({data}) => {
  return <span>{data}<span>
}

换句话说,正在发生的是这样的:

<MyComponent ...{wellKnownLoader()} />

框架调用 wellKnownLoader 并将返回值传递给组件。关键是要理解,你不允许编写那段代码!如果你这样做了,它会强制打包工具将 wellKnownLoader 包含在客户端包中,这是不好的,因为 wellKnownLoader 可能会导入仅限服务器端的代码,例如对数据库的调用。

但我们需要一种方法来断言正确的类型信息在 wellKnownLoaderMyComponent 之间流动,因此我们通常会写类似这样的代码:

export const wellKnownLoader = () => {
  return someData;
}

export const MyComponent = ({data}: ReturnType<typeof wellKnownLoader>) => {
  return <span>{data}<span>
}

关键部分是 ReturnType 。这使我们能够引用 wellKnownLoader 的类型信息,而不必引用 wellKnownLoader 。什么?你看,TypeScript 首先运行,并且 TypeScript 会擦除所有类型引用。所以,尽管有对 wellKnownLoader 的类型引用,但没有值引用。这一点至关重要,因为它允许我们引用服务器类型,而不会导致打包工具包含服务器代码。

总之,我们依赖于 well-known-exports 来引用服务器上的代码,但在客户端丢失了代码。

函数提取

导出提取很不错;还有什么能比这更好呢?嗯,导出提取有两个限制:

  1. 它必须是一个众所周知的名字。
  2. 我们必须记得手动流动类型。

让我们深入探讨这两者的含义。

我们有一个众所周知的名称是一个问题,因为这意味着我们每个文件只能有一个服务器函数,而且只有框架能够调用那个函数。如果我们能在每个文件中有多个服务器函数,并且不仅限于服务器调用函数然后给我们数据,那不是很好吗?例如,能够从用户交互中调用服务器代码会很不错。(想想 RPC

第二个问题是我们必须手动流转类型,理论上我们可能会传递错误的类型。没有什么能阻止我们这么做,就像这个例子中展示的那样。

export const wellKnownLoader = () => {
  return someData;
}

export const MyComponent = ({data}: WRONG_TYPE_HERE) => {
  return <span>{data}<span>
}

所以我们真正想要的是这个:

export const myDataLoader = () => {
  // SERVER CODE
  return dataFromDatabase();
}

export const MyComponent = () => {
  // No need to flow type manually. Can't get this wrong.
  const data = myDataLoader();

  return (
    <button onClick={() => {
        ((count) => {
          // SERVER CODE
          updateDatabase(count)
        })(1); 
      }}>
      {data}
    </button>
  );
}

除了这会破坏 tree-shaker,因为所有服务器代码现在都包含在客户端中,客户端将尝试执行服务器代码,这将会引发错误。

那么我们如何将一些代码“标记”为“服务器”呢?

所以这个问题有两个部分:

  1. 标记“服务器”代码
  2. 将代码转换成可以将服务器代码与客户端代码分离的东西。

如果我们能将这个问题转化为之前的导出-提取问题,我们就知道如何分离服务器客户端代码。输入一个标记函数!

标记函数是一种允许我们标记代码片段以进行转换的函数。

让我们用 SERVER() 作为标记函数重写上面的代码:

export const myDataLoader = SERVER(() => {
  // SERVER CODE
  return dataFromDatabase();
});

export const MyComponent = () => {
  const data = myDataLoader();

  return (
    <button onClick={() => {
        SERVER((count) => {
          // SERVER CODE
          updateDatabase(count)
        })(1); 
      }}>
      {data}
    </button>
  );
}

请注意,我们将服务器代码包装在 SERVER() 函数中。我们现在可以编写一个 AST 转换,它查找 SERVER() 函数,并将其翻译成类似这样的内容:

/*#__PURE__*/ SERVER_REGISTER("ID123", () => {
  return dataFromDatabase();
});
/*#__PURE__*/ SERVER_REGISTER("ID456", (count) => {
  updateDatabase(count);
});
export const myDataLoader = SERVER_PROXY("ID123");
export const MyComponent = () => {
  const data = myDataLoader();
  return (
    <button
      onClick={() => {
        SERVER_PROXY("ID456")(1);
      }}
    >
      {data}
    </button>
  );
};

我们的 AST 转换做了一些事情:

  1. 它将代码从 SERVER() 移到了一个新的顶级位置
  2. 它为每个 SERVER() 函数分配了一个唯一的 id。
  3. 移动的代码被包裹在 SERVER_REGISTER() 中。
  4. SERVER_REGISTER() 获得了 /*#__PURE__*/ 注释。
  5. SERVER() 标记已经转换为带有唯一 id 的 SERVER_PROXY()

那么我们来详细分析一下这个问题。

首先, /*#__PURE__*/ 注释至关重要,因为它告诉打包工具不要在客户端包中包含这段代码。这就是我们从客户端移除服务器代码的方式。

其次,AST 转换已将代码从内联位置移动到顶级位置,在那里它将受到树摇处理。

第三,我们已经使用 SERVER_REGISTER() 函数将移动函数注册到框架中。

我们允许框架提供一个 SERVER_PROXY() 功能,它允许通过某种形式的 RPC、fetch 等将客户端和服务器代码“桥接”起来。

哇!我们现在可以在客户端撒上服务器代码,并让正确的类型在我们的系统中流动。胜利!

嗯,我们可以做得更好。目前为止,我们已经将 AST 转换硬编码为仅识别 SERVER() 。如果我们能有一整套这些标记函数的词汇怎么样? worker()server()log() 等等?更好的是,如果开发者能创建他们自己的呢?所以我们需要一种方法,让任何函数都能触发转换。

比如 $ 后缀。如果任何带有 $ 后缀的函数(如 ___$() )都能触发上述 AST 并执行此类翻译呢?这里有一个带有 webWorker$() 的例子。

import {webWorker$} from 'my-cool-framework';

export function() {
  return (
    <button onClick={async () => {
      console.log(
          'browser', 
           await webWorker$(() => {
             console.log('web-worker');
             return 42;
           })
      );
    })}>
     click
   </button>
  );
}

将变成:

import {webWorkerProxy, webWorkerRegister} from 'my-cool-framework';

/*#__PURE__*/ webWorkerRegister('id123', () => {
  console.log('web-worker');
  return 42;
});

export function() {
  return (
    <button onClick={async () => {
              console.log('browser', await webWorkerProxy('id123'));
            })}>
     click
   </button>
  );
}

现在 AST 转换允许开发者创建任何标记函数,并让开发者为其赋予自己的含义。你所要做的就是导出 ___$___Register___Proxy 函数,你就可以创建自己的酷炫代码!一个在服务器上、在 web worker 上运行代码的标记函数,或者……想象一下可能性。

函数提取,第二部分

嗯,有一种可能性是行不通的。如果我们想要一个可以懒加载代码的函数该怎么办?

import {lazy$} from 'my-cool-framework';
import {inovkeLayzCode} from 'someplace';

export function() {
  return (
    <button onClick={async () => lazy$(() => invokeLazyCode())}>
     click
   </button>
  );
}

lazy$() 的问题在于它无法获取代码,因为树摇器已经将其丢弃了!所以我们需要的是一种稍微不同的策略。让我们重构代码,以便我们可以实现 lazy$() 。这将需要将我们的代码移动到另一个文件中进行懒加载,而不是将其标记为 /*#__PURE__*/ 进行 tree-shaking。

FILE: hash123.js

import { invokeLazyCode } from "someplace";
export const id456 = () => invokeLazyCode();

FILE: 原始文件

import {lazyProxy} from 'my-cool-framework';

export function() {
  return (
    <button onClick={async () => lazyProxy('./hash123.js', 'id456')}>
     click
   </button>
  );
}

通过这种设置, lazyProxy() 函数可以懒加载代码,因为树摇器没有将其丢弃;相反,它只是将其放在了另一个文件中。现在由该函数决定如何处理它。

这种方法的第二个好处是我们不再需要依赖 /*#__PURE__*/ 来丢弃我们的代码。我们将代码移动到一个不同的位置,并让代码决定是否应该将这段代码加载到当前运行时。

最后,我们不再需要 __Register() 功能,因为如果需要,运行时可以在服务器上决定并加载该功能。

闭包提取

好的,上面的确实很棒!它让你通过标记功能创造出一些超棒的 DX。那么还有什么能更好呢?

嗯,这段代码不会工作!

import {lazy$} from 'my-cool-framework';
import {invokeLazyCode} from 'someplace';

export function() {
  const [state] = useStore();
  return (
    <button onClick={async () => lazy$(() => invokeLazyCode(state))}>
     click
   </button>
  );
}

==问题在于 lazy$(() => invokeLazyCode(state)) 封闭了 state 。所以当它被提取到一个新文件时,它会创建一个未解决的引用。==

import {inovkeLayzCode} from 'someplace';

export id234 = () => invokeLazyCode(state); // ERROR: `state` undefined

但不要害怕!这也有解决办法。我们来这样生成代码吧。

FILE: hash123.js

import {invokeLazyCode} from 'someplace';
import {lazyLexicalScope} from 'my-cool-framework';

export const id456 = () => {
  const [state] = lazyLexicalScope(); // <==== IMPORTANT BIT
  invokeLazyCode(state);
}

FILE: 原始文件

import {lazyProxy} from 'my-cool-framework';

export function() {
  const [state] = useStore();
  return (
    <button onClick={async () => lazyProxy('./hash123.js', 'id456', [state])}>
     click
   </button>
  );
}

需要注意的两点是:

  1. 当编译器提取闭包时,它会注意到闭包捕获了哪些变量。然后它通过插入 lazyLexicalScope() 给框架一个恢复这些变量的机会。
  2. 当编译器生成 lazyProxy() 调用时,它按照如下顺序插入缺失的变量 [state]

上述两个更改允许底层框架将封闭的变量编组到新位置。换句话说,我们现在可以懒加载闭包了!🤯(如果你的脑袋没有 🤯,那么你可能没有认真看!)

创建你自己的标记函数

假设我们想要实现 lazy$() ,需要做些什么呢?嗯,出乎意料的是,很少。

export function lazy$<ARGS extends Array<unknown>, RET>(
  fn: (...args: ARGS) => RET
): (...args: ARGS) => Promise<RET> {
  return async (...args) => fn.apply(null, args);
}

let _lexicalScope: Array<unknown> = [];
export function lazyLexicalScope<SCOPE extends Array<unknown>>(): SCOPE {
  return _lexicalScope as SCOPE;
}

export function lazyProxy<
  ARGS extends Array<unknown>,
  RET,
  SCOPE extends Array<unknown>
>(
  file: string,
  symbolName: string,
  lexicalScope: SCOPE
): (...args: ARGS) => Promise<RET> {
  return async (...args) => {
    const module = await import(file);
    const ref = module[symbolName];
    let previousLexicalScope = _lexicalScope;
    try {
      _lexicalScope = lexicalScope;
      return ref.apply(null, args);
    } finally {
      _lexicalScope = previousLexicalScope;
    }
  };
}

好的,那么 server$() 能够在服务器上调用代码怎么样?

export function server$<ARGS extends Array<unknown>, RET>(
  fn: (...args: ARGS) => RET
): (...args: ARGS) => Promise<RET> {
  return async (...args) => fn.apply(null, args);
}

let _lexicalScope: Array<unknown> = [];
export function serverLexicalScope<SCOPE extends Array<unknown>>(): SCOPE {
  return _lexicalScope as SCOPE;
}

export function serverProxy<
  ARGS extends Array<unknown>,
  RET,
  SCOPE extends Array<unknown>
>(
  file: string,
  symbolName: string,
  lexicalScope: SCOPE
): (...args: ARGS) => Promise<RET> {
  // BUILD TIME SWITCH  
  return import.meta.SERVER ? serverImpl : clientImpl;
}

function serverImpl<
  ARGS extends Array<unknown>,
  RET,
  SCOPE extends Array<unknown>
>(
  file: string,
  symbolName: string,
  lexicalScope: SCOPE
): (...args: ARGS) => Promise<RET> {
  return async (...args) => {
    const module = await import(file);
    const ref = module[symbolName];
    let previousLexicalScope = _lexicalScope;
    try {
      _lexicalScope = lexicalScope;
      return ref.apply(null, args);
    } finally {
      _lexicalScope = previousLexicalScope;
    }
  };
}

function clientImpl<
  ARGS extends Array<unknown>,
  RET,
  SCOPE extends Array<unknown>
>(
  file: string,
  symbolName: string,
  lexicalScope: SCOPE
): (...args: ARGS) => Promise<RET> {
  return async (...args) => {
    const res = await fetch("/api/" + file + "/" + symbolName, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        args,
        lexicalScope,
      }),
    });
    return res.json();      
  };

}

相当给力!

到目前为止,我们已经向您展示了如何将简单的行为提取到服务器、闭包或懒加载中。结果非常强大。现在,如果您能够将这些概念发挥到极致,并从头开始构建一个框架,将这些理念融入到每一个部分,会怎样呢?Qwik 就是这样一个框架,它在懒加载、懒执行以及服务器/客户端代码的混合方面允许一些惊人的事情。看看这个例子:

import { component$ } from "@builder.io/qwik";
import { routeLoader$ } from "@builder.io/qwik-city";
import { server$ } from "./temp";

export const useMyData = routeLoader$(() => {
  // ALWAYS RUNS ON SERVER
  console.log("SERVER", "fetch data");
  return { msg: "hello world" };
});

export default component$(() => {
  // RUNS ON SERVER OR CLIENT AS NEEDED
  const data = useMyData();
  return (
    <>
      <div>{data.value.msg}</div>
      <button
        onClick$={async () => {
          // RUNS ALWAYS ON CLIENT
          const timestamp = Date.now();
          const value = await server$(() => {
            // ALWAYS RUNS ON SERVER
            console.log("SERVER", timestamp);
            return "OK";
          });
          console.log("CLIENT", value);
        }}
      >
        click
      </button>
    </>
  );
});

看看将服务器和客户端代码混合起来是多么无缝,这都要归功于代码提取。

Secrets

显而易见的问题是,这样做难道不容易泄露 secrets 吗?在当前状态下,是的,但实际的实现要复杂一些,以确保 secrets 不会发送给客户端,不过这是另一篇文章的内容了。

结论

我们已经在现有技术中通过导出提取模式,在单个文件中混合了服务器/客户端代码。但这些解决方案是有限的。新技术的前沿将允许你通过函数提取和闭包提取,进一步混合代码。这可以通过让开发者创建他们自己的标记函数,并以前所未有的方式利用代码分割来实现。