React Boot前端简单实现后端SpringBoot框架的核心


项目地址:react boot github

页面地址:react boot demo

Background:

“我们前端也要有自己的SpringBoot框架”

该项目来源一个突发的灵感,Java后端的SpringBoot框架,通过简化配置、独立运行,集成IOC容器、依赖注入、控制反转、
日志异常、微服务支持等功能,显著提高了后端开发的效率和便捷性,我想是否前端也需要有这样的框架,于是我开始设计并着手开发了.

通过关注Java语法及Spring的一些特性,结合前端领域的知识,我构想了以下的一些技术方案和要点:

 1. TypeScript 、Class、Function
 2. React、TSX、SFC
 3. Vite、动态批量导入、path、env、import.meta
 4. 装饰器、注解、继承、抽象类、ICO容器,切面编程、依赖注入、控制反转
   (启动类,run方法、泛型、类型参数、自动推断、扫描的modules、reflect-metadata反射元数据、装饰器标识)
   (单例模式、模块/类实例导出、逻辑抽离、策略模式,定义接口规范,扩展点,日志异常系统)
 5. 类装饰器: @Application、 @Provider、@Consumer
 6. 函数Hooks:createApp、withProvider、useConsumer
 7. 应用卸载:destory

技术架构图简示
  • 从架构图中可以看出ReactBoot目前版本的内容比较简单,只实现了上述方案中比较核心的功能.

  • 其中“组件”代表是任何可导出的模块类型(export default any).

  • 从图中也可以看出,组件经过IOC容器后再由页面消费,这样做是否是多此一举,这也引发了我的一个思考:
    SpringBoot是因为Java需要频繁的创建和使用对象以及管理对象生命周期,所以将对象收集在IOC容器中集中管理和依赖注入,
    那么React组件/JS对象会频繁的创建销毁吗?

    • 答案是会,但是这是keep-alive组件缓存/对象缓存应该解决的问题,已经有许多解决方案了,并且组件的生命周期管理都是由React或者你自己来控制.
  • 所以这个项目并不是为了减少React应用中组件的创建销毁次数,而是为了解耦页面和组件的硬连接 (松耦合),
    这体现在容器中并不负责实例化具体的组件对象,实例化的过程交由React或者你自己的代码来完成,容器只管理你提供的任意可导出的模块引用,
    也就是说ReactBoot不关心具体的组件实例,而是关注到模块的引用.

  • 这样做有什么优势?或者说能利用这个特性来做些什么?

    • 可以用来做条件注入(多环境配置/权限控制),比如:根据条件注入不同版本的组件、函数、对象等
    • 可以用来做公共模块的复用,比如:公共组件、公共方法、公共配置等
    • 实现插件系统,构建一个允许第三方扩展的应用程序时,可以动态/远程加载不同的组件或插件
    • 微前端场景中,主应用提供各种组件能力给各个子应用注入使用
    • 集中管理容器中的组件资源,提供模块的元数据分析/看板,比如:模块名称、版本、描述、依赖关系等
    • 等等其他使用场景
  • ReactBoot的核心与React应用是隔离的,也就是非侵入式接入,本项目是一个单独的ts npm包,可以在任何React应用中引入(理论上其他JS应用也行).

    目前项目的功能不够完善,仍然处于Beta阶段,且没有详细的测试,切勿在生产项目中使用.

Install:

// npm 安装
npm install @pjqdyd/react-boot

// yarn 安装
yarn add @pjqdyd/ract-boot

How to use:

一. 在标准的Vite React项目中,构造如下目录结构的示例项目 react-boot-demo

 ├─public                        public目录
 ├─src                           源码目录
   ├─pages                       页面文件
     └─Home                      首页目录
       ├─types                   类型定义文件
       ├─async                   异步组件目录
         └─AsyncComponent.tsx    首页异步组件
       └─components              组件目录
         ├─HomeComponent.tsx     首页组件一
         └─HomeComponentTwo.tsx  首页组件二
       └─index.tsx               首页页面
   ├─router                      路由文件
   ├─utils                       工具类文件
     ├─Utils.tsx                 工具类一
     └─UtilsTwo.tsx              工具类二
   ├─main.tsx                    main.tsx入口文件
   ├─react-boot.ts               react-boot定义文件
   └─react-modules.ts            react-boot扫描的模块定义文件
 ├─index.html                    html文件
 ├─tsconfig.json                 tsconfig文件
 ├─vite.config.ts                vite配置文件
 └─package.json                  package.json文件

tsconfig.json中的compilerOptions添加如下配置,以开启装饰器语法和元数据支持:

"declaration": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,

二. 编辑react-boot.ts文件,添加如下代码,用于初始配置/定义react-boot的API及装饰器hooks:

import React from "react";
import { ReactBoot } from '@pjqdyd/react-boot'

const {
    createApp,
    Application,
    Provider,
    Consumer,
    withProvider,
    useConsumer,
    destroyApp
} = ReactBoot({
    react: React,
    name: 'app',
    description: '我的app',
    onload: () => {}
});

