react-nil 逻辑渲染器

106 阅读10分钟

初探react-nil:“啥也不渲染”的React渲染器有何用?

最近在浏览React相关的开源项目时,偶然刷到了一个名为 react-nil 的自定义React渲染器。点进项目主页,第一眼看到它的核心介绍,我直接愣住了——

A custom react renderer that renders nothing, null, litterally.

翻译过来就是“一个啥也不渲染的自定义React渲染器,真的就只返回null”。当下脑子里第一反应就是:这不是脱裤子放屁吗?React的核心价值不就是声明式渲染UI吗?一个连像素都不输出的渲染器,存在的意义是什么?难不成是作者的恶作剧,或者是某个技术实验的半成品?

但转念一想,能被公开放在GitHub上,还被pmndrs(一个知名的React生态组织)维护,肯定不至于这么简单。万一我带着先入为主的偏见错怪它了呢?抱着“不能轻易否定一个不了解的事物”的心态,我强迫自己静下心来,逐字逐句地再细细品读项目文档,试图从字里行间找到它的价值所在。

果然,在核心介绍下方,作者紧接着就给出了补充说明,像是早就预料到会有人质疑一样:

There are legitimate usecases for null-components, or logical components

紧接着,一段更详细的解释彻底颠覆了我对“React组件”的固有认知:

A component has a lifecycle, local state, packs side-effects into useEffect, memoizes calculations in useMemo, orchestrates async ops with suspense, communicates via context, maintains fast response with concurrency. And of course - the entire React eco system is available.

读完这段,我才恍然大悟:原来作者想强调的,是“逻辑组件”的概念。我们平时写React组件,总是下意识地把“UI渲染”和“逻辑处理”绑定在一起——组件最终要返回JSX,要在页面上呈现出对应的元素。但这个react-nil,恰恰是把“UI渲染”这个环节彻底剥离了,只保留了React组件最核心的逻辑能力。

换句话说,借助react-nil,我们可以创建纯粹的“逻辑组件”。这种组件虽然不产生任何视觉输出,但却能完整地利用React的整个组件生命周期:从挂载、更新到卸载的全流程可控;可以维护自己的local state,不用依赖外部状态管理库就能存储临时逻辑数据;可以通过useEffect封装各种副作用操作,比如数据监听、事件绑定、资源加载与释放;可以用useMemo对复杂计算进行缓存,提升性能;还能借助Suspense协调异步操作,通过Context实现跨组件的逻辑通信,甚至利用React的并发特性保证响应速度。更重要的是,整个React生态系统的工具和库,都能直接为这种逻辑组件服务。

原来如此,它不是“啥也不干”,而是把“干的活”从“渲染UI”转移到了“纯粹的逻辑管理”上。这种将React组件的逻辑能力与UI渲染彻底解耦的思路,确实刷新了我的认知。

放下文档,我开始不由自主地琢磨:既然它能让我们用React组件的方式来管理逻辑对象,那具体能落地到哪些场景呢?脱离了UI的束缚,这种纯粹的逻辑组件,又能解决哪些我们平时开发中遇到的痛点呢?

将 react-nil 作为一种状态管理方案

顺着这个思路往下想,一个很关键的应用方向逐渐清晰起来——将react-nil作为一种轻量化的状态管理方案。

在日常的React开发中,我们常常会遇到一个棘手的问题:逻辑(组件)树的形状与视图组件树的形状往往并不一致。比如说,某个页面的视图是由头部导航、内容列表、底部Footer等多个独立UI组件组成的树形结构,但支撑这些视图的业务逻辑(比如用户信息获取、列表数据请求、筛选条件管理等),其依赖关系和组织形态可能完全是另一回事。如果强行把这些逻辑和对应的视图组件绑定在一起,就很容易形成丑陋又难以维护的代码:要么是在无关的UI组件中塞入大量不相关的业务逻辑,让组件变得臃肿不堪;要么是为了共享逻辑而随意提升状态层级,导致“prop drilling”(属性透传)的问题;更有甚者,会出现逻辑代码在多个视图组件中重复编写的情况。这也是为什么我们需要Redux、Zustand、Jotai等外部状态管理库的核心原因——本质上都是为了打破逻辑与视图的强耦合。

