1.1.0 • Published 4 years ago

@runnan/obvious v1.1.0

Weekly downloads
-
License
MIT
Repository
github
Last release
4 years ago

obvious.js

轻量级的微前端框架

示例

  • 介绍

微前端架构,也常被叫做前端微服务架构,其实这并不算一个很新的概念,已经有许多公司做过相应的实践。要说前端微服务自然绕不开后端微服务,一个技术或概念的出现并不是凭空产生,而是为了解决开发中存在的痛点和问题。在后端没有微服务架构的时候,随着系统体量的增加,整个项目中大大小小的功能模块集中在一起,变得非常臃肿,难以维护。同时单体系统的各个功能模块依赖于同样的数据库、内存资源等,一旦某个模块对资源使用不当,整个系统都被拖垮。同时在对系统做集群扩展的时候,只能对整个系统进行水平扩展,而不能只针对性地对造成性能瓶颈的模块进行扩展。正是由于这样的原因,后端才出现了微服务的架构,即各个功能模块单独开发和部署,可以用不同的编程语言编写,占用独立的资源,每一个服务都跑在自己的进程中,微服务之间通过轻量级机制(http restful接口, rpc调用等)进行消息通信,最终多个微服务共同组成完整的系统。这样系统的灵活性大大增加,同时在团队组织和开发方式上,各个微服务组可以自由选择编程语言,相对独立地进行开发和维护,大大减少了管理成本。

说回前端微服务,React、Angular、Vue等前端框架出现后,DOM操作已经由框架帮你做了,另外页面渲染也几乎被javaScript完全cover, 在jQuery大行其道的时代,前端开发是先有页面结构,再用数据填充结构,而三大框架出现后,前端开发模式变成了先有数据,再根据数据生成页面结构。因此自然可以开发出愈发复杂的前端应用,而我认为真正让前端地位出现质的提升的一个标志是单页应用(SPA)的出现,在没有SPA的时候,页面之间的数据传递一定要经过后端,而单页应用时代,复杂的页面数据传递被前端cover之后,前端的体量一下子可以变得很大,特别是一些toB的应用,可能动辄有四五十页需要管理。这时跟后端同样的问题也就出现了,虽然React、Vue、Angular各自都有成熟的状态管理和单页框架,但是如果要做一个大的前端系统,就只能选择一套技术栈,当然了,在前端领域,一般一个公司都会用相对统一的技术栈,但是另一个问题是这种模式下所有人都得往同一个仓库推代码,对于一个四五十页的应用来说,这样的架构恐怕过于臃肿了。