export { Application, Provider, Consumer, createApp, withProvider, useConsumer, destroyApp }

三. 编辑react-modules.ts文件,添加如下代码,用于定义需要扫描的模块范围

import { withProvider } from "@/react-boot.ts";
import type { Modules, AsyncModule} from "@pjqdyd/react-boot";

/**
 * 扫描的依赖模块
 * 同步导入 使用import.meta.glob {eager: true}
 * 异步导入 使用withProvider + import()
 */
const asyncComponent = () => import('./pages/Home/async/AsyncComponent.tsx');
// 导出模块
export const modules: Modules = {
    // 同步模块
    ...import.meta.glob(['./pages/Home/components/*.tsx'], { eager: true }),
    ...import.meta.glob('./utils/*.ts', { eager: true }),
    // 异步模块
    'AsyncComponent': withProvider<AsyncModule>({ name: 'AsyncComponent', isAsync: true })(asyncComponent),
};

export default modules

四. 在main.ts入口文件中,定义启动类的run方法,传入扫描的模块定义

import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { Application } from './react-boot'
import { ReactBootApplication } from '@pjqdyd/react-boot'
import Router from './router'
import './index.css'

import modules from "@/react-modules.ts";
// const modules = import('@/react-modules.ts'); // 模块定义动态导入

// 启动类
@Application({ modules: modules })
class App implements ReactBootApplication {
    run () {
        ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
            <React.StrictMode>
                <RouterProvider router={Router} />
            </React.StrictMode>
        )
    }
}

// 或者使用createApp方法启动
// const app = createApp({
//     modules: modules,
//     run: () => {
//         ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
//             <React.StrictMode>
//                 <RouterProvider router={Router} />
//             </React.StrictMode>
//         )
//     }
// })

五. 在需要注入的组件/工具类中,使用@Provider装饰器或者withProvider hooks来修饰定义,如下:

import React, { Component } from 'react';
import { Provider } from "@/react-boot.ts";
import type { HomeComponentProps } from "@/pages/Home/types";

// 首页组件
@Provider({ name: 'HomeComponent' })
class HomeComponent extends Component<HomeComponentProps, never> {
    constructor() { ... }
    render() { ... }
}

export default HomeComponent;
import React, { FunctionComponent } from 'react';
import { withProvider } from "@/react-boot.ts";
import type { HomeComponentProps } from "@/pages/Home/types";

// 首页组件2
const HomeComponentTwo = (props: HomeComponentProps) => {
    return ( ... );
}
export default withProvider<FunctionComponent>({ name: 'HomeComponentTwo' })(HomeComponentTwo);
import { Provider } from "@/react-boot.ts";

// 工具类版本一
@Provider({ name: 'Utils', version: '1.0.0' })
class Utils {
    static getDate(): string {
        return new Date().toLocaleString()
    }
}

export default Utils
import { Provider } from "@/react-boot.ts";

// 工具类版本二
@Provider({ name: 'Utils', version: '2.0.0' })
class UtilsTwo {
   static getDate(): string {
      return `${new Date().toLocaleString()} v2.0.0`
   }
}

export default UtilsTwo

六. 在需要使用组件的页面中,类组件中使用@Consumer装饰器,函数组件中使用useConsumerhooks来消费组件/工具类,如下:

import React, { Suspense } from "react";
import { Consumer } from "@/react-boot.ts";
import type { UtilsType } from "@/utils/interface.ts";
import type { HomeComponentProps } from "@/pages/Home/types";

interface IState { visible?: boolean }

// 首页
class Home extends React.Component<never, IState> {

    @Consumer({name: 'HomeComponent'})
    private HomeComponent: React.ComponentClass<HomeComponentProps>;

    @Consumer({name: 'HomeComponentTwo'})
    private HomeComponentTwo: React.FunctionComponent<HomeComponentProps>;

    @Consumer({name: 'AsyncComponent'})
    private AsyncComponent: () => Promise<{ default: React.ComponentClass<HomeComponentProps> }>;

    @Consumer({name: 'Utils', version: '1.0.0'})
    private Utils: UtilsType

    @Consumer({name: 'Utils', version: '2.0.0'})
    private UtilsTwo: UtilsType

    constructor(props) {
        super(props);
        this.state = { visible: false }
    }

    componentDidMount() {
        // 未来某一时刻加载异步组件
        setTimeout(() => this.setState({visible: true}), 5000)
    }

    render() {
        const {
            HomeComponent,
            HomeComponentTwo,
            Utils,
            UtilsTwo,
        } = this;
        const AsyncComponent = React.lazy(this.AsyncComponent);

        return (
            <React.Fragment>
                <p>{Utils.getDate()}</p>

                <p>{UtilsTwo.getDate()}</p>

                <HomeComponent title="组件一"/>

                <HomeComponentTwo title="组件二"></HomeComponentTwo>

                {this.state.visible && (
                    <Suspense fallback={<div>loading...</div>}>
                        <AsyncComponent title="异步组件" />
                    </Suspense>
                )}
            </React.Fragment>
        );
    }
}

