Skip to content

前端与 RPC ​

Cell 应用可以在同一个项目里同时有 React 前端和后端。cell dev 在一个端口上同时提供两者(前端 HMR、后端热重载),cell build 把前端放到 .cell/output/public/,由后端经 @celljs/serve-static 返回,或由平台的 CDN 托管(Cloudflare、Vercel)。

bash
npx @celljs/cli init fullstack my-app

fullstack 模板依赖 @celljs/react(它会加上 frontend 构建目标)、@celljs/rpc 和 @celljs/serve-static。

页面 ​

页面就是 src/browser/pages/ 下的文件,每个文件的默认导出是页面组件。

text
src/browser/pages/
├── _layout.tsx        包在它下面所有页面外层
├── index.tsx          /
├── about.tsx          /about
└── users/
    ├── index.tsx      /users
    └── [id].tsx       /users/:id
tsx
// 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></>;
}
tsx
// 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 获取它们和配置:

tsx
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 调用:共享一个接口,像调用本地对象一样调用它。

ts
// src/common/greeting-protocol.ts:前后端都会导入
export const GreetingServer = Symbol('GreetingServer');

export interface GreetingServer {
    greet(name: string): Promise<string>;
}
ts
// 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}!`;
    }
}
tsx
// 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 HTTP POST 提供在 /<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 ​

yaml
rpc:
  transport: websocket

此后每个浏览器标签页的调用走一条 WebSocket 连接(/<rpc.path>/ws),与 HTTP 一样经过中间件、会话和安全校验,后端也能反过来调用前端。前端提供的能力用 @RpcClient 声明(接口放在 src/common/,实现类放在 src/browser/ 并由前端模块导入):

ts
// 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);
    }
}
ts
// 后端某个 @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 后端的服务:

ts
import { Component } from '@celljs/core';
import { Autorpc } from '@celljs/rpc';

@Component()
export class Greeter {
    @Autorpc(GreetingServer)
    protected readonly greetingServer: GreetingServer;
}
yaml
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 文档。