而react-nil的出现,恰好为这个问题提供了一种全新的解决思路。我们完全可以将业务逻辑单独抽离出来,通过react-nil来组织成独立的“逻辑组件树”。这些由react-nil驱动的逻辑组件,不需要承担任何UI渲染职责,只专注于业务逻辑的流转、状态的维护和副作用的处理——比如用useState存储核心业务状态,用useEffect发起异步请求并处理数据更新,用useMemo缓存计算结果优化性能。之后,再通过状态订阅的方式,将逻辑组件树中管理的状态精准地传递给需要这些状态的视图组件树。

这样一来,逻辑与视图就实现了彻底的解耦:逻辑组件树可以按照业务逻辑的自然依赖关系自由组织,不用受限于视图的结构;视图组件树则可以纯粹地专注于UI渲染,只需要被动订阅所需的状态,不用关心状态的产生和流转过程。这种分离不仅让代码结构更清晰、维护成本更低,而且相较于传统的外部状态管理库,react-nil完全复用了React自身的API(如useState、useEffect、Context等),不需要我们学习新的语法和概念,开发成本也更低。

举个简单的例子,假设我们开发一个电商商品列表页面,视图上有商品列表组件、筛选器组件、分页组件。支撑这些视图的逻辑包括:商品数据的请求与缓存、筛选条件的变更管理、分页参数的同步等。如果用react-nil来做状态管理,我们就可以创建一个由react-nil驱动的“商品列表逻辑组件”,把数据请求、条件管理等逻辑都封装在这个组件内部;然后通过React Context或者自定义的订阅钩子,让视图层的列表、筛选器、分页组件分别订阅自己需要的状态(列表数据、当前筛选条件、分页信息)。当逻辑组件内部的状态发生变化时,只会通知对应的视图组件更新,既保证了状态流转的清晰,也避免了不必要的UI重渲染。

思考一个舒服的使用姿势

基于这样的核心思路,我开始尝试基于react-nil封装一个更易用的逻辑组件模型,让这种“逻辑与视图分离”的开发模式更符合日常开发习惯。结合对 react-nil 能力的粗浅理解,我构思了一套直观的代码用例,核心是通过createModel 函数封装逻辑组件的定义、创建、关联和状态订阅等能力,具体如下:

function createModel(options: any) {
    // 忽略实现
}

const AModel = createModel({
    // 初始状态
    initState: (id, props) => ({
        // ...
    }),
    hook(state, setState) {
        useEffect(() => { }, [state.someProp]);
        const memoState = useMemo(() => { }, [state.someProp]);

        return { memoState }
    }
})

const BModel = createModel({
    // ... 同上,忽略
})

await AModel.create(id, {}); // 挂载到根组件
await BModel.create(id2, {}, AModel); // 挂载到 AModel 而非全局

const instance = AModel.get(id);

instance.getChildren(BModel); // Map
instance.useChildren(BModel); // 订阅 Map

instance.removeChildren(BModel, id2); // 移除指定挂载的其他逻辑

instance.useState();          // 在视图组件中使用 {...state, memoState}
instance.useState(state => state.memoState);  // 支持 selector

instance.destroy(); // 移除所有挂载的其他逻辑(包括 children)

这套代码用例的设计思路,完全是为了适配react-nil的“逻辑组件”特性。首先通过createModel函数定义逻辑模型,传入initState用于初始化状态(支持接收id和props动态生成初始值),hook函数则是核心逻辑载体——这里可以直接使用useEffect、useMemo等React Hooks,封装副作用处理和计算缓存逻辑,最终返回需要对外暴露的衍生状态。

