Fresh 快速入门

2022-09-12 · 3,164 chars · 16 min read

前段时间因为 Islands Architecture 的原因,了解了一下 Fresh,整体感觉还是不错的,设计思路很棒,简洁而高效。虽然还有部分功能缺失,目前也很难在企业级应用开发中使用,但是了解其基本功能和架构设计,对我们开阔思路、突破桎梏还是大有裨益的。所以,这里梳理一份快速入门的文档,分享给大家,希望多少能有些帮助。

起步#

首先我们把环境准备好,项目跑起来。本文基于以下版本:

  • Deno:1.25.2
  • Fresh:1.1.0

安装 Deno#

第一步是安装 Deno,在 macOS 和 Linux 上:

curl -fsSL https://deno.land/x/install/install.sh | sh

在 windows 上我建议是使用 wsl,之后全部按照 Linux 操作即可。如果想直接安装,可以参考官方文档,里面有更多的安装方式和升级指南等。

目前 Deno 和 Fresh 还处在密集开发阶段,我在自己玩的过程中,就遇到过版本问题而需要升级的情况。

创建并启动项目#

接下来创建项目,Fresh 的官方文档是这么教大家建项目的:

deno run -A -r https://fresh.deno.dev my-project
cd my-project
deno task start

我不建议用这个脚手架,因为项目过于简单了,也不方便调试和学习。我们比较建议直接 clone 官方仓库,既包含 Fresh 源码,也有官方的文档站点:

git clone https://github.com/denoland/fresh.git --branch main --depth 10

然后执行 deno task www,就在本地将官网运行起来了。

目录结构#

因为我们是直接 clone Fresh 源码的。要看 Fresh 项目的目录结构,只看 www 目录即可:

.
├── README.md
├── components/
├── data/
├── deno.json
├── dev.ts
├── fresh.gen.ts
├── import_map.json
├── islands/
├── main.ts
├── routes/
├── static/
├── twind.config.ts
└── utils/

这里面有几个主要的文件(夹)需要关注一下:

  1. dev.ts:开发入口。本地开发就是启动这个文件。
  2. main.ts:线上环境运行的入口。
  3. fresh.gen.ts:清单文件,包含 routes 和 islands,Fresh 自动生成的。
  4. routes/:所有 route 都要在这个目录下,这是约定,后面细说。
  5. islands/:所有 island 都要在这个目录下,这是约定,后面细说。
  6. static/:存放静态文件,例如 favicon.ico 等。

Routes#

路由(Route)在 Fresh 里是一个很重要的概念,渲染页面、API 响应、数据请求等都是路由的延伸。

匹配规则#

所有的路由必须放在 routes 目录下,类似 Next.js 的 File-system routing,或者叫做 “约定式路由” 也行。看几个例子就一目了然了:

路由文件地址对应的 pathname
routes/index.tsx/
routes/about.tsx/about
routes/about/contact.tsx/about/contact

Component route#

每个路由文件都可以 export default 一个组件,用来渲染相应的页面:

// routes/index.tsx

export default function Home() {
  return <div>Hello Fresh!</div>
}

访问 / 就会看到:

<div>Hello Fresh!</div>

Handler route#

默认导出的组件会响应为 HTML,除此之外,还可以导出一个 handler 函数,用来自定义响应,可以像接口一样返回 json,也可以返回 HTML。

handler 函数接受 Request 对象,返回 Response 或者 Promise<Response>

// routes/api/random-uuid.ts

import { Handlers } from '$fresh/server.ts'

export const handler: Handlers = (req) => {
  const uuid = crypto.randomUUID()

  return new Response(JSON.stringify(uuid), {
    headers: { 'Content-Type': 'application/json' },
  })
}

上面的写法会处理所有的 HTTP methods,还可以指定只处理 GET 或者 POST,此时 handler 是个对象,相应的 HTTP method 是 key:

// routes/api/random-uuid.ts

import { Handlers } from '$fresh/server.ts'

export const handler: Handlers = {
  GET(req) {
    const uuid = crypto.randomUUID()
    return new Response(JSON.stringify(uuid), {
      headers: { 'Content-Type': 'application/json' },
    })
  },
}

Mixed handler and component route#

不知道大家注意到没,component route 使用的默认导出 export default,而 handler route 使用的是具名导出 export const handler,所以在一个路由中可以同时混合使用两种。

例如在响应 HTML 的同时,又想添加一个自定义 Headers:

// routes/about.tsx

import { Handlers } from '$fresh/server.ts'

export const handler: Handlers = {
  async GET(req, ctx) {
    const resp = await ctx.render()
    resp.headers.set('X-Custom-Header', 'Hello')
    return resp
  },
}

export default function AboutPage() {
  return (
    <main>
      <h1>About</h1>
      <p>This is the about page.</p>
    </main>
  )
}

混合使用时,需要在 handler 里面手动调用 ctx.render(),和 koa,express 之类的框架挺像的。

聪明的你可能已经想到了,没错,所谓的 component route 其实有个默认的 handler,就是单纯的 render 组件。