export default Home
// 首页组件2函数组件可既作为提供者又作为消费者
const HomeComponentTwo = (props: HomeComponentProps) => {

    // 消费HomeComponent组件
    const [HomeComponent] = useConsumer<React.ComponentClass<HomeComponentProps>>({ name: 'HomeComponent' });

    return (<HomeComponent title="组件二中使用" />);
}
export default withProvider<FunctionComponent>({ name: 'HomeComponentTwo' })(HomeComponentTwo);

运行启动项目后,访问Home首页:

总结:项目启动后会在控制台打印应用的启动信息、模块的加载及注入信息、模块组件的版本及使用信息。


All Methods:

class类中的方法:


/** 应用启动类装饰器 */
Application: (target?: ApplicationTarget) => any

/** 提供者装饰器 */
Provider: (params: ProviderParams) => (target?: ProviderConstructor) => void

/** 消费者装饰器 */
Consumer: (params: ConsumerParams) => (target: any, propertyKey: string, descriptor: Descriptor) => Descriptor

函数中的方法:

/** 应用启动函数 */
createApp: (options: AppOptions) => ReactBootApplication | void

/** 提供者hooks */
withProvider: <T>(params: ProviderParams) => (target: T) => T

/** 消费者hooks */
useConsumer: <T>(params: ConsumerParams) => T[]

/** 应用销毁函数 */
destroyApp: () => void

总结:更多详细代码请参考demo项目或者源码。


Key Questions 开发中遇到/解决的关键问题

  • 模块扫描注入采用什么方案
    • 刚开始考虑过vite插件机制,通过扫描读取文件来注入模块,但这样可能会导致vite打包速度变慢,
      并且会额外增加使用成本,使用vite插件的局限性,如果要在webpack项目中使用还得提供webpack插件,
      最终采用了vite的import.meta.glob来扫描模块,通过动态批量导入的方式来注入模块,
      这样webpack项目的require.context也能够支持,加上装饰器和反射元数据来过滤需要注入的模块
  • 如何保证加载模块和消费模块的顺序
    • 因为整个ReactBoot核心都是在运行时执行,那么只需要将模块加载的过程,放在run方法(启动react应用)之前同步执行就行了
  • 如何解决加载模块循环依赖的问题
    • 使用Promise异步+生成器模式,保证模块在应用注册后,应用启动前加载,断开模块硬连接,在未来某一时刻加载模块,避免阻塞
  • IOC容器采用什么方式来实现的
    • 采用了Map集合类型来作为容器,其中抽离出了App(应用)和Component(任意可导出模块组件)的概念来作为注入的对象,
  • 如何在TypeScript中使用Reflect反射
    • TS的类型系统是静态的,类型信息只在编译时可用,而不会在JS运行时被保留,
      这与Java中可以通过反射机制在运行时获取类型信息有区别,虽然TS没有内置反射机制,
      但可以借助reflect-metadata库来实现元数据反射支持,
      元数据反射允许在类和属性上添加元数据,这些信息可以在运行时访问,通过结合装饰器来实现定义元数据
  • 如何解决异步组件模块的异步加载问题
    • 这个目前没有想到很好的解决办法,因为扫描注入模块靠的是模块提供的元数据信息,而元数据是通过装饰器修饰在模块中的,
      一旦你获取到了元数据,也就意味着你加载了模块,这就是同步加载了,存在逻辑上的矛盾,
      所以要想不加载模块的前提下获取模块的元数据,那么只能通过外部配置定义的方式来,
      目前是提供了import() + withProvider这个hooks来定义异步模块的元数据
  • TS编译后命名混淆,无法获取到class name,无法使用类名注入
    • 在Java中,可以通过类型自动注入依赖,比如:@Autowired、@Resource等,而在JS中只能使用命名注入,
      有点类似@Resource(name = "name")注入名称为name的Bean
  • 类属性注入以及hooks注入采用什么方案
    • 类属性注入采用的是属性装饰器 + Object.defineProperty代理对象的get方法来实现的,hooks注入采用了React.useRef来缓存需要注入的值
  • 如何保证在多个ReactBoot应用情况下,单个应用容器的隔离,避免注入组件覆盖和消费混用
    • 这个是在初始ReactBoot()高阶函数闭包 + Symbol符号来作为应用的key解决的,也就是说一旦在初始ReactBoot({...})时,
      定义的name是Symbol类型,那么其他应用就无法访问该应用的组件,只能通过该应用ReactBoot()导出的API来消费,从而保证了容器的隔离
  • 日志模块自定义异常如何设计
    • 目前项目中实现了info|warn|error|system这四个级别的日志,可以控制日志的打印级别,自定义异常则是在内部继承Error来定义
  • 如果你有更好的想法或意见,欢迎提issue/pr改进,欢迎大家一起参与开源,交流学习.

How to publish npm:

npm run build

npm login

// 发布@xxx私有包为公共, 使用--access publish
npm publish --access public

How to update npm:

  1. change the version.
  2. build.
  3. npm publish.

License

This project is licensed under the MIT license.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容