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

参考资料



经验分享      React

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!