Deno实战之Fresh篇(下)

257 阅读15分钟

前方高能预警,本文干货较多,有些长。

目录结构调整

从上节我们可以看到,所有文件都在根目录下,这样不太符合我们的开发习惯,我们通常是有src目录的: image.png

这个调整也简单,把业务代码都移动到src目录下,根目录只保留配置文件: image.png

最终的项目结构大概如下:

.
|-- 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: image.png

修改后为:

 "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就能用,对于这个功能来说是完全足够了:

image.png

这是GPT的回答: image.png

当然,这是一个简单的转换,你也可以将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现在是这样的: image.png

只需要加入一个配置:

import { RouteConfig } from "$fresh/server.ts";

export const config: RouteConfig = {
  skipAppWrapper: true, // Skip the app wrapper during rendering
};

就可以跳过啦: image.png

接口响应

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的文章,诸般琐事缠身,一直未能成行。 image.png

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。 image.png

但看最终页面中,它注入了一段CSS,体积只有4K: image.png

同样地,我们在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的小了不少: image.png

再看页面元素,你会发现class类稍有不同,命名会编译为hash值: image.png

页面中也没有找到注入的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。 image.png

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样式: image.png 生成的cssText就是Twind编译生成的CSS样式: image.png main.ts打包后的代码与这些CSS样式最终会嵌入到HTML的style中: image.png

渲染插件的核心处理在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,一时影响倒不大: image.png

如果要极致优化的话,最好加个开关由开发者控制,只是那样一来,会增加使用者的心智负担,又比较麻烦了。

Fresh中如何开启Twind

Fresh脚手架创建项目时,如果你选择了使用Tailwind,那么默认用的就是twind@0.16.19版本。而Twind已经升了一个大版本,建议还是升级使用v1(不然你看文档很有可能看错,当年我在Material UI上得到的血的教训🐶)。

deno.jsonimports增加:

{
   "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: image.png

这样,上节提到的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";
  };
  // 其它代码
};

当用户名或密码校验失败时,这时就显示出原来表单提交后页面跳转的效果: image.png 登陆成功后,我们延时3秒,再进行页面跳转: 862372e2-3c21-4f6e-9c52-bf7ae3bed611.gif

上传的坑

注册时,需要用户上传图片,上传以后,这个图片可以被代理访问,比如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",

这次不会再重启了。

但更严重的事情发生了,用户登陆后显示不出来头像。 image.png

看了下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路径是动态的: image.png

为了优化性能,Fresh框架设置这些JS文件为强缓存: image.png

为避免重新上线后没有使用到新的代码(虽然不一定所有缓存都应该失效,但这就是这种处理的缺陷了),我们在创建Docker时需要更新这个值,官方给的样例中DENO_DEPLOYMENT_ID就是使用Git的版本hash。

$ git rev-parse HEAD
fdfa4649ba8949730283a5d8d50da505f9a0ace8

也就是最后一条提交记录: image.png

我之前的文章《探索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));
}

最终的镜像中就有了: image.png 但是,官方这个镜像文档是旧的,不包含上篇说的新增了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实战,相信可以帮助你掌握这款框架,明白它的优缺点与适用场景。

咦,我是不是没有说它的缺点?开发体验中最明显的一点是没有热更新,只是简单粗暴地刷新页面,介意的话还是莫用了吧。。。