Skip to content

Repository files navigation

ReactBoot.JS NPM

react-boot

react-boot


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/react-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:

importReactfrom"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";importtype{Modules,AsyncModule}from"@pjqdyd/react-boot";/** * 扫描的依赖模块 * 同步导入 使用import.meta.glob {eager: true} * 异步导入 使用withProvider + import() */constasyncComponent=()=>import('./pages/Home/async/AsyncComponent.tsx');// 导出模块exportconstmodules: 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),};exportdefaultmodules

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

importReactfrom'react'importReactDOMfrom'react-dom/client'import{RouterProvider}from'react-router-dom'import{Application}from'./react-boot'import{ReactBootApplication}from'@pjqdyd/react-boot'importRouterfrom'./router'import'./index.css'importmodulesfrom"@/react-modules.ts";// const modules = import('@/react-modules.ts'); // 模块定义动态导入// 启动类
@Application({modules: modules})classAppimplementsReactBootApplication{run(){ReactDOM.createRoot(document.getElementById('root')asHTMLElement).render(<React.StrictMode><RouterProviderrouter={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来定义,如下:

importReact,{Component}from'react';import{Provider}from"@/react-boot.ts";importtype{HomeComponentProps}from"@/pages/Home/types";// 首页组件
@Provider({name: 'HomeComponent'})classHomeComponentextendsComponent<HomeComponentProps,never>{constructor(){ ... }render(){ ... }}exportdefaultHomeComponent;
importReact,{FunctionComponent}from'react';import{withProvider}from"@/react-boot.ts";importtype{HomeComponentProps}from"@/pages/Home/types";// 首页组件2constHomeComponentTwo=(props: HomeComponentProps)=>{return( ... );}exportdefaultwithProvider<FunctionComponent>({name: 'HomeComponentTwo'})(HomeComponentTwo);
import{Provider}from"@/react-boot.ts";// 工具类版本一
@Provider({name: 'Utils',version: '1.0.0'})classUtils{staticgetDate(): string{returnnewDate().toLocaleString()}}exportdefaultUtils
import{Provider}from"@/react-boot.ts";// 工具类版本二
@Provider({name: 'Utils',version: '2.0.0'})classUtilsTwo{staticgetDate(): string{return`${newDate().toLocaleString()} v2.0.0`}}exportdefaultUtilsTwo

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

importReact,{Suspense}from"react";import{Consumer}from"@/react-boot.ts";importtype{UtilsType}from"@/utils/interface.ts";importtype{HomeComponentProps}from"@/pages/Home/types";interfaceIState{visible?: boolean}// 首页classHomeextendsReact.Component<never,IState>{
@Consumer({name: 'HomeComponent'})privateHomeComponent: React.ComponentClass<HomeComponentProps>;
@Consumer({name: 'HomeComponentTwo'})privateHomeComponentTwo: React.FunctionComponent<HomeComponentProps>;
@Consumer({name: 'AsyncComponent'})privateAsyncComponent: ()=>Promise<{default: React.ComponentClass<HomeComponentProps>}>;
@Consumer({name: 'Utils',version: '1.0.0'})privateUtils: UtilsType
@Consumer({name: 'Utils',version: '2.0.0'})privateUtilsTwo: UtilsTypeconstructor(props){super(props);this.state={visible: false}}componentDidMount(){// 未来某一时刻加载异步组件setTimeout(()=>this.setState({visible: true}),5000)}render(){const{
HomeComponent,
HomeComponentTwo,
Utils,
UtilsTwo,}=this;constAsyncComponent=React.lazy(this.AsyncComponent);return(<React.Fragment><p>{Utils.getDate()}</p><p>{UtilsTwo.getDate()}</p><HomeComponenttitle="组件一"/><HomeComponentTwotitle="组件二"></HomeComponentTwo>{this.state.visible&&(<Suspensefallback={<div>loading...</div>}><AsyncComponenttitle="异步组件"/></Suspense>)}</React.Fragment>);}}exportdefaultHome
// 首页组件2函数组件可既作为提供者又作为消费者constHomeComponentTwo=(props: HomeComponentProps)=>{// 消费HomeComponent组件const[HomeComponent]=useConsumer<React.ComponentClass<HomeComponentProps>>({name: 'HomeComponent'});return(<HomeComponenttitle="组件二中使用"/>);}exportdefaultwithProvider<FunctionComponent>({name: 'HomeComponentTwo'})(HomeComponentTwo);

运行启动项目后,访问Home首页:
react-boot-demo
总结:项目启动后会在控制台打印应用的启动信息、模块的加载及注入信息、模块组件的版本及使用信息。


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应用)之前完成就行了
  • 如何解决加载模块循环依赖的问题
    • 如果A组件中依赖了ReactBoot导出的函数(注解),同时A组件又作为生产者,ReactBoot需要扫描注入A组件,那么两者就形成循环依赖, 解决方法是使用Promise异步+生成器模式来扫描注入组件,将ReactBoot导出函数与扫描注入异步解耦,断开模块之间硬连接,避免阻塞, 保证A模块在ReactBoot应用注册后、React应用启动前加载,此时ReactBoot导出了函数,但由于模块扫描yield异步暂停了,所以函数具体实现没有完成,也就是 A组件可以顺利引入函数,此时的函数更像一个声明,没有具体实现,待A等组件同步引入函数代码执行完成后,异步的模块扫描next开始执行,最后在执行完成后 的then回调中启动React应用,此时模块已经扫描注入完成,函数有了具体实现,此时应用的运行时组件就能正常消费函数了
  • 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,而在node.js服务端中,通常不会混淆类型,因此可以通过类名注入
  • 类属性注入以及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.

Releases

Packages

Used by

Contributors

Languages