动态路由#

除了最开始提到的几个 “静态” 路由例子外,还支持动态的路由。例如 routes/greet/[name].tsx 文件,可以匹配 /greet/Luca 或者 /greet/John

:name 参数可以通过组件的 props 获取:

// routes/greet/[name].tsx

import { PageProps } from '$fresh/server.ts'

export default function GreetPage(props: PageProps) {
  const { name } = props.params
  return (
    <main>
      <p>Greetings to you, {name}!</p>
    </main>
  )
}

Fetching data#

Fresh 强调的是 “Just-in-time rendering” 和 “No build”,需要自身的简洁、高性能、边缘网络的加持,否则不会比 SSG 响应的更快。

基于 Fresh 这些特性,数据请求就很简单了,CSR 的 Island 直接在客户端 fetch data 即可。SSR 可以直接使用 Deno 请求,在渲染的时候通过 props 传入组件:

// routes/github/[username].tsx

import { Handlers, PageProps } from '$fresh/server.ts'

interface User {
  login: string
  name: string
  avatar_url: string
}

export const handler: Handlers<User | null> = {
  async GET(_, ctx) {
    const { username } = ctx.params
    const resp = await fetch(`https://api.github.com/users/${username}`)
    if (resp.status === 404) {
      return ctx.render(null)
    }
    const user: User = await resp.json()
    return ctx.render(user)
  },
}

export default function Page({ data }: PageProps<User | null>) {
  if (!data) {
    return <h1>User not found</h1>
  }

  return (
    <div>
      <img src={data.avatar_url} width={64} height={64} />
      <h1>{data.name}</h1>
      <p>{data.login}</p>
    </div>
  )
}

传入 ctx.render 的数据可以通过 props.data 在组件内获取。

Islands#

重头戏来了,可以这么说,我看 Fresh 就是为了 Islands 😁😁😁。关于 Islands Architecture 是什么,之前的文章说的很清楚了,不了解的朋友可以去看看。

Fresh 约定所有 Islands 组件放在 islands/ 目录下。

这些组件都是普通的 Preact 组件,与 Routes 组件的最大不同是,Route Components 只在服务端运行,在客户端看到的都是渲染好的纯 HTML;而 Islands 组件还会在客户端加载 JS 代码并执行,所以可以支持用户交互。如果需要响应用户交互,那这部分的组件必须放在 islands/ 目录下。

举个例子,Counter 计数器组件,+1, -1 两个小功能:

// islands/Counter.tsx

import { useState } from 'preact/hooks'
import { IS_BROWSER } from '$fresh/runtime.ts'

interface CounterProps {
  start: number
}

export default function Counter(props: CounterProps) {
  const [count, setCount] = useState(props.start)
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count - 1)} disabled={!IS_BROWSER}>
        -1
      </button>
      <button onClick={() => setCount(count + 1)} disabled={!IS_BROWSER}>
        +1
      </button>
    </div>
  )
}

然后在 route 里引用 island:

// routes/index.ts

import Counter from '../islands/Counter.tsx'

export default function Home() {
  return (
    <div>
      <p>Welcome to Fresh.</p>
      <Counter start={3} />
    </div>
  )
}

这样 Counter 组件就可以在客户端响应用户交互了,在客户端也仅需要加载 Counter 相关的 JS 代码。

中间件#

中间件总结起来就主要是三点:

  1. 命名为 _middleware.ts,放在 routes/ 目录下。一个就是 handler 函数,多个就是 handler 数组。
  2. 通过 ctx.next() 来向下执行。
  3. 通过 ctx.state 来传递数据(向下或者向上)。

中间件的代码大概长这样:

// routes/_middleware.ts
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
}

在路由 handler 中读取中间件写入的数据:

// routes/myHandler.ts
export const handler: Handlers<any, { data: string }> = {
  GET(_req, ctx) {
    return new Response(`middleware data is ${ctx.state.data}`)
  },
}

执行流程类似 koa 的洋葱模型。

举个例子,假设有这样的目录结构:

  • routes/_middleware.ts
  • routes/index.ts
  • routes/admin/_middleware.ts
  • routes/admin/index.ts
  • routes/admin/signin.ts

请求 /

  • 执行 routes/_middleware.ts
  • 调用 ctx.next() 后执行 routes/index.ts

请求 /admin

  • 执行 routes/_middleware.ts
  • 调用 ctx.next() 后执行 routes/admin/_middleware.ts
  • 调用 ctx.next() 后执行 routes/admin/index.ts

请求 /admin/signin

  • 执行 routes/_middleware.ts
  • 调用 ctx.next() 后执行 routes/admin/_middleware.ts
  • 调用 ctx.next() 后执行 routes/admin/signin.ts

异常页面#

404: Not Found#

约定 routes/_404.tsx 这个路由,用来渲染 404 页面。

// routes/_404.tsx

import { UnknownPageProps } from '$fresh/server.ts'

export default function NotFoundPage({ url }: UnknownPageProps) {
  return <p>404 not found: {url.pathname}</p>
}

