【推荐👍】前端微服务简单实践

2,203 阅读7分钟

最近看了字节跳动技术团队写的《前端微服务在字节跳动的打磨与应用》这一篇文章,对其中的服务注册和动态加载模块比较感兴趣,再加上之前做过一些类似的东西,所以就花了点时间做了一些简单的实践。希望可以帮助到大家。

我对微服务的理解

我理解的微服务,本质上就是把一个大型的应用拆分为很多个独立的模块,每一个模块的可以单独的开发、调试并上线。这样的好处我理解主要有以下几个:

  • 每个模块都是一个独立的个体,如果有某个模块出现问题了,不会导致整个应用挂掉。
  • 由于每个模块可以单独上线,因此上线会更快,有利于更新迭代。
  • 由于有了服务注册的功能,因此页面都可以通过配置化的方式来动态加载,对于功能的新增、回滚特别方便。
  • 框架无关(这可能取决于具体实现)

本文主要是想简单讨论下服务发现以及动态加载模块的一些实践。当然这里只是给出一种简单的思路,仅供参考。

服务发现

首先,我们来思考一个问题。如果我们将一个大型应用拆分为多个模块的话,那主程序怎么知道有哪些模块,以及各个模块对应的配置信息(js / css 等配置信息)呢。其实,查找配置的模块信息的过程,就叫做服务发现。

那么我们怎么实现服务发现呢?

有一种很简单粗暴的做法,就是我们将这些配置信息直接硬编码在主程序里面,可是这样造成的问题是什么呢?每一次你要新增、修改和删除模块的话,你都需要发布一次主程序,这种做法肯定是不行的。

那么,有没有更好的办法呢?

这个时候比较聪明的同学可能就想到了,那我把配置信息通过接口的方式调用不就行了?我个人比较推荐的也是这种做法。因此有时候我们需要根据用户的身份、权限来返回不同的模块配置信息,通过接口的话,我们就可以很方便的做到这一点。我给一个简单的模块配置信息模块:

[{
    name: 'home',
    path: '/home',
    js: 'https://unpkg.com/react@16/umd/react.development.js',
    css: 'https://unpkg.com/react@16/umd/react.css'
}]

配置信息主要分为四项,path 指的是该模块对应的路由地址,也就是说,当前端匹配到路由为 /home 的时候,就会加载对应的 js 文件和 css 文件,并执行对应的 js 文件,渲染模块内容。

动态加载模块

那么问题来了,假设我们匹配到 /home 这个路由,加载了对应的 js 文件后,我们如何渲染对应的模块呢?

动态加载模块的话,目前我想到的有两种方案,一种是字节技术团队文中使用的 new Function + CommonJs 的方案,还有一种是类似于 AMD 的方案,接下来我简单的介绍下两种方案实现。

new Function

基础知识

不清楚同学们有没有将文件打包为 CommonJs 格式,我先贴一段 CommonJs 打包后的样子

从图中我们可以看到,其实 CommonJs 打包后,会将你导出模块的内容都挂在 exports 这个对象上,因此,我们就可以结合 new Function 使用。多说无益,我们来结合代码食用

首先我们先实现一个简单的模块功能

import React from 'react';
import ReactDom from 'react-dom';

function App() {
  return React.createElement('div', null, 'hello world');
}

export const render = container => {
  ReactDom.render(React.createElement(App), container);
};

这段代码特别简单,就是正常的实现了个 hello world 逻辑,并且导出了一个 render 方法。

我们接着来看下主程序是如何加载模块的

// 全局模块管理
const modules = {};

function loadModule() {
      const currentConfig = {
        name: 'home',
        path: '/home',
        js: './dist/main.js',
      };
    
      const { name, path, js } = currentConfig;
    
      modules[name] = {
        exports: {},
      };
      const ajax = new XMLHttpRequest();
      ajax.open('get', js);
      ajax.onload = function(event) {
        new Function('module', 'exports', this.responseText)(
          modules[name],
          modules[name].exports,
        );
    
        modules[name].exports.render(document.getElementById('app'));
      };
      ajax.send();
}