在使用层面,通过create方法可以创建逻辑组件实例,并且支持指定父级模型(比如将BModel挂载到AModel下),形成层级化的逻辑组件树,这和React组件树的层级关系逻辑一致,但不涉及任何UI渲染。创建完成后,通过get方法获取实例,就能进行一系列操作:获取或订阅子逻辑组件(getChildren/useChildren)、移除子组件(removeChildren)、订阅状态(useState,支持传入选择器精准订阅部分状态),以及销毁实例(destroy)释放资源。

这样的封装设计,核心是把react-nil的底层能力封装成更贴近业务开发的API。开发者不用直接操作react-nil的渲染逻辑,只需要通过createModel定义业务逻辑,通过实例方法管理状态和组件关系,就能充分利用React的Hooks生态和生命周期能力,同时保持逻辑与视图的彻底分离。

最后来实现 createModel

这里的代码是更新过的,之前那版用豆包生成的无法正常运行,这一版直接使用 react-dom 的 createRoot 单独挂载逻辑树,可以达到一样的效果

import { createRoot } from "react-dom/client";
import { BehaviorSubject, map, skip, Subject, tap } from "rxjs";
import {
  useForceUpdate,
  useObservable,
  useObservableEagerState,
  useObservableState,
  useSubscription,
} from "observable-hooks";
import { Fragment, useMemo, useState } from "react";
import { identity, isEqual } from "lodash-es";
import { shallowEqual } from "../utils/equal";

interface IHookApi<T> {
  state: T;
  setState: (state: Partial<T> | ((prevState: T) => Partial<T>)) => void;
  id: string;
}

interface IModelOption<
  T extends object,
  P extends object,
  R extends object = T
> {
  initState: (id: string, param: P) => T;
  hook?: (params: IHookApi<T>) => R;
}

interface IModel<T extends object, P extends object, R extends object = T> {
  _id: string;
  create(
    id: string,
    param: P,
    parent?: IModelInstance<any>
  ): IModelInstance<T, R>;
  get(
    id: string,
    parent?: IModelInstance<any>
  ): IModelInstance<T, R> | undefined;
}

interface IModelInstance<T extends object, R extends object = T> {
  id: string;
  alive: boolean;
  children: Map<IModel<any, any>, IModelInstance<T>[]>;
  childrenChange: Subject<void>;
  hook?: (params: IHookApi<T>) => R;
  state: BehaviorSubject<T>;
  mergeState: BehaviorSubject<T & R>;
  getState(): T;
  setState: (state: Partial<T> | ((prevState: T) => Partial<T>)) => void;
  getHookState(): T & R;
  useState<Ret = T & R>(selector?: (state: T & R) => Ret): Ret;
  getChildren<T1 extends object, P1 extends object, R1 extends object = T1>(
    model: IModel<T1, P1, R1>
  ): IModelInstance<T, R>[];
  useChildren<T1 extends object, P1 extends object, R1 extends object = T1>(
    model: IModel<T1, P1, R1>
  ): IModelInstance<T, R>[];
  removeChildren(model: IModel<any, any, any>, id: string): void;
  destroy(): void;
}

const rootModelInstance: IModelInstance<any> = {
  id: "root",
  alive: false,
  children: new Map(),
  childrenChange: new Subject<void>(),
  hook: undefined,
  state: new BehaviorSubject<any>({}),
  mergeState: new BehaviorSubject<any>({}),
  getState() {
    return this.state.value;
  },
  useState() {
    return this.state.value;
  },
  setState() {},
  getHookState() {
    return this.mergeState.value;
  },
  getChildren(model: IModel<any, any>) {
    return this.children.get(model) || [];
  },
  useChildren(model: IModel<any, any>) {
    useObservableEagerState(this.childrenChange);
    return this.children.get(model) || [];
  },
  removeChildren(model: IModel<any, any>, id: string) {
    const children = this.children.get(model) || [];
    this.children.set(
      model,
      children.filter((child) => child.id !== id)
    );
    this.childrenChange.next();
  },
  destroy() {
    this.children.forEach((childrenType) => {
      childrenType.forEach((child) => child.destroy());
    });
    this.children.clear();
    this.childrenChange.next();
    this.childrenChange.complete();
    this.alive = false;
  },
};

