React的jsx语法很香,它的声明式API可以帮助我们摆脱命令式的繁琐,从而以方便简洁的方式去书写程序。本文将以文件系统为例,简单演示一下如何去自定义一个 react render
预期目标
举个例子🌰
const App = () => (
<Directory name="aes">
<Directory name="mmp">
<File name="poke" />
</Directory>
<File name="cbc">
abcd1234
</File>
<Directory name="rua" />
</Directory>
)
ReactFS.render(<App />, process.cwd())
前置知识
什么是jsx
众所周知,jsx是一种特殊的语法,其可以在html中直接写js逻辑。但值得注意的是,jsx并不是标准的js语法,但因为react太过流行,所以像TS、Babel这样的编译器都具备了jsx语法transform的功能。那么把jsx语法转换过后,就是 React.createElement()
形式了,变成了普通的函数调用形式
Component vs Element vs Instance
这三者的关系是有略微不同的,简单来讲:
- Instance是实际渲染中,渲染出来的东西(类比于DOM元素)
- Element是一个JS对象,通过 type、props等字段来去表示这个元素(类比于vDOM)
- Component是React组件,他通过 React.createElement 或者JSX语法,创建一个Element并返回
JSX组件,标签的resolve方式
- 标签的type就是一个字符串,则被认为是原生组件,直接 createInstance 创造实例,如果标签首字母是小写,那么也认为它是字符串(这种情况就像字面量,不需要定义,直接用)
- 标签的type是 Function(函数组件) 或者 Class(类组件),这时候React会再递归地寻找对应组件的实现,直到找到原生组件为止
实际开发
主要可以参考 https://github.com/nitin42/Making-a-custom-React-renderer 的教学资料来进行。
Instance确定
我们的目标是做一个文件系统,那么第一步是确定Instance的类型结构,显然文件系统有两种类型,文件夹(Directory)与文件(File),这两个Class可以继承自同一个基类
export abstract class BaseInst {}
export class FileInst extends BaseInst{}
export class DirectoryInst extends BaseInst{}
对于Instance来说,还缺少props,显然文件夹与文件都需要名字,所以给基类加上props定义
export interface IBaseProps {
name: string
children?: React.ReactNode
}
export abstract class BaseInst<T extends IBaseProps = IBaseProps> {
constructor(public props: T) {}
}
Container确定
Container是整个jsx的最上层,承载一些全局的信息来供子Instance去调用,所以,在创建Instance时,也需要把Container一并传过去
export default class FSContainer {}
export abstract class BaseInst<T extends IBaseProps = IBaseProps> {
constructor(public root: FSContainer, public props: T) {}
}
Reconciler配置
这个是控制react节点的创建、删除的核心,因为文件系统不涉及刷新,只是一个静态的render,所以很多配置可以简化。
关注critical的部分即可
export const FSReconciler = ReactReconciler<
string, // type
any, // props
FSContainer, // container
BaseInst, // instance
Buffer, // text instance
any, // suspense instance
any, // hydratable instance
any, // public instance
any, // host context
any, // update payload
any, // child set
any, // timeout handle
any // no timeout
>({
appendInitialChild(parent, child) {
// critical,将子Instance加入父Instance的动作
},
createInstance(type, props, rootContainer) {
// critical,创建Instance的动作
},
createTextInstance(text) {
// critical,创建文字Instance的动作
},
getPublicInstance(inst) {
return inst
},
getRootHostContext() {
return emptyObject
},
getChildHostContext() {
return emptyObject
},
appendChild(parent, child) {
// critical,同appendInitialChild
},
clearContainer(_container) {
},
appendChildToContainer(container, child) {
// critical,把最上层的Instance加入container的动作
},
now: Date.now,
supportsMutation: true,
supportsHydration: false,
supportsPersistence: false,
isPrimaryRenderer: true,
cancelTimeout() {},
finalizeInitialChildren() { return false },
prepareForCommit() { return null },
prepareUpdate() { return null },
noTimeout() {},
queueMicrotask() {},
preparePortalMount() {},
shouldSetTextContent() { return false },
scheduleTimeout() {},
resetAfterCommit() {},
})
创建顺序
这是一个很重要的问题,需要明确的是,Instance的创建顺序是从最底层->最顶层的顺序的。
所以,无法在 appendChild
的时候进行文件的创建,因为这个时候instance还不知道文件的完整路径。
我们需要换一种方式,即Instance内部再去维护一个children数组,等到最后进行render的时候,再有由顶层往下进行文件的创建
这其实是种 anti-pattern,react官方并不推荐这样做
如何调用JSX
我们现在定义的都是Instance,那么该怎么在jsx中用上这些instance呢?直接在标签里写肯定是不行的,因为FileInst、DirectoryInst并没有 render
, setState
这样的方法,并且它们是复合类型,所以Reconciler会先resolve它们的render结果,但Instance并没有render方法,这样就导致了什么也没有做。
正确的方案是参考html的标签,它们是定义在了 JSX.IntrinsicElements
的interface中,那么我们也可以利用同名interface的Merge特性,把我们的标签名和props类型加上
declare global {
namespace JSX {
interface IntrinsicElements {
File: IBaseProps,
Directory: IBaseProps
}
}
}
最终效果
让我们运行最开头的代码,可以看到将会在 ./aes
中创建如下的结构
❯ tree aes
aes
├── cbc
├── mmp
│ └── poke
└── rua
2 directories, 2 files
其中 ./aes/cbc
中内容即为 abcd1234
参考资料
- 完整代码:https://github.com/fun4wut/react-yafs
- Awesome React Renderer: https://github.com/chentsulin/awesome-react-renderer
- 比较与理解React的Components,Elements和Instances: https://github.com/creeperyang/blog/issues/30
本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!