前端与 RPC
Cell 应用可以在同一个项目里同时有 React 前端和后端。cell dev 在一个端口上同时提供两者(前端 HMR、后端热重载),cell build 把前端放到 .cell/output/public/,由后端经 @celljs/serve-static 返回,或由平台的 CDN 托管(Cloudflare、Vercel)。
npx @celljs/cli init fullstack my-appfullstack 模板依赖 @celljs/react(它会加上 frontend 构建目标)、@celljs/rpc 和 @celljs/serve-static。
页面
页面就是 src/browser/pages/ 下的文件,每个文件的默认导出是页面组件。
src/browser/pages/
├── _layout.tsx 包在它下面所有页面外层
├── index.tsx /
├── about.tsx /about
└── users/
├── index.tsx /users
└── [id].tsx /users/:id// src/browser/pages/users/[id].tsx
import { Link, useParams } from '@celljs/react';
export default function User() {
const { id } = useParams();
return <><h1>User {id}</h1><Link to="/users">All users</Link></>;
}// src/browser/pages/_layout.tsx
import { NavLink } from '@celljs/react';
import type { PropsWithChildren } from 'react';
export default function Layout({ children }: PropsWithChildren) {
return <><nav><NavLink to="/">Home</NavLink> <NavLink to="/users">Users</NavLink></nav>{children}</>;
}[...rest].tsx匹配剩余的整段路径:pages/[...rest].tsx就是未知 URL 的页面。_layout.tsx通过children拿到页面。布局逐层嵌套(外层在前),在它下面的页面之间切换时保持挂载;没有_layout的页面使用@DefaultLayout。- 页面渲染时抛错、或者它的代码块加载失败,只在它的布局里显示错误,不会让整个应用白屏;用户去别的路径时会重新尝试。显示的是最近的
_error.tsx(同目录或上级),props 是{ error, reset }(@celljs/react的ErrorFallbackProps);没有时显示错误信息和一个重试按钮。 - 以
_开头的文件和目录、以及测试文件不是页面:页面用到的组件可以放在旁边的_components/里。 - 每个页面单独成一个 chunk,访问时才加载。两个文件对应同一路径(
about.tsx与about/index.tsx)会报CELL_ROUTE_CONFLICT。 - 不需要在任何地方加 import:
cell dev会感知页面的增删。路由生成在.cell/generated/frontend/routes.js,npx cell info --json会列出它们(frontend.routes)。
路由基于 React Router 7。@celljs/react 重新导出了页面常用的部分:Link、NavLink、Navigate、Outlet、useParams、useNavigate、useLocation、useSearchParams、useMatch。cell.yml 里的 react.router.type 选择 browser(默认)或 hash 路由。@celljs/react 的 @App、@DefaultLayout 和 @Router 分别替换根组件、默认布局和路由器;包可以用 @View 添加页面(见参考文档)。
src/browser/module.ts 是前端的 DI 模块:它导入前端服务所在的文件(见下文)。
前端拿到的是组件标记为 public 的配置键和你的 app.public,在构建时求值,对所有人可见。在浏览器里,React 组件用 useValue('html.title') 读取,DI 组件用 @Value。
在组件里使用服务
前端服务(状态、接口客户端、共享的逻辑)和后端一样是 DI 组件。React 组件用 @celljs/react 的 hook 获取它们和配置:
import { useInject, useValue } from '@celljs/react';
import { UserService } from './user-service'; // 在 module.ts 导入的文件里用 @Component(UserService) 声明
function Header() {
const userService = useInject<UserService>(UserService); // 每个组件实例解析一次
const title = useValue<string>('app.public.title', 'My app');
return <header>{title}: {userService.name}</header>;
}shell 通过 ContainerContext 提供应用的容器。测试组件时,把它渲染在 <ContainerContext.Provider value={container}> 里,传入你自己的容器。页面、布局和 context 保持为普通的 React 组件,它们用到的东西从容器里取。
useRpc(id) 取后端服务(见下一节),useLocale() 返回 [locale, setLocale]:用户在 widget.locales 里选的语言,记在 localStorage。要翻译文案,安装 react-intl,并在 src/browser/module.ts 里导入一次 @celljs/react/lib/browser/intl,之后 useIntl() 和 <FormattedMessage> 跟随所选语言。全部 hook 见 @celljs/react 的 README。
调用后端:RPC
不必为自己的前端写 HTTP 接口和 fetch 调用:共享一个接口,像调用本地对象一样调用它。
// src/common/greeting-protocol.ts:前后端都会导入
export const GreetingServer = Symbol('GreetingServer');
export interface GreetingServer {
greet(name: string): Promise<string>;
}// src/node/greeting-server.ts:由 src/node/module.ts 导入
import { Rpc } from '@celljs/rpc';
import { GreetingServer } from '../common/greeting-protocol';
@Rpc(GreetingServer)
export class GreetingServerImpl implements GreetingServer {
async greet(name: string): Promise<string> {
return `Hello, ${name}!`;
}
}// src/browser/pages/index.tsx
import { useRpc } from '@celljs/react';
import { GreetingServer } from '../../common/greeting-protocol';
export default function Home() {
const greetingServer = useRpc<GreetingServer>(GreetingServer);
const [message, setMessage] = React.useState('Loading…');
React.useEffect(() => {
greetingServer.greet('browser').then(setMessage);
}, [greetingServer]);
return <h1>{message}</h1>;
}@Rpc(Id)既是组件(照常往里注入服务),也是一个端点:以 JSON-RPC over HTTPPOST提供在/<rpc.path>/<Id>,默认为/api/GreetingServer(symbol 的描述)。- 每个方法都返回 Promise;参数和结果以 JSON 传输(JSON-RPC 2.0)。只能调用类自己声明的方法。服务端抛出的错误会让调用方的 Promise reject:
ApplicationError原样还原,其他错误是带status的RpcError;生产环境下除 4xx 的HttpError外不暴露错误信息。 - 安装了
@celljs/validation时,类类型的参数会像控制器一样被校验。 - 测试里
createTestApp()的app.get(GreetingServer)直接调用实现。
后端调用前端:WebSocket
rpc:
transport: websocket此后每个浏览器标签页的调用走一条 WebSocket 连接(/<rpc.path>/ws),与 HTTP 一样经过中间件、会话和安全校验,后端也能反过来调用前端。前端提供的能力用 @RpcClient 声明(接口放在 src/common/,实现类放在 src/browser/ 并由前端模块导入):
// src/browser/notice-client.ts
import { RpcClient } from '@celljs/rpc';
import { NoticeClient } from '../common/notice-protocol';
@RpcClient(NoticeClient)
export class NoticeClientImpl implements NoticeClient {
async notify(text: string): Promise<void> {
console.log(text);
}
}// 后端某个 @Rpc 服务里
import { RpcContext } from '@celljs/rpc/lib/node';
const connection = RpcContext.requireConnection(); // 调用方所在的标签页
await connection.client<NoticeClient>(NoticeClient).notify('order shipped');
connection.onClose(() => { /* 标签页已关闭 */ });RpcConnectionManager(@celljs/rpc/lib/node)列出所有打开的连接,可以用来通知全部标签页。无法建立连接的地方(Serverless 预设、不支持 WebSocket 的代理)调用改走 HTTP,此时 RpcContext.requireConnection() 会抛 RpcNoConnectionError。其他源的页面不能连接,除非列在 rpc.websocket.allowedOrigins 里。其余配置见参考文档。
从另一个后端调用
后端用注入的代理调用另一个 Cell 后端的服务:
import { Component } from '@celljs/core';
import { Autorpc } from '@celljs/rpc';
@Component()
export class Greeter {
@Autorpc(GreetingServer)
protected readonly greetingServer: GreetingServer;
}rpc:
defaultEndpoint: "http://greetings.internal:3000{rpcPath}/{serviceIdentifier}"
endpoint: # 按服务指定,优先于 defaultEndpoint
GreetingServer: "${env.GREETING_URL}"RpcUtil.create<T>(endpoint, Id) 不经容器创建同样的代理。
只有前端或只有后端的项目
构建目标随已安装的组件而定:@celljs/react 加上 frontend,backend 总是存在。cell.config.ts 的 targets 可以显式指定。
注解和配置项的完整列表见 @celljs/react 文档。