前方高能预警,本文干货较多,有些长。
目录结构调整
从上节我们可以看到,所有文件都在根目录下,这样不太符合我们的开发习惯,我们通常是有src目录的:
这个调整也简单,把业务代码都移动到src目录下,根目录只保留配置文件:
最终的项目结构大概如下:
.
|-- README.md
|-- config.yaml
|-- deno.json
|-- fresh.config.ts
|-- logs
| `-- deno.2023-08-26.log
|-- img
| |-- 0Cas6d2fX7zrJT9UQ_95U.png
| |-- MpE7_INejhZkR4cdHJTOW.png
| |-- f87682d694857ac1714eae683e2ab83fc17312e3.jpeg
| `-- yM1GY8_LhkGmuuU9VV4xe.jpeg
|-- src
| |-- components
| | |-- Comments.tsx
| | |-- Head.tsx
| | |-- Nav.tsx
| | |-- NavSetting.tsx
| | |-- Notification.tsx
| | |-- PostContent.tsx
| | |-- Posts.tsx
| | |-- SigninForm.tsx
| | |-- SignupForm.tsx
| | `-- posts
| | |-- Create.tsx
| | `-- Edit.tsx
| |-- dev.ts
| |-- fresh.gen.ts
| |-- globals.ts
| |-- islands
| |-- main.ts
| |-- modules
| | |-- comments
| | | |-- comments.dto.ts
| | | |-- comments.schema.ts
| | | `-- comments.service.ts
| | |-- posts
| | | |-- posts.dto.ts
| | | |-- posts.schema.ts
| | | `-- posts.service.ts
| | |-- session
| | | |-- session.interface.ts
| | | |-- session.middleware.ts
| | | |-- session.schema.ts
| | | `-- session.service.ts
| | `-- user
| | |-- user.dto.ts
| | |-- user.schema.ts
| | `-- user.service.ts
| |-- routes
| | |-- _404.tsx
| | |-- _app.tsx
| | |-- _middleware.ts
| | |-- index.ts
| | |-- posts
| | | |-- [id]
| | | | |-- comment
| | | | | `-- [commentId].ts
| | | | |-- comment.ts
| | | | |-- edit.tsx
| | | | `-- remove.ts
| | | |-- [id].tsx
| | | `-- create.tsx
| | |-- posts.tsx
| | |-- signin.tsx
| | |-- signout.ts
| | `-- signup.tsx
| |-- static
| | |-- favicon.ico
| | |-- footer.js
| | |-- logo.svg
| | `-- styles.css
| `-- tools
| |-- db.ts
| |-- log.ts
| `-- utils.ts
`-- twind.config.ts
17 directories, 59 files
修改deno.json文件,将路径都添加上src:
修改后为:
"tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"start": "deno run -A --check --watch=src/static/,src/routes/ src/dev.ts",
"build": "deno run -A src/dev.ts build",
"preview": "deno run -A src/main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
},
EJS -> Preact
我们的模板代码不需要重写,只是模板文件从EJS换成了Preact的TSX。
看过我这篇《官网技术选型与性能优化:探索Islands架构与Qwik的奥秘》的读者应该知道,我们当时官网从Node.js迁移到Deno后,并没有充分利用到Fresh的能力,旧的页面还是用的EJS模板渲染,原因是改造成本太高了,旧的EJS模板数量将近100个,我又没有找到一个现成的转换到Preact的工具(手撸了一个,太拉垮,只能转换一些简单的规则,要完整实现一个太麻烦了),只能放弃。
而ChatGPT的出现,无形中解决了这个问题,这种机械性的工作交给它万无一失。这里再次推荐下BITO(详见我这篇《暂时免费的ChatGPT4工具推荐:Bito AI》),薅羊毛薅了几个月后,现在开始收费了,不过GPT3.5是永久免费的,不用fq就能用,对于这个功能来说是完全足够了:
这是GPT的回答:
当然,这是一个简单的转换,你也可以将CSS代码发给它,将对应的class也替换为Tailwind.css。鉴于我们这个工程用的是semantic.css,比较复杂,还不如重写样式,就不处理了。
以通知为例,转换后的Notification.tsx为:
interface NotificationProps {
success?: string;
error?: string;
}
const Notification = ({ success, error }: NotificationProps) => {
return (
<div class="ui grid">
<div class="four wide column"></div>
<div class="eight wide column">
{!!success && (
<div class="ui success message">
<p>{success}</p>
</div>
)}
{!!error && (
<div class="ui error message">
<p>{error}</p>
</div>
)}
</div>
</div>
);
};
export default Notification;
路由
上篇说过,Fresh的路由仿照的Next.js,是约定路由,以目录结构、名称来表达路由路径。
这是改造后的目录结构:
src/routes
|-- _404.tsx
|-- _app.tsx
|-- _middleware.ts
|-- index.ts
|-- posts
| |-- [id]
| | |-- comment
| | | `-- [commentId].ts
| | |-- comment.ts
| | |-- edit.tsx
| | `-- remove.ts
| |-- [id].tsx
| `-- create.tsx
|-- posts.tsx
|-- signin.tsx
|-- signout.ts
`-- signup.tsx
3 directories, 14 files
其中,.tsx结尾的代表页面,而.ts则是纯粹的接口(当然,你也可以写作.tsx,只是这样区分更明朗些)。
_404.tsx
_404.tsx不必多说,是未找到的路由默认会响应的页面。
如果某个路由想要响应这个页面,只需要返回ctx.renderNotFound即可:
export default async function MyPage(req: Request, ctx: RouteContext) {
const value = await loadFooValue();
if (value === null) {
return ctx.renderNotFound();
}
// 其它代码
}
除了404状态码,Fresh还内置了500状态码的页面,也就是routes/_500.tsx文件,如果想要设置样式,添加这个文件即可:
import { ErrorPageProps } from "$fresh/server.ts";
export default function Error500Page({ error }: ErrorPageProps) {
return <p>500 internal error: {(error as Error).message}</p>;
}
_app.tsx
_app.tsx是默认各个页面的父页面,也就是将各个页面共有的部分放在这里。详见《App wrapper》。
对我们而言,公共的有全局的CSS、JS和头部组件、通知:
import Head from "@/components/Head.tsx";
import { defineApp } from "$fresh/server.ts";
import Notification from "@/components/Notification.tsx";
import globals from "@/globals.ts";
import { State } from "@/modules/session/session.middleware.ts";
// deno-lint-ignore require-await
export default defineApp<State>(async (_req, ctx) => {
const title = globals.meta.title;
const description = globals.meta.description;
const session = ctx.state.session;
const { user, success, error } = session || {};
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fresh_blog</title>
<link rel="stylesheet" href="https://cdn.uino.cn/deno/semantic.css" />
<link rel="stylesheet" href="/styles.css" />
<script src="https://cdn.bootcss.com/jquery/1.11.3/jquery.min.js" defer>
</script>
<script
src="https://cdn.bootcss.com/semantic-ui/2.1.8/semantic.min.js"
defer
>
</script>
<script src="/footer.js" defer></script>
</head>
<body>
<Head title={title} description={description} user={user} />
<Notification success={success} error={error} />
<ctx.Component />
</body>
</html>
);
});
看得出来,这几个JS、CSS的引入与Fresh的islands架构的优势是背道而驰的,所以最佳方案是使用TWind进行重构,去掉jQuery与相关的CSS。
如果某个页面想跳过_app.tsx的包裹,比如_404.tsx现在是这样的:
只需要加入一个配置:
import { RouteConfig } from "$fresh/server.ts";
export const config: RouteConfig = {
skipAppWrapper: true, // Skip the app wrapper during rendering
};
就可以跳过啦:
接口响应
Fresh是根据每个路由export的handler来定义接口,要求响应是个Response。
import { HandlerContext, Handlers } from "$fresh/server.ts";
export const handler: Handlers = {
GET(_req: Request, _ctx: HandlerContext) {
return new Response("Hello World");
},
};
接口除了GET外,其它的几个类型也是支持的:
export type KnownMethod = typeof knownMethods[number];
export const knownMethods = [
"GET",
"HEAD",
"POST",
"PUT",
"DELETE",
"OPTIONS",
"PATCH",
] as const;
重定向
如果想要重定向,除了原生地在headers中添加location外,还可以使用Response.redirect。
每次Form提交后相当于一次页面跳转,如果接口失败,需要跳转回去,在原来的oak框架中,可以使用res.redirect(REDIRECT_BACK),而这可不是原生的Response方法,于是我封装了一个toBack(好吧,我承认我扒了它的源码,借鉴了下),其实就是跳转到headers中的Referer:
export function toPage(req: Request, page: `/${string}`, status?: number) {
return Response.redirect(getRedirectPath(req.url, page), status);
}
export function toBack(req: Request) {
const referer = req.headers.get("Referer");
if (referer) {
return Response.redirect(referer);
} else {
return toHome(req);
}
}
定制路由
Fresh的路由除了默认的路径约定路由外,还可以额外定制。
比如routes/posts.tsx,正常来说它代表的url是http://localhost:8000/posts,像http://localhost:8000/posts/abcd是不支持的,你必须新建一个posts文件夹,里面放abcd.tsx文件才可以。
这种情况,可以添加一个routeOverride:
import { RouteConfig } from "$fresh/server.ts";
export const config: RouteConfig = {
routeOverride: "/posts/abcd",
};
这时,只会匹配/posts/abcd,匹配不了/posts了。
也可以使用正则:
export const config: RouteConfig = {
routeOverride: "/posts/:path*",
};
这时可以同时匹配/posts/abcd和/posts。
再复杂点,/x/:module@:version/:path*可匹配/x/bestModule@1.33.7/asdf。如果使用context中的params:
export const handler = {
GET(_req: Request, { params }: HandlerContext) {
console.log(params);
return new Response(params.path);
},
};
能看到打印结果:
{
"module": "bestModule",
"version": "1.33.7",
"path": "asdf"
}
官方文档给了个更牛逼的样例:
export const config: RouteConfig = {
routeOverride: "/api/db/:resource(jobs?|bar)/:id(\\d+)?",
};
它能匹配的路径有:
- /api/db/bar/1
- /api/db/jobs/1
- /api/db/job/1
- /api/db/job
- /api/db/jobs
- /api/db/bar
下面这几个不能:
- /api/db/other/123
- /api/db/jobs/abc
- /api/db
通常来说,我们用不到这么复杂的路由。由于Fresh框架的重点不是接口开发,不像我的oak_nest有各个接口路径的打印,设计复杂的话只会增加维护的难度。
中间件
src/routes/_middleware.ts这个也是特定的文件,代表中间件。与oak框架类似,都遵循洋葱模型(对中间件不了解的可以看这篇《从koa到oak》)。
我们代码中目前只用到session中间件:
import { SessionMiddleware } from "@/modules/session/session.middleware.ts";
export const handler = [
SessionMiddleware,
];
这是多个中间件的写法。
如果只有一个,则与上面接口响应差不多:
import { MiddlewareHandlerContext } from "$fresh/server.ts";
interface State {
data: string;
}
export async function handler(
req: Request,
ctx: MiddlewareHandlerContext<State>
) {
ctx.state.data = "myData";
const resp = await ctx.next();
resp.headers.set("server", "fresh server");
return resp;
}
特殊中间件
值得一提的是,Fresh还提供了一种基于文件路径的特殊中间件:
└── routes
├── _middleware.ts
├── index.ts
└── admin
├── _middleware.ts
└── index.ts
└── signin.ts
routes/admin/_middleware.ts文件只会在用户访问/admin和/admin/signin时触发,执行顺序则是在routes/_middleware.ts之后。
destination
在处理中间件时,一个常见的场景是需要过滤掉许多无效的请求,比如我们期望的中间件只处理路由相关,像static的静态文件(比如/favicon.ico)、islands编译后的文件(比如_frsh/js/3c7400558fc00915df88cb181036c0dbf73ab7f5/deserializer.js)这些希望能够过滤掉。这时可以使用context.destination:
export async function SessionMiddleware(
req: Request,
context: MiddlewareHandlerContext<State>,
) {
if (context.destination !== "route") {
return context.next();
}
// 其它代码
}
它的签名为:
export type DestinationKind = "internal" | "static" | "route" | "notFound";
internal就是_frsh/开头的路径。
更多细节参见官网文档《Middlewares》。
Controller与Service复用
在原来的oak_nest框架中,我们的业务校验放在Controller里,业务逻辑放Service里,而dto文件则是存放的Web端传递的参数类型与校验规则,schema是与数据库(MongoDB)相关的表字段。
src/comments
|-- comments.controller.ts
|-- comments.dto.ts
|-- comments.module.ts
|-- comments.schema.ts
`-- comments.service.ts
迁移到Fresh后,dto、schema是可以直接复用的,而service需要稍作修改,controller则是修改到对应的routes文件里,接口开发方面没有以前方便,在所难免。
Service复用
以CommentsService为例,原来的代码是这样的:
import { Injectable } from "oak_nest";
import { InjectModel, Model } from "deno_mongo_schema";
import { Comment } from "./comments.schema.ts";
@Injectable()
export class CommentsService {
constructor(@InjectModel(Comment) private readonly model: Model<Comment>) {}
findById(id: string) {
return this.model.findById(id);
}
}
得益于oak_nest的依赖注入,我们很容易在Service中组装与数据库相关的Model,但Fresh框架并没有这个能力,所以不得不进行修改。
我们先声明一个BaseService的abstract类,它有一个init方法,用来初始化model。
export abstract class BaseService {
async init(): Promise<void> {}
}
再定义一个工具函数,用来获取Service的单例:
const services = new Map();
export async function getServiceInstance<T extends BaseService>(
Service: Type<T>,
): Promise<T> {
if (services.has(Service)) {
return services.get(Service);
}
const instance = new Service();
// await instance.init(); // 不能这么写
const promise = instance.init().then(() => instance);
services.set(Service, promise);
await promise;
console.log(`初始化${Service.name}成功`);
return instance;
}
注意下,这段之所以要instance.init().then(() => instance),是因为init是个异步操作,如果是下面这种写法,则可能会出现同一时间,另一个Service已经获取到了,但仍未初始化完成,导致后面报错。
const instance = new Service();
services.set(Service, instance);
await instance.init();
而如果调整顺序:
const instance = new Service();
await instance.init();
services.set(Service, instance);
则会导致同一时间new了多个Service的情况出现,与我们期望的单例不符。
现在改为在services中存储Promise,则避免了以上两种情况。
这时,CommentsService就变成了这样:
import { Model, MongoFactory } from "deno_mongo_schema";
import { Comment } from "./comments.schema.ts";
import { BaseService } from "@/tools/utils.ts";
export class CommentsService extends BaseService {
model: Model<Comment>;
async init() {
this.model = await MongoFactory.getModel(Comment);
}
}
在需要使用的地方获取单例:
this.commentsService = await getServiceInstance(CommentsService);
Controller复用
原来的Controller,我们内置了Guard守卫、参数校验与一系列装饰器,现在这些是用不了。 比如原来是这样的:
@Controller("/posts")
@UseGuards(SSOGuard)
export class CommentsController {
constructor(
private readonly commentsService: CommentsService,
private readonly logger: Logger,
) {}
@Post("/:postId/comment")
async createComment(
@Params("postId") postId: string,
@Form() params: CreateCommentDto,
@Res() res: Response,
@UserParam() userInfo: UserInfo,
@Flash() flash: Flash,
) {
const id = await this.commentsService.create({
postId,
userId: userInfo.id,
content: params.content,
});
this.logger.info(`用户${userInfo.id}创建了博客${postId}的留言: ${id}`);
flash("success", "留言成功");
// 留言成功后跳转到上一页
res.redirect(REDIRECT_BACK);
}
}
我们会根据CreateCommentDto的规则进行校验:
import { IsString, MaxLength, MinLength, } from "deno_class_validator";
export class CreateCommentDto {
@IsString()
@MaxLength(1000)
@MinLength(10)
content: string;
}
现在则只能封装一个函数validateParams:
import { validateOrReject, ValidationError } from "deno_class_validator";
export interface Type<T = any> extends Function {
new (...args: any[]): T;
}
export type Constructor<T = any> = Type<T>;
export async function validateParams(Cls: Constructor, value: object) {
if (!Cls || Cls === Object) { // if no class validation, we can skip this
return [];
}
const post = new Cls();
if (value instanceof FormData) {
for (const [name, v] of value.entries()) {
post[name] = v;
}
} else {
Object.assign(post, value);
}
const msgs: string[] = [];
try {
await validateOrReject(post);
} catch (errors) {
// console.debug(errors);
errors.forEach((err: ValidationError) => {
if (err.constraints) {
Object.values(err.constraints).forEach((element) => {
msgs.push(element);
});
}
});
}
return msgs;
}
在路由接口中显式调用,如果校验错误,则跳转页面或响应错误状态码:
const commentsService = await getServiceInstance(CommentsService);
const form: FormData = await req.formData();
const errMsgs = await validateParams(CreateCommentDto, form);
if (errMsgs.length > 0) {
flash(ctx, "error", errMsgs.join("\n"));
return toBack(req);
}
Controller后面的代码是可以复用的。
Flash的实现
我们原来封装了一个Flash的装饰器,在需要响应提示信息时调用:
flash("success", "留言成功");
现在需要再封装flash方法,将它放在中间件里:
export interface Notification {
success?: string;
error?: string;
userId?: string;
}
export interface State {
session?: Session;
notification?: Notification;
}
export const flash = (
context: HandlerContext<unknown, State>,
key: keyof Notification,
val: string,
) => {
if (!context.state.notification) {
context.state.notification = {};
}
context.state.notification[key] = val;
};
在session中间件的next之后调用(也就是每个接口响应后):
context.state.session = session;
const res = await context.next();
const { success, error, userId } = context.state.notification || {};
// 处理逻辑
需要注意的一点是,Cookie的设置有些小技巧,不能直接修改res.headers(因为它是只读的),必须新建一个出来,响应一个新的Response:
import { Cookie, getCookies, setCookie } from "$std/http/cookie.ts";
const res = await context.next();
const headers = new Headers(res.headers);
const cookie: Cookie = {
name: SESSION_KEY,
value: sessionId,
httpOnly: true,
secure: false,
maxAge: 60 * 60 * 24 * 7,
sameSite: "Strict",
};
setCookie(headers, cookie);
return new Response(res.body, {
status: res.status,
headers,
});
数据库的初始化
对于我们的服务而言,数据库的初始化越早越好,所以新一个db.ts文件:
import { MongoFactory } from "deno_mongo_schema";
import globals from "../globals.ts";
await MongoFactory.forRoot(globals.db);
在main.ts中引入:
import "$std/dotenv/load.ts";
import { start } from "$fresh/server.ts";
import manifest from "./fresh.gen.ts";
import config from "../fresh.config.ts";
import "./tools/db.ts"; // 初始化数据库
await start(manifest, config);
这种引入属于比较暴力的,也可以将它注入到context的state中,比如官方文档里这样写的在中间件里处理:
ctx.state.context = Context.instance();
只是我认为不是很有必要罢了。
有了上面这些,基本上就能将原来的功能复刻迁移了。
Twind
之前一直说要写一篇关于Twind的文章,诸般琐事缠身,一直未能成行。
Twind就像它宣传的,是一个最小的、最快的,且具有最完整在JS中使用Tailwind.css(为行文方便,后面统一为Tailwind)的方案。更通俗的说,Twind是一个小型编译器,它将实用程序class转换为运行时的CSS,其目标是将CSS-in-JS的灵活性与Tailwind API的精心考虑的限制统一起来。
与Tailwind的对比
我们平时是怎么使用Tailwind的呢?它提供了一个PostCSS插件,可以与Webpack、Vite等工具集成,在生产阶段提取出当前工程用到的class类,以减少CSS体积。
它也可以在HTML中使用:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>tailwindcss demo</title>
<script src="https://cdn.tailwindcss.com/"></script>
</head>
<body>
<body>
<div class="text-3xl font-bold underline">Hello world!</div>
</body>
</body>
</html>
在网络里可以看到这个JS文件还是比较大的,有300K。
但看最终页面中,它注入了一段CSS,体积只有4K:
同样地,我们在HTML中使用Twind:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>twind demo</title>
<script src="https://cdn.twind.style" crossorigin></script>
<script>
twind.install({})
</script>
</head>
<body>
<body>
<div class="text-3xl font-bold underline">Hello world!</div>
</body>
</body>
</html>
在网络里看到这个JS约50K,体积是比Tailwind的小了不少:
再看页面元素,你会发现class类稍有不同,命名会编译为hash值:
页面中也没有找到注入的CSS,应该是Twind运行时处理的。
几个原子CSS的内卷史
Tailwind CSS v2.1发版的博文中说:
多年来,我们在改进Tailwind时遇到的最难的限制之一是开发中生成的文件大小。通过对配置文件进行足够的自定义,生成的CSS文件可以达到10MB甚至更多,而构建工具甚至浏览器本身能够舒适地容纳的CSS量是有限的。
这种情况制约了Tailwind的开发性能,尤其是热更新,所以又产生了Windi CSS、UnoCSS等方案来解决它的问题,不过Tailwind也不是吃素的,在2.1版本(2021年3月)开始就加入了JIT(Just-in-Time)实时编译,到3.0已经内置使用了。
UnoCSS的作者antfu是Vite团队的成员,开发的初衷是使用Vite时认为Tailwind生成了数M的CSS,使得加载与更新CSS成为了整个Vite应用的性能瓶颈,于是切换到了Windi CSS,后来也成为了其核心成员,再到后来觉得还不够舒服,又起炉灶在国庆期间搞出了UnoCSS(详见重新构想原子化 CSS,也可以看公众号文章)。UnoCSS是一个引擎,而非一款框架,因为它并未提供核心工具类,所有功能可以通过预设和内联配置提供,也就是说它可以使用Tailwind,也可以使用Windi、Tachyons甚至Bootstrap。
antfu是国内大佬,看GitHub还搞了个文言编程,真是好6:
随着Tailwind的演进与UnoCSS的成功,Windi CSS没有了奋斗的欲望,发了个公告《Windi CSS is Sunsetting》撤了。所以喜欢Windi的同学可以考虑迁移到UnoCSS了(可见《为什么选择UnoCSS》)。
Twind的定位
Twind也属于Tailwind式微(未推出JIT)时的一个解决方案,第一个版本出现在2020年(当时的包名还是tw-in-js),主要目的是为了提供一个Tailwind运行时,在JS中编写Tailwind(实现CSS-in-JS),它是完全兼容Tailwind的。
早期的写法还是这样的:
document.body.innerHTML = `
<main class=${tw('bg-black text-white')}>
<h1 class=${tw('text-xl')}>This is Tailwind in JS!</h1>
</main>
`
这种写法哪有直接写Tailwind的class舒服?
我们可以想象现在Twind的尴尬定位(有点像Node.js的CommonJS规范),它在Tailwind未使用JIT时,不失为一种提升开发阶段性能的替代方案,但在Tailwind推出JIT(在写此文时,发现Tailwind还用了Rust,太卷了)后,又还有什么竞争优势呢?生产阶段的运行时体验终究不如零运行时。
所以,Twind的受众是有限的,适用于轻量级的原生工程(非工程化),又或者无构建阶段的SSR框架,比如Deno的Fresh。
Twind在Fresh中是如何工作的
先看下Twind的Fresh插件怎么写的,以最新版本v1(默认用的不是v1,下文会说怎么开启)为例:
export default function twindv1(options: Options): Plugin {
const sheet = virtual(true);
setup(options, sheet);
const main = `data:application/javascript,import hydrate from "${
new URL("./twindv1/main.ts", import.meta.url).href
}";
import options from "${options.selfURL}";
export default function(state) { hydrate(options, state); }`;
return {
name: "twind",
entrypoints: { "main": main },
async renderAsync(ctx) {
// 略
},
};
}
其中,main是个动态的字符串,其实是供esbuild使用的入口文件(entryPoints),以下是src/build/esbuild.ts的代码:
const res = await esbuild.build({
write: false,
platform: "browser",
target: ["chrome99", "firefox99", "safari15"],
format: "esm",
bundle: true,
treeShaking: true,
entryPoints: { "main": main },
plugins: [
buildIdPlugin("abcd"),
...denoPlugins({ configPath: "./deno.json" }),
],
});
动态注入的hydrate函数在twindv1/main.ts,它也就是我们常说的水合作用,也称胶水代码。这段代码是运行在浏览器中的,目的就是调用Twind的API,恢复Twind状态,初始化Twind。
export default function hydrate(options: TwindConfig) {
const elem = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement;
const sheet = cssom(elem);
sheet.resume = getSheet().resume.bind(sheet);
document.querySelector('[data-twind="claimed"]')?.remove();
setup(options, sheet);
}
我们再看后面的renderAsync函数,则是运行在服务端,是渲染页面时会调用的钩子函数,它返回了两个字段,一个是scripts标签,一个是css样式。
async renderAsync(ctx) {
const res = await ctx.renderAsync();
const cssText = stringify(sheet.target);
const scripts = [];
if (res.requiresHydration) {
scripts.push({ entrypoint: "main", state: [] });
}
return {
scripts,
styles: [{ cssText, id: STYLE_ELEMENT_ID }],
};
},
以官网首页为例,我们看到sheet.target是收集的所有的class样式:
生成的cssText就是Twind编译生成的CSS样式:
main.ts打包后的代码与这些CSS样式最终会嵌入到HTML的style中:
渲染插件的核心处理在src/server/render.ts中(以下代码经过删减):
const renderResults: [Plugin, PluginRenderResult][] = [];
const asyncPlugins = opts.plugins.filter((p) => p.renderAsync);
async function renderAsync(): Promise<PluginRenderFunctionResult> {
const plugin = asyncPlugins.shift();
if (plugin) {
const res = await plugin.renderAsync!({ renderAsync });
renderResults.push([plugin, res]);
} else {
await opts.renderFn(ctx, () => renderSync().htmlText);
}
return {
htmlText: bodyHtml,
requiresHydration: renderState.encounteredIslands.size > 0,
};
}
await renderAsync();
// Create Fresh script + style tags
const result = renderFreshTags(renderState, {
bodyHtml,
imports: opts.imports,
csp,
dependenciesFn: opts.dependenciesFn,
styles: ctx.styles,
pluginRenderResults: renderResults,
});
我们看到的这个requiresHydration是判断页面是否需要胶水代码,规则是当前页面是否有islands。
由此也可以看出Twind插件本身是有些许浪费的,因为页面用到的CSS已经在服务端组装到内嵌到HTML中,islands中即使有交互,在大部分情况下是不需要Twind本身的胶水代码的,只有在页面有状态的情况下(比如登陆与未登陆时显示的不一样),才可能会在页面上用到Twind来动态生成样式。只不过,这个文件br压缩后只有12K,一时影响倒不大:
如果要极致优化的话,最好加个开关由开发者控制,只是那样一来,会增加使用者的心智负担,又比较麻烦了。
Fresh中如何开启Twind
Fresh脚手架创建项目时,如果你选择了使用Tailwind,那么默认用的就是twind@0.16.19版本。而Twind已经升了一个大版本,建议还是升级使用v1(不然你看文档很有可能看错,当年我在Material UI上得到的血的教训🐶)。
在deno.json的imports增加:
{
"imports": {
"twind": "https://esm.sh/@twind/core@1.1.3",
"@twind/preset-autoprefix": "https://esm.sh/@twind/preset-autoprefix@1.0.7",
"@twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4"
}
}
新增twind.config.ts:
import { defineConfig, type Preset } from "twind";
import type { Options } from "$fresh/plugins/twindv1.ts";
// twind preset
import presetTailwind from "@twind/preset-tailwind";
import presetAutoprefix from "@twind/preset-autoprefix";
const config = defineConfig({
presets: [
presetTailwind() as Preset,
presetAutoprefix(),
],
preflight: false, // 去掉默认注入的样式,如果是从头开发,可以考虑开启
});
export default {
...config,
selfURL: import.meta.url,
} as Options;
修改fresh.config.ts:
import { defineConfig } from "$fresh/server.ts";
import twindPlugin from "$fresh/plugins/twindv1.ts";
import twindConfig from "./twind.config.ts";
export default defineConfig({
plugins: [twindPlugin(twindConfig)],
});
Form表单提交
用了Fresh框架后,如果还用原生表单提交,就很不优雅了,因为每次提交后,不管是成功还是失败,页面都要刷新或重定向,体验效果并不好。
我们先禁用Form的提交,新增一个onSubmit函数。
<form
class="ui form segment"
method="post"
encType="multipart/form-data"
+ onSubmit={handleSubmit}
- action="/signin"
>
</form>
新增handleSubmit:
import { useEffect } from "preact/hooks";
const SignInForm = () => {
const handleSubmit = async (event: Event) => {
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
const res = await fetch("/signin", {
method: "POST",
body: formData,
});
if (!res.ok) {
alert(await res.text());
return;
}
alert("登陆成功");
// 注册成功后跳转到登录页面
location.href = "/signin";
};
// 其它代码
}
这里成功或失败的提示信息,我仅用原生alert处理,读者可以封装一个组件进行提示。
状态共享
由于Fresh的路由系统,本质上是多页面,每个页面之间都是独立的,这是所有MPA的共性,没什么好说的。 但当个页面中,各组件(在Fresh里特指的是islands)间的状态共享,则是我们需要考虑的。 刚开始使用Fresh时还在GitHub issue里找解决方案,使用过fresh_store,后来作者回复可以直接使用Preact的Signal,我这才意识到Preact的这个新特性是多么方便。
Signal翻译过来是信号的意思,它是除React外新兴的框架如Vue、Solid.js、Preact、Svelte甚至Angular都采用一种处理机制,可以更细粒度地更新DOM。
体现在代码中一个直观的好处是,Signal不必局限于hooks中使用。比如我们写useState时,必须是在组件中:
function Counter() {
const [value, setValue] = useState(0);
const increment = useCallback(() => {
setValue(value + 1);
}, [value]);
return (
<div>
<p>Counter: {value}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
而Signal可以独立于组件存在,也就是说可以放在组件外,Preact会自动根据value的变化而响应更新DOM。
import { signal } from "@preact/signals";
const count = signal(0);
function Counter() {
// Accessing .value in a component automatically re-renders when it changes:
const value = count.value;
const increment = () => {
// A signal is updated by assigning to the `.value` property:
count.value++;
}
return (
<div>
<p>Count: {value}</p>
<button onClick={increment}>click me</button>
</div>
);
}
Preact也可以在JSX中直接使用Signal,简便开发:
import { signal } from "@preact/signals";
const count = signal(0);
function Counter() {
return (
<div>
<p>Count: {count}</p>
<button onClick={() => count.value++}>click me</button>
</div>
);
}
关于Signal的详细用法,建议参看Preact官方文档,写的很详尽了。
在Fresh中,跨组件使用只需要把Signal共享到各个islands即可:
export default function Home() {
const sliderSignal = useSignal(50);
return (
<div>
<SynchronizedSlider slider={sliderSignal} />
<SynchronizedSlider slider={sliderSignal} />
<SynchronizedSlider slider={sliderSignal} />
</div>
);
}
亦或者,Signal可以单独提取到某个TS文件:
import { signal } from "@preact/signals";
export const cart = signal<string[]>([]);
任何islands组件将它引入,就可以响应更新。
对于我们这个项目而言,没有业务需要各个组件间共享状态。我们有个官网项目的登陆是由专门的SSO服务提供,前端进行的用户信息获取,不同组件间对用户信息的读取使用Signal就非常适宜了。
在博客这个项目里,有一处适合使用Signal,那就是信息提示。
单独封装一个src/client/messages.ts文件:
import { signal } from "@preact/signals";
export enum MessageType {
Success = "success",
Warning = "warning",
Error = "error",
}
export interface IMessage {
message: string;
type: MessageType;
}
export const message = signal<IMessage | null>(null);
消息信息的变化我们export三个函数供外部使用:
function setMessage(msg: string, type: MessageType) {
const latest = { message: msg, type };
message.value = latest;
// setTimeout(() => {
// if (message.value === latest) {
// message.value = null;
// }
// }, 1000);
}
export function success(msg: string) {
setMessage(msg, MessageType.Success);
}
export function warn(msg: string) {
setMessage(msg, MessageType.Warning);
}
export function error(msg: string) {
setMessage(msg, MessageType.Error);
}
新建src/islands/Modal.tsx,它消费这个消息Signal:
import { message } from "@/client/messages.ts";
const Modal = () => {
const msg = message.value?.message;
if (!msg) return <></>;
const cls = `ui ${message.value!.type} message`;
return (
<div class="ui grid">
<div class="four wide column"></div>
<div class="eight wide column">
<div class={cls}>
<p>{msg}</p>
</div>
</div>
</div>
);
};
export default Modal;
在src/routes/_app.tsx中全局引入Modal:
这样,上节提到的Form表单提交的alert信息,就可以修改为:
import { error, success } from "@/client/messages.ts";
import { delay } from "$std/async/delay.ts";
const SignInForm = () => {
const handleSubmit = async (event: Event) => {
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
const res = await fetch("/signin", {
method: "POST",
body: formData,
});
if (!res.ok) {
error(await res.text());
return;
}
const user = await res.json();
let timeLeft = 3;
const handleRedirect = async () => {
for (let i = 0; i < 3; i++) {
success(`欢迎回来:${user.name},页面将在${timeLeft}秒后跳转`);
await delay(1000);
timeLeft--;
}
};
await handleRedirect();
// 注册成功后跳转到登录页面
location.href = "/posts";
};
// 其它代码
};
当用户名或密码校验失败时,这时就显示出原来表单提交后页面跳转的效果:
登陆成功后,我们延时3秒,再进行页面跳转:
上传的坑
注册时,需要用户上传图片,上传以后,这个图片可以被代理访问,比如http://localhost/img/myIoqM194zkguxYRJD02v.png。
Fresh有个静态目录static,我们将目录迁移到src后就是src/static/,我们在它之下新建一个目录img,将文件上传到这个目录下,就可以正常就访问了。
但是,在测试时遇到一个问题,每次上传完图片,发现服务就重启了。原因是开发阶段,我们使用--watch监听文件变化来重启服务,默认配置有static目录:
"start": "deno run -A --check --watch=src/static/,src/routes/ src/dev.ts",
为解决这个情况,将--watch修改为屏蔽掉img目录:
"start": "deno run -A --check --watch=src/static/,src/routes/,!src/static/img/ src/dev.ts",
这次不会再重启了。
但更严重的事情发生了,用户登陆后显示不出来头像。
看了下Fresh源码,原来是Fresh为了优化性能,预先遍历了static目录,将这些文件标记为静态文件,
const staticFiles: StaticFile[] = [];
const staticFolder = new URL(
opts.staticDir ?? "./static",
manifest.baseUrl,
);
const entries = walk(fromFileUrl(staticFolder), {
includeFiles: true,
includeDirs: false,
followSymlinks: false,
});
const encoder = new TextEncoder();
for await (const entry of entries) {
const localUrl = toFileUrl(entry.path);
const path = localUrl.href.substring(staticFolder.href.length);
const stat = await Deno.stat(localUrl);
const contentType = typeByExtension(extname(path)) ??
"application/octet-stream";
const etag = await crypto.subtle.digest(
"SHA-1",
encoder.encode(BUILD_ID + path),
).then((hash) =>
Array.from(new Uint8Array(hash))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
);
const staticFile: StaticFile = {
localUrl,
path,
size: stat.size,
contentType,
etag,
};
staticFiles.push(staticFile);
}
再把它们添加到静态路由中:
for (
const { localUrl, path, size, contentType, etag } of this.#staticFiles
) {
const route = sanitizePathToRegex(path);
staticRoutes[route] = {
baseRoute: toBaseRoute(route),
methods: {
"HEAD": this.#staticFileHeadHandler(
size,
contentType,
etag,
),
"GET": this.#staticFileGetHandler(
localUrl,
size,
contentType,
etag,
),
},
};
}
也就是说,它只会处理服务启动前已有的static目录下的文件,新增的不会处理。
这个逻辑本身是对的,如果不这样的话,面对任何一个不存在的路由,Fresh都需要降级到static下查找,很可能是一次浪费的I/O。
不得不感慨,任何细节都是坑,谁会了解这个呢?
于是按照上面的定制路由的方法,新增一个src/routes/img.ts文件:
import { HandlerContext, RouteConfig } from "$fresh/server.ts";
export const config: RouteConfig = {
routeOverride: "/img/:path*",
};
export const handler = {
async GET(_req: Request, { params }: HandlerContext) {
const { path } = params;
try {
const file = await Deno.readFile(`./img/${path}`);
return new Response(file);
} catch (_error) {
return new Response("Not Found", { status: 404 });
}
},
};
将img目录移动到了根目录下。
部署
Fresh的部署与《3.5 部署》别无二致。
Docker镜像
需要注意的一点是,Fresh的Dockerfile文件在官方文档里推荐是这样的:
FROM denoland/deno:1.35.0
ARG GIT_REVISION
ENV DENO_DEPLOYMENT_ID=${GIT_REVISION}
WORKDIR /app
COPY . .
RUN deno cache main.ts
EXPOSE 8000
CMD ["run", "-A", "main.ts"]
构建镜像时把Git的当前版本信息注入:
$ docker build --build-arg GIT_REVISION=$(git rev-parse HEAD) -t my-fresh-app .
之所以这样,是因为Fresh使用的chunk的JS路径是动态的:
为了优化性能,Fresh框架设置这些JS文件为强缓存:
为避免重新上线后没有使用到新的代码(虽然不一定所有缓存都应该失效,但这就是这种处理的缺陷了),我们在创建Docker时需要更新这个值,官方给的样例中DENO_DEPLOYMENT_ID就是使用Git的版本hash。
$ git rev-parse HEAD
fdfa4649ba8949730283a5d8d50da505f9a0ace8
也就是最后一条提交记录:
我之前的文章《探索Buildah:简化Docker镜像构建》里面构建镜像时注入了GIT_REVISION为环境变量CI_COMMIT_SHA,其实就是为了Fresh:
if let Ok(revision) = env::var("CI_COMMIT_SHA") {
command
.arg("--build-arg")
.arg(format!("GIT_REVISION={}", revision));
}
最终的镜像中就有了:
但是,官方这个镜像文档是旧的,不包含上篇说的新增了build后的功能。
所以,更合理的是加入build步骤,将镜像切换为alpine版本(详见《你需要了解的Deno镜像》),将用户也切换为deno:
FROM denoland/deno:alpine-1.36.2
ARG GIT_REVISION
ENV DENO_DEPLOYMENT_ID=${GIT_REVISION}
WORKDIR /app
COPY . .
RUN echo "${DENO_DEPLOYMENT_ID}"
EXPOSE 8000
RUN deno task build \
&& deno cache src/main.ts
RUN chown -R deno:deno /app
USER deno
CMD deno run --allow-sys --allow-read --allow-env --allow-net --allow-write --allow-run src/main.ts
我们看下打包后的镜像体积,大小为181MB。
docker images | grep my-fresh-app
my-fresh-app latest ba184d10b7bd 5 minutes ago 181MB
仔细想想上篇我说的,build后,其实esbuild本身就没有用了,我们进入容器看下:
$ ~/.cache/esbuild/bin # ls
@esbuild-linux-x64@0.18.11
$ ~/.cache/esbuild/bin # ls -l
total 9000
-rwxr-xr-x 1 root root 9216000 Sep 6 09:05 @esbuild-linux-x64@0.18.11
$ ~/.cache/esbuild/bin # pwd
/root/.cache/esbuild/bin
体积大概是9M左右。
所以我们再修改下镜像,在build之后删除掉这个esbuild缓存目录,顺便加入npm国内镜像的环境变量NPM_CONFIG_REGISTRY:
FROM denoland/deno:alpine-1.36.2
ARG GIT_REVISION
ENV DENO_DEPLOYMENT_ID=${GIT_REVISION}
WORKDIR /app
COPY . .
RUN echo "${DENO_DEPLOYMENT_ID}"
EXPOSE 8000
# ENV DENO_DIR=deno-dir # 修改Deno默认缓存目录
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
RUN deno task build && rm -rf /root/.cache/esbuild \
&& deno cache src/main.ts
RUN chown -R deno:deno /app
USER deno
CMD deno run --allow-sys --allow-read --allow-env --allow-net --allow-write --allow-run src/main.ts
这次构建完镜像后只有172MB了。
docker images | grep my-fresh-app
my-fresh-app latest 807b81678390 35 seconds ago 172MB
当然,你也可以不显式删除esbuild目录,而采取级联构建Docker的方式,先执行build,再将产物(src/_fresh文件夹)复制到这个镜像里,这里就不赘述了。如果使用GitLab CI,则可在前置操作中执行build,再到这一步直接复制。
不过有一点需要注意,Fresh判断esbuild要不要生成sourcemap的条件是根据DENO_DEPLOYMENT_ID这个环境变量:
function isDevMode() {
// Env var is only set in prod (on Deploy).
return Deno.env.get("DENO_DEPLOYMENT_ID") === undefined;
}
所以需要做好处理。
Deno Deploy平台
如果玩的花点儿,可以考虑将服务托管到Deno Deploy平台。那样,数据库可以使用 Deno KV,但是大概率是不能存储图片的,那样图片可以考虑存储到OSS或者七牛云之类的平台。
本文目的仅是介绍Fresh,这些就交给读者自行探索了。
总结
本文首先教你如何调整创建的目录结构,如何使用AI将EJS转换为Preact,简化你的开发。详细介绍了Fresh框架的路由、中间件,以及之前版本的代码如何复用。又介绍了Twind与Tailwind的关系及其它原子CSS的发展史,还有使用Preact的功能进行Form表单提交,共享组件间的状态。我们还遇到了上传的坑,最后是部署环节Docker构建的优化和一些注意事项。
有兴趣可以参看本文源码,我只实现了部分功能。 结合这上下两篇Fresh实战,相信可以帮助你掌握这款框架,明白它的优缺点与适用场景。
咦,我是不是没有说它的缺点?开发体验中最明显的一点是没有热更新,只是简单粗暴地刷新页面,介意的话还是莫用了吧。。。