如果在别的路由中,例如 routes/greet/[name].tsx 里,遇到了不存在的 :name,想要渲染 404 页面,可以在路由 handler 里调用 ctx.renderNotFound 来展示 404 页面。

500: Internal Server Error#

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>
}

其他#

还有一些东西没讲,例如表单提交,这种需求我就根本就没遇到过,万一遇到就临时抱佛脚好了。

再比如静态文件的处理,Fresh 约定是放在 /static 目录,但是默认没有缓存头。Fresh 提供的 asset 函数我没仔细研究,感觉上很麻烦,如果真的在生产环境使用的话,还是动静分离,单独处理静态文件好了。

Fresh 从 1.1 版本开始支持 plugin,有兴趣的朋友可以自己去看看

开发和调试#

Hot Reload#

目前 Fresh 的热更新还很初级,watch 文件变动后,直接刷新页面:

// /_frsh/refresh.js

let reloading = false
const buildId = 'a4d4936c-97a8-48ee-a8db-3398236fa519'
new EventSource('/_frsh/alive').addEventListener('message', (e) => {
  if (e.data !== buildId && !reloading) {
    reloading = true
    location.reload()
  }
})

Debug#

Debug 是我很关注的部分,因为几乎没什么是比调试一遍 Demo 更好的学习方式了。但是在实际操作的时候却非常不顺利。

一开始尝试的是 VSCode,官方文档还是空的,安装 Deno 插件后自己折腾一下也能跑起来,配置大概是这样的:

// .vscode/launch.json

{
  // Use IntelliSense to learn about possible attributes.
  // Hover to view descriptions of existing attributes.
  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "request": "launch",
      "name": "Launch Program",
      "type": "node",
      "program": "${workspaceFolder}/www/dev.ts",
      "cwd": "${workspaceFolder}",
      "runtimeExecutable": "/Users/keenwon/.deno/bin/deno",
      "runtimeArgs": [
        "run",
        "--import-map",
        "./.vscode/import_map.json",
        "--inspect",
        "--allow-all"
      ],
      "attachSimplePort": 9229
    }
  ]
}

启动调试后,routes/ 下的 componet 和 handler 都是可以断点到的。调试基本的业务逻辑没大问题,但想 debug 整个启动过程就不行了,断点毫无反应。

尝试使用 --inspect-brk 和 chrome devtools,debugger 自动在第一行代码出暂停了,但是却看不到源码,没法继续调试。Github 上面看到了相关的 issue,提出来挺久了,等了几天没啥进度,目前 Deno 的代办 issues 还是还多的,目测短时间内解决不了。

不过调试无非两种,安装官方的 IDE 插件,在 IDE 里面调试,或者支持 V8 Inspector Protocol 的,手动链接到 Chrome 调试。大家使用的时候可以再试试,可能 bug 已经修复,文档也更完善了。

部署#

部署这部分就不多说了,使用 Deno Deploy 是不可能的 😅。官方还给了使用 docker 方案的示例:

FROM denoland/deno:1.25.0

ARG GIT_REVISION
ENV DENO_DEPLOYMENT_ID=${GIT_REVISION}

WORKDIR /app

COPY . .
RUN deno cache main.ts --import-map=import_map.json

EXPOSE 8000

CMD ["run", "-A", "main.ts"]

其实就是启动入口文件 main.ts

话说回来,没有边缘计算网络的加持,这种模式在运行机制上和普通 SSR 差别不大。

总结#

Fresh 除了官方宣传的几个特点之外,还有几处我觉得挺有趣的地方。

首先肯定就是 Islands Architecture,单看这一点,我认为是当之无愧的 “The next-gen web framework”。它几乎占了全部的优点,客户端只需加载少量的 JS 代码、纯静态组件(页面)无需 hydrate、良好的性能、支持 SEO、基于组件的现代化开发模式...。不过,不支持 SSG 这点确实遗憾,现阶段 CDN + SSG 肯定要比 Edge Runtime 更容易应用。即使你的前端基础设施拥有可靠好用的边缘计算能力,但再快的计算也不如不计算,SSG “永不过时”。

另外,Fresh 深度集成了 Preact,无需构建流程,可以直接在服务端渲染 Preact 组件。相比 React,Preact 在大多数情况下确实好用也够用。不像传统 React SSR 要构建两份代码分别用于 server 和 client,Fresh 拥有框架级的支持,虽然没有在生产环境大规模验证过,但这个思路却是非常赞的!

handler route 和 component route 非常像是把 koa、express 简化处理,同时用 JSX 替换了传统的模板引擎,让我们可以像写前端代码一样写后端 deno 应用。

当然了,就像开头说的,Fresh 肯定还是不成熟的,静态文件处理、Debug、Hot Reload、第三方生态等方面都还很单薄,但这并不影响我们学习了解它的设计思路和架构。

最后,Fresh 其实也我也没有太多实战经验,如有不对的地方,欢迎大家留言指正。

赞赏

微信