loadModule();

实现步骤

看代码可能大家就比较容易理解了,主程序在加载模块的时候,主要分为以下步骤:

  1. 开发模块的时候,按照约定,导出一个 render 方法。
  2. 主程序加载的时候,根据配置信息,创建模块的 module 信息。
  3. 通过 xhr 加载拿到模块的 js 代码,并通过 new Function 的方式,将我们的模块 module 信息传进去执行 js 代码,这样 js 代码导出的内容就会挂载到 modules[name] 上。
  4. 调用模块导出的 render 方法来渲染模块内容。

这里面有两个关键信息

  • 导出的模块必须选用 CommonJs 打包类型,否则无法将我们自己的 module 传进去。
  • 加载模块的时候使用 xhr 请求,这样才能拿到代码的 source code。

以上就是通过 new Function 来实现动态加载模块的关键。 接下来我们来讲下类似 AMD 的实现思路。

AMD

AMD(Asynchronous Module Definition) 跟 CommonJs 一样,也是一种模块管理方案。它的特点在于,你每次要定义一个模块的时候,都需要使用如下类似的写法

define('myModule', [...deps], function () {
    ...some code
});

通过这种方式定义模块的话,其他模块就可以通过依赖项注入的方式来使用该模块。当然,我们这里不涉及太深入的东西,只是简单做了个实现。还是用之前那个 hello world 例子,不过这次我们做了些修改:

import React from 'react';
import ReactDom from 'react-dom';

function App() {
  return React.createElement('div', null, 'hello world');
}

const render = container => {
  ReactDom.render(React.createElement(App), container);
};

window.defineModule('home', {
  render,
});

主要修改在于,我们不通过 export 将模块导出了,而是通过 window.defineModule 这个方法来定义自己的模块。而 window.defineModule 这个方法的实现,则是放在主程序下:

const namespace = Symbol('namespace');
window[namespace] = {};

function defineModule(name, exports) {
  window[namespace][name] = exports;
}

function getModule(name) {
  return window[namespace][name];
}

window.defineModule = defineModule;

function loadModule() {
      const currentConfig = {
        name: 'home',
        path: '/home',
        js: './dist/main.js',
      };
    
      const { name, path, js } = currentConfig;
    
      const scriptEle = document.createElement('script');
      scriptEle.src = js;
      scriptEle.onload = () => {
        const module = getModule(name);
        module.render(document.getElementById('app'));
      };
    
      document.body.appendChild(scriptEle);
}

loadModule();

实现步骤

这里代码应该也比较容易理解,接下来我们来梳理下实现步骤

  1. 主程序通过定义一个 defineModule 方法,并将其挂载在 window 上来实现模块定义。
  2. 单独模块在开发的时候,通过 window.defineModule 方法来定义自己的模块,并将自己的 render 方法导出。
  3. 主程序在加载模块的时候,通过正常的创建 script 来加载。在加载完成后,根据模块的配置信息可以拿到模块的导出内容。
  4. 调用模块导出的 render 方法来渲染模块内容。

路由监听

当然,这里面其实还遗漏了最重要的一点,就是路由监听。因为我们每一个模块都是跟路由绑定在一起的,比如访问 /home 路由的时候才渲染 home 模块。对于路由监听的话,这里就不做展开了,有兴趣的同学可以看下 history 相关的接口以及 hashChange 事件。当然也可以看下 react-router-dom 的源码哈哈哈。

总结

本文只是简单的对前端微服务做了一些实践,并且讲了我对服务发现、动态加载模块的一些想法。只是个人的思考,希望能带给大家一些帮助。主要总结如下

  • 服务发现可以通过配置化接口的方式来实现,一方面有利于模块动态增删改查,另一方面,可以根据用户的身份权限来返回不同的模块信息。
  • 动态加载模块可以使用 new Function 和 类似于 AMD 的方式实现,具体使用哪种的话,取决于个人偏好吧。我个人觉得 window.defineModule 的方式可以更优雅点,调试起来可能更方便。

本文地址在->本人博客地址, 欢迎给个 start 或 follow。