介绍
前端领域一直在不断的发展,传统的 jQuery + Backbone + Bootstrap MVC 解决方案逐渐被 Angular、Ember、React、Vue 等 MVVM 框架替代,前后端分离和前端组件化的思想已经达到了顶峰。
在传统的系统中,通常会有一个站点,所有的业务都在这个站点上,随着业务复杂度的上升,打包的体积会迅速变大,发布时也会变慢。为了适应业务的复杂度往往需要更多的开发者、更细粒度的团队组织。分组开发时大家的模块解耦到各自完成,上线时糅合在一起运行,这时会产生出层出不穷的分支合并、代码回滚等,都会造成合作效率的骤降。
所有的业务聚合在一起还会造成频繁发布,每个业务都会产生一定的更新频率,每个业务都会导致整个项目一起升级、测试和上线,发布频率的总和会非常高、非常频繁。
以如此高的上线频率、版本迭代速度来看,开发者也极难追溯哪个版本对应哪个改动。
框架层出不穷,版本更是迭代不穷,难免会出现前端项目技术栈不统一、所用框架版本不统一。
比如:有的项目使用了 Vue,有的项目使用了 React,Vue 的项目已经稳定运行,若是没有新的功能加入,但却需要结合到其它的项目中时,对 Vue 的重构的成本会很高,这时就需要去兼容不同类型的前端框架。
一家大的公司也可能有很多的应用,这些应用代表了公司的组织架构,在用户眼里他们是一个产品。
聚合成为了一个技术趋势,体现在前端的聚合就是微服务化架构。
那么问题来了,什么是前端微服务?
一个集成了不同业务的大型应用,将应用拆分成多个模块,每一个模块可以单独的开发、调试并上线,最后由应用提供统一的入口。
有什么优势?
每个模块都是一个独立的个体,如果有某个模块出现问题了,不会导致整个应用挂掉。
由于每个模块可以单独上线,因此上线会更快,有利于更新迭代。
由于有了服务注册的功能,因此页面都可以通过配置化的方式来动态加载,对于功能的新增、回滚特别方便。
框架无关 (每个模块都可以根据实际需求选择不同的框架)
会遇到什么样的难题?
如何将不同业务模块集中到一个大的应用上,统一对外开放?
如何给不同用户赋予权限,让其能够访问平台的特定业务模块,同时禁止其访问无权限的业务模块?
如何快速接入新的模块,并对模块进行版本管理,保证功能同步?
实现方案
我们已经知道了什么是微服务,那我们该如何具体去实现呢?
独立开发
每个模块单独开发,不需要和其他模块保持完全一致的组织模型,也可以选择适合自己的框架。
独立开发的优点:
业务模块分布式开发,代码仓库更易管理。
模块内的代码高内聚,更专注于业务;
新模块的接入不需要修改已有模块,不会影响其他模块的功能;
业务模块移植性强,可单独部署,也可整合到大平台下。
独立部署
单体应用的一大问题是发布非常慢,导致每天上线不了几次,风险也很大。当业务多的时候,不管有多少更新都要一起发布。
独立部署解决了以上的问题,每次只需要发布对应模块的应用即可。
服务发现
单体应用拆分成多个模块之后,一个项目里的方法分开部署了,那主程序怎么知道有哪些模块,以及各个模块对应的配置信息( js / css 等配置信息)呢。
查找配置的模块信息的过程,就叫做服务发现。
需要有个统一的注册机构,把提供服务的各个应用都查到。
假如:我们有三个不同的业务应用,用户如果想使用这三个业务,如何去切换这三个应用呢?

当用户登录时,我们先调用接口根据用户的身份、权限来返回不同的模块配置信息,当用户点击对应的模块后,通过 nginx 配置反向代理,来进行路由分发,从而实现前端微服务。
具体怎么实现呢?
每个模块将自己的全量路由路径传入给主程序 ,而在主程序启动时,主程序会调用接口从后端拉取当前登录用户有权限的路由路径,当访问某模块的路由时,会与有权限的路由路径进行比对,比对失败的路由路径会自动导向无权限的页面视图。
至于路由的权限维护,可以做一个可视化配置路由的管理页面,权限的细化程度根据自己的业务情况自定义即可。
下面是通过接口返回的一个模块的配置信息,path 代表模块对应的路由地址,也就是说,当前端匹配到了路由为 /home 的时候,就会加载对应的 js 和 css 文件,并执行 js 文件渲染模块内容。
[{
name: 'home',
path: '/home',
js: 'https://XXX/home.js',
css: 'https:XXX/home.css'
}]
动态加载
当前端匹配到了一个路由,需要确定当前路由路径对应的是哪一个模块,若对应的模块尚未注入路由信息,需要动态加载模块资源包,待加载并执行了对应的 js 脚本资源包后,再继续执行后续的渲染逻辑。
模块的资源包可以有多种形式的打包方式,如 AMD、Commonjs、UMD 等。
- CommonJs
首先实现一个简单的模块功能:
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();
上面这段代码主要做了如下工作:
主程序加载的时候,根据配置信息,创建模块的 module 信息。
通过 xhr 加载拿到模块的 js 代码,并通过 new Function 的方式,将我们的模块 module 信息传进去执行 js 代码,这样 js 代码导出的内容就会挂载到 modules[name] 上。
调用模块导出的 render 方法来渲染模块内容。
注意:
导出的模块必须选用 CommonJs 打包类型,否则无法将我们自己的 module 传进去。
加载模块的时候使用 xhr 请求,这样才能拿到代码的 source code。
- AMD
AMD 语法:
define('module', [...deps], function () {
...
});
首先,定义一个简单的模块:
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,
});
通过 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 方法来渲染模块内容。
生命周期管理
在切换各模块时,上一个模块的 DOM 会被替换,但相关的事件并未正确清除。比如使用 React 框架的模块,当我们替换掉 DOM 内容时,并未正确触发 React 组件的 UnMount 事件。
所以,我们需要为模块添加 destroy 和 ready 接口:
class App {
ready() {
// 在当前模块切换进来时调用
}
destroy() {
// 在当前模块切换出去时调用
}
}
在切换模块时,需自动调用上一个模块的销毁接口,然后在渲染新的模块后,再自动调用当前模块的准备接口。
总结
当一个应用非常庞大且复杂时,可以考虑使用前端微服务这种方式来提高性能。
大的应用可以拆分成多个子模块独立开发。
多个独立的子模块可以单独发布。
可以通过接口的方式实现服务发现,可以根据用户不同的权限动态返回配置信息。
动态加载模块可以使用 new Function + CommonJs 和 AMD 的实现,具体哪种方法取决于个人。
在模块销毁的时候处理数据,在模块刚进入的时候做一下准备工作。