译:什么叫踏马的代码提取

69 阅读14分钟

原文:WTF Is Code Extraction (builder.io)

作者:Miško Hevery

译者:Kimi Chat / DeepL

润色:Gadzan

我们是全栈开发者!这意味着我们编写客户端和服务器端代码。但是,我们应该在哪里放置服务器和客户端代码?传统智慧告诉我们,我们应该将它们放在不同的文件中。

然而,这并不简单;我们还有在服务器和客户端都运行的代码。毕竟,我们进行服务器端渲染(SSR),因此我们的大部分客户端代码也在服务器上运行。

我想挑战传统智慧,并说服你,将服务器和客户端代码放在一起的趋势是存在的,而且更好。让我们称之为:“代码共置”。

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

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

image.png

看看上面的 NextJS 代码。注意 getStaticProps 函数。它只在服务器上执行,而默认导出的组件在客户端(以及作为 SSR/SSG 的一部分的服务器上)执行。

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

NextJS 只是这样做的众多框架之一。大多数元框架都基于导出提取有一些机制。

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

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

将代码分离并创建服务器和客户端代码包的过程称为代码提取。

从最基本的到最高级的不同策略有三种:

  1. 导出提取
  2. 函数提取
  3. 闭包提取

让我们深入了解它们。

导出提取

导出提取是通过依赖于 bundle 树摇行为来从客户端 bundle 中移除服务器代码的一种方式。例如:

export const wellKnownName = () => {
  // 仅服务器端代码
  // 在客户端代码中没有任何对 `wellKnownName` 的引用。
}

export const ComponentA = () => {
  // 客户端代码 (有时运行在服务器端).
  // 这些代码是从代码库的其他位置导入的.
}

树摇器(tree-shaker)从根(您的应用程序的 main() 函数)开始,然后从该方法递归地遍历引用。任何可到达的东西都被放入 bundle 中。任何不可达的东西都被丢弃。

ComponentA 可以从 main() 方法中访问,因此保留在客户端 bundle 中。(如果不是,为什么它在您的代码库中?)

wellKnownName 则无法从 main() 方法中访问,因此从 bundle 中移除。我在这个例子中称之为 wellKnownName 的原因是导出的名称不是随意的。这是框架期望并可以使用反射(reflection)调用的名称,这就是为什么我们称之为 well-known-export。

译者注:

有一些特定的名称(如函数名、变量名等)是框架预设的,这些名称是框架设计时就预期存在的。这些名称被称为“well-known names”或“well-known exports”,因为它们是框架内部逻辑的一部分,框架需要能够识别并调用这些名称。

在反射(reflection)的上下文中,这意味着框架有能力在运行时检查代码的结构,包括类、方法、属性等的名称。通过反射,框架可以动态地找到这些预设的名称,并执行相应的操作,比如调用方法或获取属性值。这种能力使得框架能够更加灵活地与开发者的代码交互,而不需要开发者显式地编写调用框架功能的代码。

在代码提取的上下文中,使用反射来调用 well-known names 允许框架在不同的执行环境中(如服务器和客户端)正确地执行代码,而不需要开发者手动处理代码的分发和执行。这样,开发者可以专注于编写业务逻辑,而框架则负责处理代码的部署和执行细节。

所以,如果我们启用树摇来打包我们的代码库,我们最终会得到一个客户端 bundle。另一方面,如果我们禁用树摇来打包我们的代码库,我们最终会得到所有代码,然后在服务器上使用。

数据传递和类型问题

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

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

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

换句话说,现在的情况是这样的:

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

框架调用 wellKnownLoader 并将返回值传递给组件。需要理解的关键点是,我们不允许编写那段代码!如果你这样做了,就会迫使打包程序将 wellKnownLoader 包含在客户端 bundle 中,而这样做并不好,因为 wellKnownLoader 可能会导入服务器专用代码,例如对数据库的调用。

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

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

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

关键部分是 ReturnType。这允许我们引用 wellKnownLoader 的类型信息,而不需要引用 wellKnownLoader。什么?你看,TypeScript 首先运行,并且 TypeScript 抹去了所有类型引用。所以即使有对 wellKnownLoader 的类型引用,也没有值引用。这一点至关重要,因为它允许我们引用服务器类型,而不会导致 Bundler 包含服务器代码。

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

函数提取

导出提取(export extraction)很好;有什么比这更好的吗?嗯,导出提取有两个限制:

  1. 必须是 well-known name(众所周知的名称)。
  2. 我们必须手动传递类型。

让我们深入了解这两个的限制。

