原文: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
可能会导入仅限服务器端的代码,例如对数据库的调用。
但我们需要一种方法来断言正确的类型信息在 wellKnownLoader
和 MyComponent
之间流动,因此我们通常会写类似这样的代码:
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 来引用服务器上的代码,但在客户端丢失了代码。
函数提取
导出提取很不错;还有什么能比这更好呢?嗯,导出提取有两个限制:
- 它必须是一个众所周知的名字。
- 我们必须记得手动流动类型。
让我们深入探讨这两者的含义。
我们有一个众所周知的名称是一个问题,因为这意味着我们每个文件只能有一个服务器函数,而且只有框架能够调用那个函数。如果我们能在每个文件中有多个服务器函数,并且不仅限于服务器调用函数然后给我们数据,那不是很好吗?例如,能够从用户交互中调用服务器代码会很不错。(想想 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,因为所有服务器代码现在都包含在客户端中,客户端将尝试执行服务器代码,这将会引发错误。
那么我们如何将一些代码“标记”为“服务器”呢?
所以这个问题有两个部分:
- 标记“服务器”代码
- 将代码转换成可以将服务器代码与客户端代码分离的东西。
如果我们能将这个问题转化为之前的导出-提取问题,我们就知道如何分离服务器客户端代码。输入一个标记函数!
标记函数是一种允许我们标记代码片段以进行转换的函数。
让我们用 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 转换做了一些事情:
- 它将代码从
SERVER()
移到了一个新的顶级位置 - 它为每个
SERVER()
函数分配了一个唯一的 id。 - 移动的代码被包裹在
SERVER_REGISTER()
中。 SERVER_REGISTER()
获得了/*#__PURE__*/
注释。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>
);
}
需要注意的两点是:
- 当编译器提取闭包时,它会注意到闭包捕获了哪些变量。然后它通过插入
lazyLexicalScope()
给框架一个恢复这些变量的机会。 - 当编译器生成
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 不会发送给客户端,不过这是另一篇文章的内容了。
结论
我们已经在现有技术中通过导出提取模式,在单个文件中混合了服务器/客户端代码。但这些解决方案是有限的。新技术的前沿将允许你通过函数提取和闭包提取,进一步混合代码。这可以通过让开发者创建他们自己的标记函数,并以前所未有的方式利用代码分割来实现。