用 TSX 写 pingo
配置
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@dopejs/pingo"
}
}
jsx 用的是 TypeScript 的自动运行时;jsxImportSource 把它指向 pingo 的
jsx-runtime,而不是 React 的。名字里的 react-jsx 只是转换模式的名称,与 React 无关。
什么可以当标签
import { createContext, memo, Text, useState, View, type PingoNode } from "@dopejs/pingo";
import { Button } from "@dopejs/pingo-ui";
const Theme = createContext("light");
function Row({ label }: { readonly label: string }): PingoNode {
const [count, setCount] = useState(0);
return (
<View width={240} padding={8}>
<text value={`${label} ${count}`} />
<Button onPress={() => setCount(count + 1)}>加一</Button>
</View>
);
}
root.render(
<Theme.Provider value="dark">
<Row label="点击次数" />
</Theme.Provider>,
);
五种形式都可以:
| 形式 | 例子 |
|---|---|
| 内建元素 | <container>、<text>、<scroll>、<editableText> |
| 基础组件 | <View>、<Text>、<Image>、<Input> |
| 自己写的函数组件 | <Row label="…" /> |
memo 包装的组件 |
@dopejs/pingo-ui 的全部组件 |
| context provider | <Theme.Provider value={…}> |
用 hooks 的组件必须挂载,不能直接调用
Row({ label }) 能通过类型检查,但会以
hooks may only run in a function component 失败——hooks 需要 reconciler 建立的
组件作用域。写成 <Row label="…" /> 就对了。
返回类型标 PingoNode 是可以的。它包含 undefined,PingoNode 与 JSX 标签的兼容
由引擎的 JSX.ElementType 声明,不需要你改写签名。
与 React 共存
一个仓库里同时有 React 和 pingo 的 TSX 文件是常见情况——例如用 React 写外壳、用 pingo 画高性能区域。
机制是文件头声明
jsxImportSource 的粒度是文件。把 pingo 文件的第一行写成:
/** @jsxImportSource @dopejs/pingo */
项目的 tsconfig.json 保持 React 配置,加了这行的文件走 pingo 运行时。tsc、
esbuild/Vite、babel 都认这一条。
其它两种想法都不成立,实测:
| 做法 | 结果 |
|---|---|
目录里放一个改了 jsxImportSource 的 tsconfig.json |
tsc 完全忽略它,而 Vite 会认——构建与类型检查结论不一致 |
用 exclude 按文件名排除 |
exclude 只影响根文件选择;React 文件一 import,它就被拉回来按 React 编译 |
要让文件名真正驱动工具链,需要 composite project references(pingo 项目产出
.d.ts,React 项目消费声明而不是源码)。
忘了写这一行不会静默出错,而是编译期报错:
error TS2322: Type 'Element' is not assignable to type 'PingoNode'.
error TS2786: 'View' cannot be used as a JSX component.
文件名后缀是约定
两种文件放在同一个目录时,建议给 pingo 文件加后缀,例如 scene.pingo.tsx——文件
列表里一眼能分辨,也方便 babel overrides 之类按文件名做配置。它是给人和配置看的
约定,不能代替文件头声明。整个目录都是 pingo 的时候,目录本身就是信号,再加后缀
只是噪音。
边界就是文件边界
一个文件只能有一种 JSX,所以 React 组件里写不了 pingo 标签。pingo 文件导出场景, React 文件引入它:
/** @jsxImportSource @dopejs/pingo */
// scene.pingo.tsx
import { Text, View, type PingoNode } from "@dopejs/pingo";
export function scene(label: string): PingoNode {
return (
<View width={240} height={80} padding={12}>
<Text value={label} />
</View>
);
}
用 PingoContainer 挂载
// App.tsx —— 这个文件的标签是 React 的
import { PingoContainer } from "@dopejs/pingo/react";
import { scene } from "./scene.pingo";
export function App() {
return <PingoContainer scene={scene("Hello")} style={{ height: 320, width: 480 }} />;
}
场景通过 scene 属性而不是 children 传入——这个文件的标签属于 React,写不出 pingo
的 children。
PingoContainer 自己创建 canvas,而不是让 React 渲染 canvas 再取 ref。这一条是必须
的:root 会把 canvas 转移给 OffscreenCanvas,转移是永久的,而 React StrictMode 在开发
环境把 effect 跑两遍——React 拥有的那个 canvas 会被交给第二个 root,然后失败:
this canvas already transferred control to an OffscreenCanvas and cannot host
a second root; create a new canvas element per root
组件内部创建的 canvas 会随被丢弃的那次挂载一起丢掉,所以不会遇到这件事。尺寸也不用管: root 会跟随 canvas 自己的盒子,用 CSS 给容器尺寸就够了。
需要拿到 root(滚动控制、诊断回调)时用 onRoot;启动失败用 onStartupError,运行期
错误仍然走 options.onHostError。
两棵树不共享状态
React 的 state 与 context 不会流进 pingo 组件树,反过来也一样。它们是两个独立的
reconciler。跨边界通信就是普通的数据流:React 侧算好值,作为 scene 传进去;pingo 侧
通过事件回调把结果送回来。
本仓库就是例子
apps/site 是一个 React 应用,同时包含 73 个 pingo TSX 组件预览。混放的目录是
apps/site/src/interop,
它的测试在 StrictMode 下运行。