const LogicTree = ({ node }: { node: IModelInstance<any> }) => {
  useObservableState(node.childrenChange);
  const state = useObservableEagerState(node.state);

  const ret = node.hook?.({ state, setState: node.setState, id: node.id });

  useMemo(() => {
    const nextMergeState = { ...node.getState(), ...ret };

    if (!isEqual(nextMergeState, node.mergeState.value)) {
      node.mergeState.next(nextMergeState);
    }
  }, [ret]);

  const items = [...node.children.entries()];

  return (
    <>
      {items.map(([model, children]) => (
        <Fragment key={model._id}>
          {children.map((child) => (
            <LogicTree key={child.id} node={child} />
          ))}
        </Fragment>
      ))}
    </>
  );
};

function ensureLogicTreeMounted() {
  if (rootModelInstance.alive) {
    return;
  }
  rootModelInstance.alive = true;
  const rootContainer = createRoot(document.createElement("div"));
  rootContainer.render(<LogicTree node={rootModelInstance} />);
}

export default function createModel<
  T extends object,
  P extends object,
  R extends object
>(option: IModelOption<T, P, R>): IModel<T, P, R> {
  ensureLogicTreeMounted();

  const { initState, hook } = option;

  const model: IModel<T, P, R> = {
    _id: Math.random().toString(36).substring(2),
    create(id: string, param: P, parent?: IModelInstance<any>) {
      if (!parent) {
        parent = rootModelInstance;
      }

      const exist = parent.getChildren(model).find((child) => child.id === id);
      if (exist) {
        return exist as IModelInstance<T, R>;
      }

      const state = new BehaviorSubject(initState(id, param) as T);
      const mergeState = new BehaviorSubject(initState(id, param) as T & R);

      const instance: IModelInstance<T, R> = {
        id,
        alive: true,
        children: new Map(),
        childrenChange: new Subject<void>(),
        hook,
        state,
        mergeState,
        getState() {
          return state.value;
        },
        setState(s: Partial<T> | ((prevState: T) => Partial<T>)) {
          let nextState = typeof s === "function" ? s(state.value) : s;
          nextState = { ...state.value, ...nextState };
          state.next(nextState as T);
        },
        getHookState() {
          return mergeState.value;
        },
        useState<Ret = T & R>(selector?: (state: T & R) => Ret): Ret {
          useForceUpdate();

          selector = selector || identity;
          const [v, set] = useState(() => selector(mergeState.value));
          useSubscription(
            useObservable(() =>
              mergeState.pipe(
                skip(1),
                map(selector),
                tap((s) => {
                  !shallowEqual(s, v) && set(s);
                })
              )
            )
          );
          return v;
        },
        getChildren: function (model: any) {
          return instance.children.get(model) || [];
        } as any,
        useChildren: function (model: any) {
          useObservableEagerState(instance.childrenChange);
          return instance.children.get(model) || [];
        } as any,
        removeChildren(model, id) {
          const children = instance.children.get(model) || [];
          instance.children.set(
            model,
            children.filter((child) => child.id !== id)
          );
          instance.childrenChange.next();
        },
        destroy() {
          instance.children.forEach((childrenType) => {
            childrenType.forEach((child) => child.destroy());
          });
          instance.children.clear();
          instance.childrenChange.next();
          parent.removeChildren(model, id);
        },
      };

      if (hook) {
        const children = parent.children.get(model) || [];
        children.push(instance);
        parent.children.set(model, children);
        parent.childrenChange.next();
      }
      return instance;
    },
    get(id: string, parent?: IModelInstance<any>) {
      if (!parent) {
        parent = rootModelInstance;
      }
      const children = parent.getChildren(model);

      return children.find((child) => child.id === id);
    },
  };

  return model;
}