一直想写篇文章来详细讲解下Deno的Fresh框架,但是没有想到合适的角度,单纯地讲API没意思,还不如直接看官方文档。想了想,不如用Fresh重构下这个博客,通过前后对比,或许更能方便理解掌握其原理与核心概念。
Fresh介绍
之前在《官网技术选型与性能优化:探索Islands架构与Qwik的奥秘》这篇文章里已经提过Fresh,它是Deno的一个全栈框架,核心是路由框架和模板引擎的组合,可在服务器上按需呈现页面。
对我们而言,Fresh框架有以下几个优点:
- 无构建步骤。得益于Esbuild的高性能,可以毫秒级转义TSX,不需要预先构建步骤。
- islands架构。对岛屿架构不了解的,可以看上面的文章与这篇《islands(岛屿)简介》。
- 零运行时开销。默认情况下没有JS发送到前端。只有需要交互时(即用到island),前端才会用到JS。
- 开箱即用的 TypeScript。
- Preact非常小巧,只有3KB,而且加入了Signals,可以在不引入状态库的情况下跨组件共享状态。
- TWind(在JS中使用Tailwind CSS的一种解决方案)在关键CSS的提取上更有天然的优势,非常适合官网、博客这种场景。
创建工程
使用deno run -A -r https://fresh.deno.dev
创建,当前Fresh版本为1.4.2。创建过程中,有个选项是否要开启Tailwind CSS,我们暂时先不开启,而是优先实现功能。
bash
$ deno run -A -r https://fresh.deno.dev
🍋 Fresh: The next-gen web framework.
Project Name [fresh-project] fresh_blog
Let's set up your new Fresh project.
Fresh has built in support for styling using Tailwind CSS. Do you want to use this? [y/N] N
Do you use VS Code? [y/N] y
The manifest has been generated for 5 routes and 1 islands.
Project initialized!
Enter your project directory using cd fresh_blog.
Run deno task start to start the project. CTRL-C to stop.
Stuck? Join our Discord https://discord.gg/deno
Happy hacking! 🦕
工程目录: 打开deno.json:
json
{
"lock": false,
"tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"start": "deno run -A --watch=static/,routes/ dev.ts",
"build": "deno run -A dev.ts build",
"preview": "deno run -A main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
},
"lint": {
"rules": {
"tags": [
"fresh",
"recommended"
]
},
"exclude": [
"_fresh"
]
},
"fmt": {
"exclude": [
"_fresh"
]
},
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.4.2/",
"preact": "https://esm.sh/preact@10.15.1",
"preact/": "https://esm.sh/preact@10.15.1/",
"preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.2.1",
"@preact/signals": "https://esm.sh/*@preact/signals@1.1.3",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.2.3",
"twind": "https://esm.sh/twind@0.16.19",
"twind/": "https://esm.sh/twind@0.16.19/",
"$std/": "https://deno.land/std@0.193.0/"
},
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}
运行deno task start
: 打开页面: 将代码提交到Git。
代码与文件介绍
dev.ts相关文件
dev.ts是开发阶段的启动文件:
typescript
#!/usr/bin/env -S deno run -A --watch=static/,routes/
import dev from "$fresh/dev.ts";
import config from "./fresh.config.ts";
await dev(import.meta.url, "./main.ts", config);
fresh.config.ts是Fresh框架的配置文件,里面可以添加插件。
typescript
import { defineConfig } from "$fresh/server.ts";
export default defineConfig({});
以下是开启了Twind插件的情况(Twind是与Tailwind.css完全兼容的动态运行方案,下篇会专门介绍):
typescript
import { defineConfig } from "$fresh/server.ts";
import twindPlugin from "$fresh/plugins/twind.ts"
import twindConfig from "./twind.config.ts";
export default defineConfig({
plugins: [twindPlugin(twindConfig)]
});
再看下dev的具体函数,我加了简单的注释,并不复杂,核心思想就是先做开发阶段的检查,再生成的新的fresh.gen.ts文件:
typescript
/**
* 开发模式下的函数
* @param base - 基础路径
* @param entrypoint - 入口点路径
* @param options - 选项
*/
export async function dev(
base: string,
entrypoint: string,
options: FreshOptions = {},
) {
// 确保Deno版本
ensureMinDenoVersion();
// 在后台运行更新检查
updateCheck(DAY).catch(() => {});
// 将 entrypoint 转换为绝对路径
entrypoint = new URL(entrypoint, base).href;
// 获取 base 的目录路径
const dir = dirname(fromFileUrl(base));
let currentManifest: Manifest;
const prevManifest = Deno.env.get("FRSH_DEV_PREVIOUS_MANIFEST");
if (prevManifest) {
currentManifest = JSON.parse(prevManifest);
} else {
currentManifest = { islands: [], routes: [] };
}
// 收集新的清单
const newManifest = await collect(dir);
// 将新的清单保存到环境变量中
Deno.env.set("FRSH_DEV_PREVIOUS_MANIFEST", JSON.stringify(newManifest));
// 检查清单是否发生变化
const manifestChanged =
!arraysEqual(newManifest.routes, currentManifest.routes) ||
!arraysEqual(newManifest.islands, currentManifest.islands);
if (manifestChanged) {
await generate(dir, newManifest); // 生成fresh.gen.ts文件
}
if (Deno.args.includes("build")) {
// 这个判断是Fresh 1.4版本加的,旨在预构建出编译后的JS文件,免去开发阶段再实时编译,是deno task build命令要使用的:deno run -A dev.ts build
// 其实我认为放这里不合适,应该拆出一个单独的build函数,语义上更清晰些
await build(join(dir, "fresh.gen.ts"), options);
} else {
await import(entrypoint); // 引入整个程序的入口文件,其实就是main.ts,代表整个程序启动了
}
}
我们再看下fresh.gen.ts,它引入了所有的routes(路由)和islands(岛屿)文件。每次routes和islands有新的文件生成或删除或重命名,这个文件都可能会更新,所以这个文件不要修改:
typescript
// DO NOT EDIT. This file is generated by Fresh.
// This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`.
import * as $0 from "./routes/_404.tsx";
import * as $1 from "./routes/_app.tsx";
import * as $2 from "./routes/api/joke.ts";
import * as $3 from "./routes/greet/[name].tsx";
import * as $4 from "./routes/index.tsx";
import * as $$0 from "./islands/Counter.tsx";
const manifest = {
routes: {
"./routes/_404.tsx": $0,
"./routes/_app.tsx": $1,
"./routes/api/joke.ts": $2,
"./routes/greet/[name].tsx": $3,
"./routes/index.tsx": $4,
},
islands: {
"./islands/Counter.tsx": $$0,
},
baseUrl: import.meta.url,
};
export default manifest;
最后一段的import(entrypoint)
就是main.ts,这就把一个服务启动了:
typescript
/// <reference no-default-lib="true" />
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="dom.asynciterable" />
/// <reference lib="deno.ns" />
import "$std/dotenv/load.ts";
import { start } from "$fresh/server.ts";
import manifest from "./fresh.gen.ts";
import config from "./fresh.config.ts";
await start(manifest, config);
build
Fresh 1.4加入了build,这里重点介绍下。 Fresh的一大卖点是没有构建,一方面是因为Deno底层对TS、TSX的支持,不需要像Next.js、NestJS一样预构建,另一方面esbuild对目标文件的高性能编译。
那么为什么1.4版本起又加入了build呢?
Fresh官方文档《Ahead-of-time Builds》是这么说的:
Fresh使您能够在部署代码之前预先优化前端资产。在此过程中,Islands的代码将被压缩和优化,以便Fresh可以向浏览器发送尽可能少的代码。根据岛屿所需的代码量,如果在动态服务器端完成,此过程可能需要几秒钟时间。
我认为并不准确。我在实践中其实遇到了这个问题,但原因不是esbuild编译引发的,要达到编译几秒钟的话,岛屿代码得是一种相当的数量级才有可能遇到(我没遇到过),更多一种可能是Fresh本身的动态加载引起的网络延时。
这是Fresh框架中src/server/context.ts的收集入口代码:
typescript
function collectEntrypoints(
dev: boolean,
islands: Island[],
plugins: Plugin[],
): Record<string, string> {
const entrypointBase = "../runtime/entrypoints";
const entryPoints: Record<string, string> = {
main: dev
? import.meta.resolve(`${entrypointBase}/main_dev.ts`)
: import.meta.resolve(`${entrypointBase}/main.ts`),
deserializer: import.meta.resolve(`${entrypointBase}/deserializer.ts`),
};
try {
import.meta.resolve("@preact/signals");
entryPoints.signals = import.meta.resolve(`${entrypointBase}/signals.ts`);
} catch {
// @preact/signals is not in the import map
}
for (const island of islands) {
entryPoints[`island-${island.id}`] = island.url;
}
for (const plugin of plugins) {
for (const [name, url] of Object.entries(plugin.entrypoints ?? {})) {
entryPoints[`plugin-${plugin.name}-${name}`] = url;
}
}
return entryPoints;
}
这些entryPoints是为esbuild构建islands使用的:
typescript
this.#builder = snapshot ?? new EsbuildBuilder({
buildID: BUILD_ID,
entrypoints: collectEntrypoints(this.#dev, this.#islands, this.#plugins),
configPath,
dev: this.#dev,
jsxConfig,
});
服务启动前,我们会在镜像中预先执行deno cache main.ts
,将相关的依赖都下载到本地,而这几个动态的线上文件就是漏网之鱼。试想下,当线上服务启动后,esbuild需要临时下载这几个文件,怎么会快得了?
所以在Fresh合并这个pull request前,我一般是有个骚操作,在main.ts中手动引入这几个文件,这当然不是个优雅的方案。
不过,光这几个文件的下载,在Fresh的官方测试中也不可能达到几秒钟的损耗,因为他们的CDN不像我们国内不稳定,我认为大头是esbuild初始化的耗时。这是Fresh的初始化esbuild的代码:
typescript
async function initEsbuild() {
// deno-lint-ignore no-deprecated-deno-api
if (Deno.run === undefined) {
await esbuild.initialize({
wasmURL: esbuildWasmURL,
worker: false,
});
} else {
await esbuild.initialize({});
}
}
Deno.run什么时候会为undefined?这是在Deno官方的Deploy服务平台中运行时,为了安全,做了类似于Docker的服务隔离,esbuild必须用WebAssembly版本,大概有10.9M,这个步骤我认为耗时更长。我们生产用的Docker,我会在Dockerfile时显式执行这个初始化,将esbuild需要的可执行文件提前下载下来。
现在添加了build之后,则生产阶段并不需要用到esbuild了,而是直接使用构建后的产物,把这两步与网络相关的问题都解决了。
以下是deno task build
后的产物_fresh
文件夹:
我们使用deno task preview
来启动生产的服务,在控制台就看不到Deno临时download的文件了:
routes
_
开头的tsx文件是内置的特定的文件,比如_404.tsx
是找不到路由时404错误响应的内容:
而_app.tsx
是每个路由都会运行的父组件:
tsx
import { AppProps } from "$fresh/server.ts";
export default function App({ Component }: AppProps) {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>deno_fresh_blog</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<Component />
</body>
</html>
);
}
如果开启了TWind,默认是没有styles.css这个文件的。
index.tsx就比较正常了,是路由/
的入口文件;greet/[name].tsx
是动态路由,风格与Nextjs的路由是一致的,典型的约定式路由,详见concepts/routing:
File name | Route pattern | Matching paths |
---|---|---|
index.ts | / | / |
about.ts | /about | /about |
blog/index.ts | /blog | /blog |
blog/[slug].ts | /blog/:slug | /blog/foo, /blog/bar |
blog/[slug]/comments.ts | /blog/:slug/comments | /blog/foo/comments |
old/[...path].ts | /old/:path* | /old/foo, /old/bar/baz |
islands与components
简单说下islands与components的区别。
二者都是前端传统意义上的组件。Fresh的前端代码使用Preact框架(只有3KB),它们也都是Preact的组件。
区别也很简单,components的代码只会运行在服务端,而islands则是需要与浏览器交互的代码,会分别在服务端和浏览器端运行。或者更简单理解的话,凡是与Preact状态相关的代码,都要放在islands下面。
以islands/Counter.tsx为例:
tsx
import type { Signal } from "@preact/signals";
import { Button } from "../components/Button.tsx";
interface CounterProps {
count: Signal<number>;
}
export default function Counter(props: CounterProps) {
return (
<div class="flex gap-8 py-6">
<Button onClick={() => props.count.value -= 1}>-1</Button>
<p class="text-3xl">{props.count}</p>
<Button onClick={() => props.count.value += 1}>+1</Button>
</div>
);
}
它里面有个点击事件,改变的是props.count的值。
Fresh为了最大化地优化代码,如果某个页面没有islands,则只会响应静态页面,不会注入与框架相关的JS代码进行水合作用。
在网络里能看到许多JS文件:
如果你把islands/Counter.tsx复制到components下,再修改routes/index.tsx的引用,你会发现少了许多内容: 这时页面上怎么点击按钮都不会有反应。
其实Fresh在1.2版本中做过一次优化,islands也能传递Preact的signals了,以前useSignal只能放islands下,因为它无法序列化:
tsx
import { useSignal } from "@preact/signals";
export default function MyIsland() {
const count = useSignal(0);
return (
<div>
Counter is at {count}.{" "}
<button onClick={() => (count.value += 1)}>+</button>
</div>
);
}
而现在routes/index.tsx可以直接使用useSignal:
tsx
import { useSignal } from "@preact/signals";
import Counter from "../islands/Counter.tsx";
export default function Home() {
const count = useSignal(3);
return (
<div class="px-4 py-8 mx-auto bg-[#86efac]">
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
...
<Counter count={count} />
</div>
</div>
);
}
static
就像前端工程的静态文件通常放在public下面,Fresh的静态文件放在static下面,代码中路径在/
下:
jsx
<img
class="my-6"
src="/logo.svg"
width="128"
height="128"
alt="the Fresh logo: a sliced lemon dripping with juice"
/>
高分是怎么得来的
打开Fresh的官网fresh.deno.dev/,在无痕模式下使用Lighthouse测试性能得分,会看到一个恐怖的分数:
也就是说,除了PWA外,Fresh拿到了大满贯的高分。
如果你看过我之前的文章《Web性能优化》,应该知道常见的优化手段无非就是网络层面的传输优化和资源优先级的调整:
Fresh官网之所以这么快,我们对号入座来看下:
网络
CDN
Fresh是Deno亲儿子(核心团队成员开发的),官网fresh.deno.dev/看后缀deno.dev
就知道它是使用Deno Deploy部署的,后者宣称『是个分布式系统,允许你在全球范围内靠近用户的边缘运行 JavaScript、TypeScript 和 WebAssembly』,这不就是个CDN吗?像共享单车一样,解决了Web网站最后一公里的问题。
压缩
代码本身的压缩自不必多说。
随便找一个JS文件,看响应标头中的Content-Encoding为br,它就是我们说的Google主推的比gzip更有效的压缩。
它是Deno根据请求头Accept-Encoding的值(gzip, deflate, br)自动选择的。
缓存
还是上面的截图,我们看到这个JS来自内存缓存,也就是强缓存。
再看Cache-Control的响应标头,它的值为public, max-age=604800, immutable
。这个是个标准的强缓存设置。
- "public": 表示响应可以被任何中间缓存(如代理服务器)缓存,并且可以被多个用户共享。
- "max-age=604800": 表示响应在被缓存之后可以保持有效的时间,以秒为单位。在这个例子中,604800 秒等于一周的时间。
- "immutable": 表示响应的内容是不可变的,即在缓存有效期内不会发生变化。这个指令可以帮助提高缓存的效率,因为缓存可以安全地存储响应,并且不需要重新验证是否过期。
因为这个JS文件的请求路径中是带hash的,Fresh重新上线部署后,这个hash都会发生变化,所以可以放心大胆地将它的缓存有效期设置的长久。
再看一个协商缓存: 这个logo文件,就是放在static目录下的,与前端工程一样,这个目录下的文件只是简单地被静态代理,没有进行任何加工,由于路径中没有hash,所以Fresh针对它的策略也容易理解,每次请求都进行协商,判断是否有更新。
HTTP2/3
Fresh是个服务端渲染的框架,并不会像前端那样进行打包,即使现在加入了build预构建,它与前端的打包仍然大大不同。它只是借助esbuild预先编译(还有压缩)了TSX为JS文件,并没有将这些文件进行合并、重新分包,因为代码合并在HTTP1.1时代非常有效,而HTTP2时代则没有那么明显,islands本身就是非常细致的可复用模块单元,足以抵消这部分差距。
时至今日,HTTP2已是新兴网站的主流。
资源优先级
按需加载
按需加载体现在Fresh中有两点:
- islands架构。上面样例中已经说过,在当前页面没有用到islands时,甚至一个JS都不会加载,俨然一个纯静态的HTML;
- TWind。Fresh只会注入当前页面相关的CSS,直接解决了关键CSS的提取难题。
预加载
我们来看下渲染出来的HTML结构:
<link rel="modulepreload">
用于预加载JavaScript模块。它告诉浏览器在页面加载期间提前下载指定的模块文件,以便在后续使用时能够更快地加载和执行。它的功能与preload是一样的,只不过它是针对module scripts的。
延迟加载
页面最下方才是要加载的JS代码:
我把这行整理下格式:
typescript
const ST = document.getElementById("__FRSH_STATE").textContent;
const STATE = JSON.parse(ST).v;
import p0 from "/_frsh/js/30f6c694a16b12b2ef3fbaf7d026e2dfb30e9852/plugin-twind-main.js";
p0(STATE[1][0]);
import { revive } from "/_frsh/js/30f6c694a16b12b2ef3fbaf7d026e2dfb30e9852/main.js";
import * as LemonDrop_default from "/_frsh/js/30f6c694a16b12b2ef3fbaf7d026e2dfb30e9852/island-lemondrop_default.js";
import * as CopyArea_default from "/_frsh/js/30f6c694a16b12b2ef3fbaf7d026e2dfb30e9852/island-copyarea_default.js";
import * as Counter_default from "/_frsh/js/30f6c694a16b12b2ef3fbaf7d026e2dfb30e9852/island-counter_default.js";
revive({lemondrop_default:LemonDrop_default,copyarea_default:CopyArea_default,counter_default:Counter_default,}, STATE[0]);
也就是直到页面渲染完毕,Fresh的水合作用才开始。
revive函数就是Fresh框架的核心代码:
typescript
export function revive(
islands: Record<string, Record<string, ComponentType>>,
// deno-lint-ignore no-explicit-any
props: any[],
) {
_walkInner(
islands,
props,
// markerstack
[],
// Keep a root node in the vnode stack to save a couple of checks
// later during iteration
[h(Fragment, null)],
document.body,
);
}
_walkInner的代码比较多,我就不贴了(有兴趣的请看原始代码),功能是『复活岛屿并将任何服务器渲染的内容拼接在一起』。
什么意思呢?
__FRSH_STATE
的内容就是Preact的状态相关的数据,这段代码查找到HTML中的注释节点(相当于岛屿的托槽),将岛屿的Preact状态、事件等恢复,完成水合作用(注水复活)。
扯远了。
由于是ESM的模块化加载,相当于script标签中加了defer,再加上这段代码还放在了Body的底部,所以并不会阻塞页面渲染。
小结
Fresh应用了各项Web优化策略,在自家网站上得到了相当的高分。这也意味着我们使用Fresh开发后,网站应该也能取得不错的成绩。
总结
本文使用Fresh创建了一个新的工程,对各个文件的作用进行了介绍。更多细节建议查看《Fresh官方文档》。另外,还讲解了Fresh官网使用Lighthouse测试高分的原因。
在下一篇里,我肯定不会再把原来的博客流程和所有代码水一遍,那样太没节操了,我会说下重构有哪些坑要踩,就像大象装进冰箱需要3步,咦,好像也没什么可说的。。。