因此就有了前端微服务的概念,我们希望有这样一种架构:前端应用被划分为相对独立的微服务,对前端来说,一个微服务可以认为是一段可执行的js代码。这些微服务可以被独立开发,独立伺服,最终以script标签的形式引入同一个html中,然后各自渲染自己负责的部分,最重要的是,这些微服务应该能够相互通信。后端微服务的通信可以通过rest接口等方式实现,但是对于前端来说,微服务之间的通信不可能走网络请求,这样的代价太大了,甚至可以说本末倒置。那么前端微服务之间应该怎么通信呢?似乎js在后端的存在形式——Node.js已经给出了答案,Node.js基于EventEmitter构筑了完整的事件通信机制,同样是javaScript, 或许我们也可以在前端做同样的事情——这就是Obvious.js

  • 概念

    • EventEmiter: 事件收发器,类似Node.js的EventEmitter, 提供事件通信能力,是Obvious的核心
    • Bus: 消息总线,每new一个Bus实例,实例内部都有一个EventEmitter,可以被传递给微服务, 同时可以配置微服务的伺服路径,由Bus拉取js代码执行
    • Socket: 消息接口,一个微服务与其他微服务通信的前端套接字。在EventEmitter提供的消息通信的基础上,封装了一层状态通信能力
    • App: 前端微服务,负责页面渲染或其他功能的前端代码,通过socket与其他app通信, app名与socket必须同名

    架构

  • 使用

  1. 下载依赖包:

    npm install @runnan/obvious // 请从1.0.3版本开始使用
  2. 在平台微服务中创建bus, 并配置bus所管理的微服务的资源:

    // 平台服务
    import { createBus } from '@runnan/obvious';
    
    // 调用完createBus后,window.__Bus__上将会挂载一个名为global的bus
    createBus('global', {
        service1:{
            js: ['/assets/service1/vendor.js', '/assets/service1/entry.js'],
            css: ['/assets/service1/entry.css']
        },
        service2: {
            js: ['/assets/service2/vendor.js', '/assets/service2/entry.js'],
            css: ['/assets/service2/entry.css']
        }
    });
  3. 微服务开发团队在约定好bus之后,分头开发微服务: 在微服务的入口文件中用 bus.createSocket创建socket, 并用socket读写状态和收发事件来与其他微服务通信

    ```javaScript
    // http://localhost/assets/service1/entry.js
    import { getBus } from '@runnan/obvious';
    
    const bus = getBus('global');
    
    bus.createSocket('service1', [], (socket, config) => {
        /**
         * callback函数里写微服务的业务逻辑: 与技术栈无关,可以用react、vue、angular
         * 渲染页面,也可以用jQuery或者原生js操作dom; 甚至也可以专门
         * 用来发ajax请求。因此微服务的本质只是一段可执行的javaScript
         * 代码, obvious提供的是用socket与其他微服务通信的能力
         */
    
        // 监听事件
        socket.on('service2BroadCast', (message) => {
            console.log(`get service2 broadCast: ${message}`);
        });
    
        // 操作dom
        document.getElementById('addCount').onclick = () => {
            const currentCount = socket.getState('count');
            socket.setState('count', currentCount + 1);
        };
    
        // 操作状态
        socket.initState('service1_ready', true);
    });
    ```
    
    ```javaScript
    // http://localhost/assets/service2/entry.js
    import { getBus } from '@runnan/obvious';
    
    const bus = getBus('global');
    
    bus.createSocket('service2', ['service1_ready'], (socket, config) => {
        // 读取配置信息
        socket.initState('count', config.initCount);
    
        // 触发事件
        socket.emit('service2Broadcast', 'hello, I am service2');
    });
    ```
  4. 在平台服务中通过bus.startApp()启动子微服务: 在创建bus时,已经配置过微服务的css和js资源有哪些,所谓启动微服务,就是先依次加载配置的css资源,再依次加载并执行配置的js资源。

    ```javaScript
    import { getBus } from '@runnan/obvious';
    
    const bus = getBus('global');
    
    bus.startApp('service2', {initCount: 1}).then(() => {
        console.log('成功拉起service2');
    });
    
    /**
     * 虽然拉起service1在拉起service2之后执行
     * 但是由于ervice2依赖状态service1_ready,
     * 所以实际是service1中执行完socket.initSta('service1_ready', true);
     * 之后,才执行service2中的回调
     */
    bus.startApp('service1').then(() => {
        console.log('成功拉起service1');
    })
    
    ```

5.高阶应用:资源配置中间件: 微前端架构是为了适应大型前端系统,比如一个OA系统,一个运维监控系统,一个大型的在线IDE, 这些复杂系统的前端有可能将被划分成20至30个前端微服务,如果这些微服务的js和css资源路径只能像上面的示例中一样,在创建bus时通过硬编码进行配置的话,显然是非常不灵活的。为了适应这种更加复杂的场景,createBus预留了第三个参数,让开发者能通过实现中间件的方式,更优雅地实现微服务资源注册和加载。 比如,你作为X公司某产品的总架构师,要求所有前端微服务的js资源最终都只打包成一个文件,所有css资源最终也都只打包成一个文件,且文件名都是微服务名,部署到https://cdn.x.com,那么你在创建bus时,只需要传入这样一个中间件函数,就能方便地启动遵照这个规范部署的前端微服务了。

```javaScript

// 一个最简单的资源加载中间件
const async XsimpleMiddleware = (name, loadJs, loadCss) => {
    await loadJs(`https://cdn.x.com/assets/js/${name}.js`);
    await loadCss(`https://cdn.x.com/assets/css/${name}.css`);
}

createBus('global', null, XsimpleMiddleWare);

