最近看了字节跳动技术团队写的 《前端微服务在字节跳动的打磨与应用》 这一篇文章,对其中的服务注册和动态加载模块比较感兴趣,再加上之前做过一些类似的东西,所以就花了点时间做了一些简单的实践。希望可以帮助到大家。
我理解的微服务,本质上就是把一个大型的应用拆分为很多个独立的模块,每一个模块的可以单独的开发、调试并上线。这样的好处我理解主要有以下几个:
本文主要是想简单讨论下服务发现以及动态加载模块的一些实践。当然这里只是给出一种简单的思路,仅供参考。
首先,我们来思考一个问题。如果我们将一个大型应用拆分为多个模块的话,那主程序怎么知道有哪些模块,以及各个模块对应的配置信息(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 的方案,接下来我简单的介绍下两种方案实现。
不清楚同学们有没有将文件打包为 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(); 复制代码
看代码可能大家就比较容易理解了,主程序在加载模块的时候,主要分为以下步骤:
这里面有两个关键信息
以上就是通过 new Function 来实现动态加载模块的关键。 接下来我们来讲下类似 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(); 复制代码
这里代码应该也比较容易理解,接下来我们来梳理下实现步骤
当然,这里面其实还遗漏了最重要的一点,就是路由监听。因为我们每一个模块都是跟路由绑定在一起的,比如访问 /home 路由的时候才渲染 home 模块。对于路由监听的话,这里就不做展开了,有兴趣的同学可以看下 history 相关的接口以及 hashChange 事件。当然也可以看下 react-router-dom 的源码哈哈哈。