近看了字节跳动技术团队写的《前端微服务在字节跳动的打磨与应用》这一篇文章,对其中的服务注册和动态加载模块比较感兴趣,再加上之前做过一些类似的东西,所以就花了点时间做了一些简单的实践。
我对微服务的理解
我理解的微服务,本质上就是把一个大型的应用拆分为很多个独立的模块,每一个模块的可以单独的开发、调试并上线。这样的好处我理解主要有以下几个:
- 每个模块都是一个独立的个体,如果有某个模块出现问题了,不会导致整个应用挂掉。
- 由于每个模块可以单独上线,因此上线会更快,有利于更新迭代。
- 由于有了服务注册的功能,因此页面都可以通过配置化的方式来动态加载,对于功能的新增、回滚特别方便。
- 框架无关(这可能取决于具体实现)
本文主要是想简单讨论下服务发现以及动态加载模块的一些实践。当然这里只是给出一种简单的思路,仅供参考。
服务发现
首先,我们来思考一个问题。如果我们将一个大型应用拆分为多个模块的话,那主程序怎么知道有哪些模块,以及各个模块对应的配置信息(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();
实现步骤
看代码可能大家就比较容易理解了,主程序在加载模块的时候,主要分为以下步骤:
- 开发模块的时候,按照约定,导出一个 render 方法。
- 主程序加载的时候,根据配置信息,创建模块的 module 信息。
- 通过 xhr 加载拿到模块的 js 代码,并通过 new Function 的方式,将我们的模块 module 信息传进去执行 js 代码,这样 js 代码导出的内容就会挂载到 modules[name] 上。
- 调用模块导出的 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();
实现步骤
这里代码应该也比较容易理解,接下来我们来梳理下实现步骤
- 主程序通过定义一个 defineModule 方法,并将其挂载在 window 上来实现模块定义。
- 单独模块在开发的时候,通过 window.defineModule 方法来定义自己的模块,并将自己的 render 方法导出。
- 主程序在加载模块的时候,通过正常的创建 script 来加载。在加载完成后,根据模块的配置信息可以拿到模块的导出内容。
- 调用模块导出的 render 方法来渲染模块内容。
路由监听
当然,这里面其实还遗漏了重要的一点,就是路由监听。因为我们每一个模块都是跟路由绑定在一起的,比如访问 /home 路由的时候才渲染 home 模块。对于路由监听的话,这里就不做展开了,有兴趣的同学可以看下 history 相关的接口以及 hashChange 事件。当然也可以看下 react-router-dom 的源码哈哈哈。
总结
本文只是简单的对前端微服务做了一些实践,并且讲了我对服务发现、动态加载模块的一些想法。只是个人的思考,希望能带给大家一些帮助。主要总结如下
- 服务发现可以通过配置化接口的方式来实现,一方面有利于模块动态增删改查,另一方面,可以根据用户的身份权限来返回不同的模块信息。
- 动态加载模块可以使用 new Function 和 类似于 AMD 的方式实现,具体使用哪种的话,取决于个人吧。我个人觉得 window.defineModule 的方式可以更优雅点,调试起来可能更方便。
本文地址在->[本人博客地址](前端微服务简单实践 · Issue #19 · chenjigeng/blog), 欢迎给个 start 或 follow
内推
今日头条内推!!!!
校招投递链接:https://job.toutiao.com/s/GfTV1C
社招可以访问这个地址:https://job.toutiao.com/s/sPDncf