事实上,well-known name 就是个问题,因为这意味着每个文件只能有一个服务端函数,而且只有框架可以调用该函数。如果每个文件能有多个服务端函数,而不局限于只能由服务端调用函数,然后将数据提供给我们,岂不更好?例如,如果能从用户交互中调用服务器代码就更好了。(想想 RPC)。

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

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

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

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

export const myDataLoader = () => {
  // 服务端代码
  return dataFromDatabase();
}

export const MyComponent = () => {
  // 无需手动输入。这里出不了错
  const data = myDataLoader();

  return (
    <button onClick={() => {
        ((count) => {
          // 服务端代码
          updateDatabase(count)
        })(1); 
      }}>
      {data}
    </button>
  );
}

但是,这样做会破坏摇树器(tree-shaker),因为所有服务器代码现在都包含在客户端中,客户端会尝试执行服务器代码,这将导致崩溃。

译者注:

怎么理解呢?

假设你有一个文件 serverFunctions.js,其中包含了两个服务器端函数 getDataupdateData。在传统的导出提取策略中,你可能会有如下代码:

// serverFunctions.js
export const wellKnownName = () => {
 // 服务器端代码,例如从数据库获取数据
 return { data: "some data" };
};

export const ComponentA = () => {
 // 客户端代码,可能需要使用服务器端函数返回的数据
 const data = wellKnownName();
 return <div>{data}</div>;
};

在这个例子中,wellKnownName 是一个 well-known name,它允许框架知道这个函数是服务器端的,并且在构建时将其从客户端捆绑包中移除。然而,由于 wellKnownName 是唯一的,这个文件中只能有一个这样的函数。

现在,如果你想在同一个文件中添加另一个服务器端函数 updateData,并且希望框架也能够调用它,你会遇到问题。因为框架只能通过一个 well-known name 来调用服务器端函数,所以你需要为 updateData 创建一个新的 well-known name,比如 anotherWellKnownName。但是,这样会导致文件结构变得混乱,并且限制了开发者在同一个文件中组织和使用多个服务器端函数的能力。

此外,由于这些函数只能通过框架调用,开发者不能直接在客户端代码中调用它们,比如:

// 这将导致错误,因为 wellKnownName 是服务器端函数
const data = wellKnownName();

这会违反树摇器的规则,因为它试图将服务器端代码包含在客户端捆绑包中,这在客户端环境中是不可执行的。

这就是为什么文章中提到 well-known names 是一个问题,因为它限制了每个文件中服务器端函数的数量,并且这些函数只能通过框架调用,而不是由开发者的代码直接调用。

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

这个问题分为两个部分:

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

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

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

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

export const myDataLoader = SERVER(() => {
  // 服务端代码
  return dataFromDatabase();
});

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

  return (
    <button onClick={() => {
        SERVER((count) => {
          // 服务端代码
          updateDatabase(count)
        })(1); 
      }}>
      {data}
    </button>
  );
}

注意,我们将服务器端代码包装在了一个函数中。现在我们可以编写一个 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__*/ 进行树摇。

文件:hash123.js

import {invokeLazyCode} from 'someplace';

export const id456 = () => invokeLazyCode();

原始文件

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

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

通过这种设置,lazyProxy() 函数可以懒加载代码,因为树摇器没有扔掉它;相反,它只是将其放在了不同的文件中。现在,它取决于函数决定如何处理它。

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

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

闭包提取

好的,以上内容相当酷!它让你通过标记函数创造了一些出色的开发体验。那么,还有什么比这更好的呢?

好吧,这段代码将不起作用!

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$(() => invokeLayzCode(state)) 的问题是,它在创建闭包时捕获了 state。所以当它被提取到新文件中时,它会创建一个未解析的引用。

import {inovkeLayzCode} from 'someplace';

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

但别担心!这个问题也有解决方案。让我们生成这样的代码。

文件:hash123.js

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

export const id456 = () => {
  const [state] = lazyLexicalScope(); // <==== 重要部分
  invokeLazyCode(state);
}

原始文件

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

export function() {
  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> {
  // 构建时开关 
  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,建立在闭包提取之上的框架

到目前为止,我们已经展示了如何将简单的行为提取到服务器、闭包或懒加载。结果是相当强大的。现在,如果你可以将这个推向极致,从头开始构建一个框架,将这些想法融入到每一个地方呢?嗯,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>
    </>
  );
});

看看如何无缝地混合服务器和客户端代码,这都得益于代码提取。

秘密

显而易见的问题是,这不会容易泄露秘密吗?在当前状态下,确实会,但实际实现要复杂一些,以确保秘密不会被发送到客户端,但这将是另一篇文章的主题。

结论

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