Skip to content

视图(TSX) ​

hazejs 用 TSX 在服务端渲染 HTML。JSX 运行时内置在 @hazejs/core 中:零依赖,把元素渲染为转义后的 HTML 字符串,所有部署预设(包括 Cloudflare Workers)都能使用。JSX 由 Vite 插件在构建时编译,运行时不需要加载模板引擎。

视图适合服务端渲染的页面、HTML 邮件、落地页、维护页和简单的管理页面。完整的单页前端请使用 Vue / React 全栈模板。

第一个视图 ​

视图放在 app/view 下(haze make:view Home 生成 app/view/Home.tsx):

tsx
// app/view/Home.tsx
export default function Home(props: { name: string }) {
  return (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>Home</title>
      </head>
      <body>
        <h1>Hello, {props.name}</h1>
      </body>
    </html>
  )
}

在路由或控制器中渲染。响应为 text/html,不经过 统一响应格式:

tsx
// app/controller/HomeController.tsx
import type { HttpContext } from '@hazejs/core'
import Home from '~/app/view/Home'

export class HomeController {
  index(ctx: HttpContext) {
    return ctx.view(<Home name="Ada" />)
  }
}

路由文件通常是 .ts,这时传入组件与属性,不写 JSX:

ts
// route/web.ts
import { defineRoutes } from '@hazejs/core'
import Home from '~/app/view/Home'

export default defineRoutes((r) => {
  r.get('/', ctx => ctx.view(Home, { name: 'Ada' })).name('home')
})

api 模板已经这样配置:GET / 渲染 app/view/Welcome.tsx。

渲染 ​

API说明
ctx.view(<Home />, init?)渲染 JSX 节点
ctx.view(Home, props, init?)渲染组件并传入属性
ctx.view('admin/Dashboard', props, init?)按名称渲染 app/view/admin/Dashboard.tsx(默认导出,或与文件同名的具名导出:export function Dashboard)
view(...)与 ctx.view() 相同,用于拿不到 ctx 的代码
return <Home />处理器或中间件返回的 JSX 节点同样渲染为 HTML
renderToString(node)渲染为字符串,例如邮件正文

init 为 { status?, headers? }。不传 status 时使用 ctx.status() 设置的状态码(默认 200):

tsx
return ctx.view(<NotFound />, { status: 404, headers: { 'cache-control': 'no-store' } })

渲染结果以 <html 开头时自动在前面加上 <!DOCTYPE html>。其它情况使用 Doctype 组件:<><Doctype /><html>…</html></>。

渲染时抛出的异常与其它异常一样交给 异常处理器。

布局与组件 ​

布局就是接收 children 的组件:

tsx
// app/view/Layout.tsx
import type { ViewChild } from '@hazejs/core'

export default function Layout(props: { title: string, children?: ViewChild }) {
  return (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>{props.title}</title>
      </head>
      <body>
        <main>{props.children}</main>
      </body>
    </html>
  )
}
tsx
return ctx.view(<Layout title="文章"><PostList posts={posts} /></Layout>)

组件可以是 async 函数。同级的异步组件并发执行,输出顺序不变:

tsx
async function LatestPosts() {
  const posts = await Post.order('id', 'desc').limit(5).select()
  return <ul>{posts.map(post => <li>{post.title}</li>)}</ul>
}

转义与原始 HTML ​

文本与属性值始终会转义。需要插入可信的 HTML 时,使用 rawHtml()(或 邮件工具 中的 html 模板),或者 dangerouslySetInnerHTML:

tsx
import { rawHtml } from '@hazejs/core'

<div>{rawHtml(markdownToHtml(post.body))}</div>
<style dangerouslySetInnerHTML={{ __html: css }} />

<script> 与 <style> 中的内容和普通文本一样会被转义,内联代码请使用 dangerouslySetInnerHTML。

属性 ​

