用React开发后端(一)

2,281 阅读4分钟
  • 作者:@heineiuo
  • 发布时间:2021-03-23

TL;DR

这篇文章和SSR没有任何关系,和React Server Component也没有关系。是字面意思地用React作为后端框架开发node.js后端,基于React解耦和管理依赖、控制组件的生命周期、用JSX直观地定义服务器状态。

出发点

后端复杂之后,无可避免地需要引入依赖管理的机制,这时候OOP语言里经典的一些设计模式就派上用场了,比如工厂模式、IoC模式。一方面为了解耦,另一方面也能提高开发效率,从无止尽地new和传递参数中解放出来。

node.js生态已经有了一些IoC实现,使用了decorators语法,比如InversifyJS(满屏幕的Java味道)。

那么,有没有可能不使用decorators实现IoC呢?这时候我又想到了Angular和React,Angular采用了类似Java DI的模式,而React却完全没有使用类似的模式,为何感觉同样拥有优雅的依赖管理?

const Theme = createContext({})
const Router = createContext({})

// Provider.js
function Provider (props){
  return (
    <Theme.Provider value={{ primaryColor: "#1d7dfa" }}>
        <Router.Provider value={{ currentPathname: "/github" }}>
          {props.children}
        </Router.Provider>
    </Theme.Provider>
  )
}

// Consumer.js
function Consumer (){
  const theme = useContext(Theme)
  const router = useContext(Router)
}

Context API分离了Provider和Consumer,使用一个hook(useContext)将Provider的value「注入」到Consumer中。使得Consumer没有在内部创建Provider实例的时候得到了Provider的value,这...不就是控制反转吗...

image.png

下一步

但是,众所周知,React是一个UI框架,是通过虚拟DOM来实现界面的。React的JSX使用的divspan这些关键词都对应着一个个的网页元素。这些年随着SSR的发展,也不乏开发者们在node.js服务端直接用ReactDOMServer去渲染出html,取代传统的模板....扯远了,既然文章的目的是实现后端,那么光搞个SSR没啥意义,至少要渲染出JSON来。

既然拥有声明式的JSX语法,那么用JSX声明一个JSON自然就是最直接的选择。这让我想到了GraphQL,GraphQL通过定义schema和resolver,能够输出一个相当自由的JSON。

那么...

那么我直接用GraphQL不就得了???

image.png

冷静一想,如果我是想用JSX声明一个JSON,其实并不能达到作为后端框架的目的,除了JSON之外的其他Response(比如text, arrayBuffer)也没那么容易用JSX声明。

转变思路,去声明一个ServerState怎么样呢?服务器上的不同组件经过JSX的嵌套,共同构成一个服务器状态。嵌套关系就是依赖关系,而Request和Response可以交给抽象成Server的单个组件去接收和处理。


<ConfigProvider>
    <DatabaseProvider>
        <ThirdPartyService>
            <SessionProvider>
                <Server onRequest={handleRequest}>
            </SessionProvider>
        </ThirdPartyService>
    </DatabaseProvider>
</ConfigProvider>

抠腚开始

其实有了上面这个结构之后,Coding是非常顺利的,Provider写起来都是固定的模式:

// Database.tsx
import { FC, useState, useContext, createContext } from "react";
import { ConfigContext } from "./ConfigContext";

class MockDatabaseClient {
  options: any;
  constructor(options: any) {
    this.options = options;
  }

  async query<T = any>(): Promise<T> {
    const result = {
      count: Math.random() < 0.5 ? 1 : 2,
    } as unknown;
    return result as T;
  }
}

type DatabaseState = {
  db: MockDatabaseClient;
};

export const DatabaseContext = createContext({} as DatabaseState);

/**
 * 子组件通过useContext(DatabaseContext)得到db。
 */
export const DatabaseProvider: FC = (props) => {
  const config = useContext(ConfigContext);
  const [db] = useState(new MockDatabaseClient(config.dbOptions));
  return (
    <DatabaseContext.Provider value={{ db }}>
      {props.children}
    </DatabaseContext.Provider>
  );
};


而Server相对特殊一点

// Server.tsx
import http from "http";
import { FC, useContext, useEffect, useRef } from "react";
import { ConfigContext } from "./ConfigContext";
import { DatabaseContext } from "./DatabaseContext";

/**
 * Won't update when `port` change.
 * Change `key` prop to close/update server.
 */
export const Server: FC = (props) => {
  const ref = useRef<any>();
  const config = useContext(ConfigContext);
  const { db } = useContext(DatabaseContext);
  const refPort = useRef<number>(config.port);

  useEffect(() => {
    ref.current = async (event: any) => {
      const result = await db.query<{ count: number }>();
      event.response.end(
        `<!DOCTYPE html><meta charset="utf8"/>hello ${Array.from({
          length: result.count,
        })
          .fill("🐈")
          .join("")} `
      );
    };
  }, [db]);

  useEffect(() => {
    const httpServer = http.createServer((req, res) => {
      if (ref.current) {
        ref.current({
          request: req,
          response: res,
        });
      }
    });

    httpServer.listen(refPort.current, () => {
      console.log(`HTTP server listening on port ${refPort.current}`);
    });

    return (): void => {
      httpServer.close();
    };
  }, []);

  return null;
};

最后一步

那么,怎么跑起来呢?

借助react-reconciler实现一个CustomRenderer是一个好方法,但是考虑到其实整个tree最终只是返回的一个null而已,可以先用简单的方法实现:


import ReactDOM from 'react-dom'
import { JSDOM, DOMWindow } from 'jsdom'
import { App } from './App'

// make ts happy
declare const global: NodeJS.Global & { window: DOMWindow; document: Document }

const dom = new JSDOM(`<div id="app"></div>`)
/**
 * ReactDOM会访问window对象,所以注册到全局。
 */
global.window = dom.window
global.document = window.document

/**
 * 之所以用#app而不直接用document.body的原因是什么呢?
 */
ReactDOM.render(<App></App>, document.querySelector('#app'))

(jsdom真是个好东西呀)

image.png

效果:

image.png

以上代码可以直接从heineiuo/react-as-backend-framework 拿到,欢迎尝试。

感想

React在很多人眼里不是一个完整的UI框架,而它在我眼里,甚至不是一个UI框架,而是一个「声明状态的框架」。所有的JSX构成的一个大树只是一个状态,这个状态可能渲染到DOM,也可能渲染到Native,也可能你和UI毫无关系,比如上面的代码里几乎全部由React Context构成,最终只返回了一个null,那这跟UI是没有丝毫关系的。

Learn Once, Write Anywhere

这次只是实现了一个hello world,体现了基于React的IoC机制,还缺少一些常用的组件,比如路由。下篇文章实现下路由。