bus.startApp('service1');
```
  • API

    • Socket:

      • socket.on(): 监听事件

        参数名是否必选类型描述
        eventNamestring事件名
        callbackFunction回调函数
      • socket.off(): 解绑回调函数

        参数名是否必选类型描述
        eventNamestring事件名
        callbackFunction回调函数

        同Node.js的EventEmiter一样,解绑时的回调函数必须和监听事件时绑定的回调函数是同一个

      • socket.emit(): 触发事件

        参数名是否必选类型描述
        eventNamestring事件名
        ...args不定长参数传递给事件回调函数的参数
      • socket.initState(): 初始化状态

        参数名是否必选类型描述
        stateNamestring状态名
        valueany状态值
        privateboolean是否是私有状态, 默认为false, 如果为true,则其他socket将不能修改该状态的值
      • socket.getState(): 获取状态

        参数名是否必选类型描述
        stateNamestring状态名
      • socket.setState(): 修改状态

        参数名是否必选类型描述
        stateNamestring状态名
        valueany状态值

        一个状态必须在init之后才能被set,否则将报错,如果状态在init时被声明为私有状态,则只有init该状态的socket才可以修改它的值

      • socket.watchState(): 监听状态

        参数名是否必选类型描述
        stateNamestring状态名
        callbackFunction回调函数, 接收两个参数,分别是newValue和oldValue
      • socket.unwatchState(): 取消监听状态

        参数名是否必选类型描述
        stateNamestring状态名
        callbackFunction回调函数, 接收两个参数,分别是newValue和oldValue

        解绑时的回调函数必须和监听事件时绑定的回调函数是同一个

      • socket.name: socket的名字

    • Bus:

      • Bus():构造函数

        参数名是否必选类型描述
        assets{ appName: string: { js: string[], css: string[] } }配置要拉取的微服务的静态资源
        middleware(appName: string, loadJs?: Function, loadCss?: Function) => Promise配置如何拉取js资源的中间件

        在Bus构造函数中, 可以通过assets手动配置静态资源,只需要配置资源路径即可 middleware是一个函数,接收三个参数,第一个参数是必选的app名, 插件开发者需要根据app名拉取对应的js和css资源, 插件可接收obvious提供的两个参数loadJS和loadCss, 这两个参数都是函数,入参是资源路径,loadJS(src)将创建script标签,加载src下的js代码并执行, loadCss(src)将加载src下的css资源并插入link标签, 插件最后需要返回一个Promise。 如果同时assets和middleware都配置了同一个微服务的资源,则assets的配置生效。 关联API: createBus

      • startApp():拉起app并启动(执行对应的js代码)

        参数名是否必选类型描述
        appNamestringapp名,必须与app内声明的socket同名
        configanyapp配置, 将在app对应的socket被create时被传给socket,如果多次start同一个app,则只有第一次传入的config生效

        startApp将返回一个Promise, 如果app是第一次被拉起,则bus会加载app对应的资源,等资源加载并执行成功后才进入promise的then回调, 但是如果app已经被start过一次,则执行startApp将直接进入then回调,且不会把config配置传递给对应的微服务。

      • createSocket(): 创建前端套接字

        参数名是否必选类型描述
        socketNamestringsocket名,必须与app同名
        dependenciesstring[]app依赖的状态列表,如果不依赖任何状态则传入一个空数组即可(状态参见socket介绍)
        callbackFunction执行app逻辑的函数,例如用React将视图渲染进一个div中。接收两个参数, 第一个参数是app对应的socket实例,用于与其他app通信, 第二个参数是Bus在startApp时传入的config对象,用于初始化app
        timeoutnumber依赖状态的超时时间,默认为10*1000ms

        startApp和createSocket需要配合使用,createSocket通常是某个微服务代码的入口函数,在createSocket的callback内执行app具体逻辑,例如有一个printString微服务,作用是将字符串{{config.text}}渲染到id为{{config.container}}的div中, 其中config由微服务被拉起时初始化,假设该微服务的代码伺服在/printString/assets/js/index.js下, 则需要在平台服务中创建Bus并拉起微服务:

        window.globalBus = new Bus({
            printString: {
                js: ['/printString/assets/js/index.js']
            }
        });
        window.globalBus.startApp('printString', {
            text: 'hello world',
            container: 'container'
        }).then(() => {
            console.log('successfully start');
        });

        而/printString/assets/js/index.js中的逻辑则是:

        window.globalBus.createSocket('printString', [], (socket, config) => {
            ReactDOM.render(<div>{config.text}</div>, document.getElementById(config.container));
        });
      • getSocket(): 获取Bus管理下的对应名字的socket的实例

        参数名是否必选类型描述
        socketNamestringsocket名
      • state:Bus管理下的所有state。该值是总线状态的一个映射,是只读的,要修改状态必须通过socket.initState和setState进行修改,直接修改bus.state会抛出异常

    • createBus()

      参数名是否必选类型描述
      namestringbus的名字
      assets{ appName: string: { js: string[], css: string[] } }配置要拉取的微服务的静态资源
      middleware(appName: string, loadJs?: Function, loadCss?: Function) => Promise配置如何拉取js资源的中间件

      正如createSocket中的样例代码所示,独立部署的两个微服务要进行通信,需要基于同一个bus实例,为了达到这个目的,在new Bus()创建出bus实例后,我们把这个实例手动挂载到window对象上,这样带来的一个问题是,当有多个团队分别基于多个bus进行通信时,有可能会不小心命名出重名bus,出现全局变量冲突。因此,obvious提供了createBus函数,它会创建一个Bus,并将其挂载到window.__Bus__上,例如,执行createBus('global')将会new一个Bus实例并挂载在window.__Bus__上,然后可以通过getBus('global')获取bus实例,执行其他操作。推荐使用createBus来创建bus, 因为不必添加额外的全局变量,且window.__Bus__做了属性保护,是只读的,挂载在window.__Bus__上的属性也是只读的,可以保证bus实例创建并挂载后不会被修改。用createBus创建同名bus,在运行时会抛出错误提示。

    • getBus()

      参数名是否必选类型描述
      namestringbus的名字

      获取bus实例,与createBus搭配使用

  • 预置状态

    • ${appName}: 表示名字是appName的微服务就绪。这个状态在用createSocket创建出app对应的socket,且回调函数执行完后被init,常用于声明微服务依赖 例子: 有两个微服务A和B,基于demoBus进行消息通信 微服务A在启动时监听printHelloWorld事件:

      ```javaScript
      import {getBus} from '@runnan/obvious';
      
      const bus = getBus('demoBus');
      bus.createSocket('A', [], (socket) => {
          socket.on('printHelloWorld', () => {
              console.log('Hello World');
          });
      });
      ```
      微服务B在启动时触发printHelloWorld事件,为了保证在触发事件时,微服务A已经监听了该事件,微服务B在创建socket时可以把$A作为状态依赖:
      ```javaScript
      import {getBus} from '@runnan/obvious';
      
      const bus = getBus('demoBus');
      bus.createSocket('B', ['$A'], (socket) => {
          socket.emit('printHelloWorld');
      });
      ```
      在平台服务中,由于微服务B已经声明了它依赖微服务A,因此即使demoBus先拉起微服务B,B的回调逻辑也会等待A的回调逻辑执行完后才执行
      ```javaScript
      import {createBus, getBus} from '@runnan/obvious';
      
      createBus('demoBus', {
          A: {
              js: ['http://{hosta}/assets/a.js'] // 先执行A
          },
          B: {
              js: ['http://{hostb}/assets/b.js'] // 后执行B
          }
      });
      
      const bus = getBus('demoBus');
      
      bus.startApp('B'); // 先拉起B
      bus.startApp('A'); // 后拉起A
      ```
  • Q&A

    • Q: 不同微服务定义的全局变量和样式如何避免互相影响? A: 对于要定义全局变量的场景,建议改为通过socket定义微服务私有状态, 或者使用Symbol把全局变量挂载到window对象上;为了避免样式污染,建议在构建时加入css module特性
    • Q: 事件收发和状态更改是同步的还是异步的? A: EventEmitter的所有操作都是同步的,obvious的状态机制也是基于同步的EventEmitter实现的
    • Q: 内置状态${appName}就绪可以保证app的所有代码都执行完了吗? A: ${appName}就绪只能保证app内的所有同步逻辑都执行完了,而不能保证异步逻辑也执行完了
  • 关联项目

    • react-obvious: 结合了obvious和react的一个类react-redux框架
    • feda: Front End Deploy Agent, 基于Node.js、Nginx、docker技术构建的一个前端静态资源管理应用,可以帮助你理解obvious资源配置中间件的使用场景
    • omicro-cli:适配Feda的微前端脚手架,支持生成代码模板和上传服务包到Feda
  • 扩展生态

    • 对Vue、Angular框架的适配
    • 微前端单页框架,实现react-router控制页面跳转,而页面渲染则不限制技术栈
  • 写在最后

    本代码仓的demo目录是一个简单的微前端示例工程,实现了把Vue页面嵌入React单页框架中的功能,为了方便开发者理解“不同微服务能独立部署”的含义,没有使用dev-server热更新,而是用express进行静态伺服。

    闭门造车,水平有限,现将代码开源,希望感兴趣的朋友能提出意见,一起交流,欢迎issue、fork和PR。你的star是我前进的动力

1.1.0

4 years ago

1.0.5

4 years ago

1.0.4

4 years ago

1.0.3

4 years ago

1.0.1

4 years ago

1.0.0

4 years ago