值输出
disabled / disabled={true}disabled
false、null、undefined不输出该属性
class / className字符串、数组或 { active: isActive } 对象:class={['btn', { active }]}
style字符串或对象;驼峰属性名转为连字符,数值加 px(opacity、lineHeight、zIndex 等无单位属性除外)
htmlForfor
函数(onClick)不输出——没有客户端运行时
Promise等待结果后再输出元素

空元素(<br>、<img>、<input>、<meta> 等)没有结束标签。常用的 HTML 属性都有类型,任何元素都可以使用 data-*、aria-*、hx-*(htmx)与 x-*(Alpine.js)。自定义元素的名称必须包含连字符(<my-widget>)。

请求上下文 ​

组件在请求内执行,常用的工具都能直接使用:

tsx
import { t, useHttpContext, useView } from '@hazejs/core'

export default function Nav() {
  const ctx = useHttpContext()
  const { locale } = useView()
  return <nav data-path={ctx.path} lang={locale}>{t('messages.home')}</nav>
}

useView() 返回 { ctx, locale, t, old, csrfToken },其中 csrfToken 是返回 Promise 的函数(await csrfToken())。t() 按请求的语言翻译,详见 多语言。

表单 ​

csrfField() 输出 csrf 中间件 校验的隐藏字段 _token。它是异步的,作为 JSX 子节点直接使用即可(Promise 会被等待);在 JSX 之外使用时需要 await。old(field, fallback?) 读取 flashInput() 闪存的输入,提交失败后可以回填表单:

tsx
// app/view/PostForm.tsx
import { csrfField, old } from '@hazejs/core'

export default function PostForm(props: { error?: string }) {
  return (
    <form method="post" action="/posts">
      {csrfField()}
      {props.error && <p class="error">{props.error}</p>}
      <input name="title" value={old('title')} />
      <button type="submit">保存</button>
    </form>
  )
}
ts
// route/web.ts
import { defineRoutes, flashInput } from '@hazejs/core'
import PostForm from '~/app/view/PostForm'

export default defineRoutes((r) => {
  r.group({ middleware: ['session', 'csrf'] }, (r) => {
    r.get('/posts/new', ctx => ctx.view(PostForm, {}))
    r.post('/posts', async (ctx) => {
      const body = await ctx.body<{ title?: string }>()
      if (!body.title) {
        await flashInput() // 闪存请求体(不含 _token 与密码字段)
        return ctx.redirect('/posts/new')
      }
      return ctx.redirect('/posts')
    })
  })
})

old() 需要已启动的会话(session 中间件),没有会话时返回 fallback。

邮件正文 ​

defineMail() 的内容可以用同样的组件渲染:

tsx
// app/mail/WelcomeMail.tsx
import { defineMail, renderToString } from '@hazejs/core'
import Welcome from '~/app/view/mail/Welcome'

export default defineMail(async (payload: { name: string }) => ({
  subject: `欢迎,${payload.name}`,
  html: await renderToString(<Welcome name={payload.name} />),
}))

JSX 的编译方式 ​

Vite 插件在开发、测试与构建时,把后端源码目录(srcDir)下的 .tsx / .jsx 文件按自动运行时、jsxImportSource: '@hazejs/core' 编译。它只作用于后端(SSR)环境,因此全栈模板的前端仍使用自己的 JSX 配置:src/ 下的 React 组件照常由 React 编译。自己声明了 pragma(/** @jsxImportSource react */)的文件不做处理。

类型检查时,后端的 tsconfig 需要相同的设置(模板中已经配置:api 为 tsconfig.json,vue / react 为 tsconfig.server.json):

json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@hazejs/core"
  },
  "include": ["**/*.ts", "**/*.tsx"]
}

开发时修改视图与修改其它后端文件一样,会热替换应用。

TIP

不支持流式渲染与客户端水合:视图渲染为完整的字符串后一次发出。

基于 MIT 许可证发布