我正在参加「掘金·启航计划」
背景
- 用了icestark做微前端大概有近一年的时间了,一直停留在使用层面上,每次被别人问到icestark怎么做到的,应用之间为什么可以做通信的时候,都是满脸问号。是时候开始看看其内部实现原理了。
- 第一次看到icestark的官网介绍,就明显的感觉到符合我的业务需求,再看文档的使用方式,简洁明了,并且几乎没有踩到任何坑,有点小疑问在github上提issue,icestark的开发人员都会及时反馈。个人认为现在微前端框架众多,实现目的都是大同小异的,所以彻底弄明白一个即可。
- 笔者是一位专注于
react
的开发者,所以本次源码阅读均是以react的思路去阅读。
源码目录结构
github
上 fork
一份代码,git clone 下来之后,可以看到核心代码是在packages
目录下,分别有icestark
、icestark-app
、icestark-data
、icestark-module
、sandbox
五个目录。
- icestark: 主应用运行的核心逻辑
- icestark-app: 子应用用到的
- icestark-data:应用之间进行通信的
- icestark-module: 开发微模块的
- sandbox: 创建沙箱的核心逻辑
概念
- 主应用 & 子应用
icestark
微前端必须要有一个顶层应用,所有微应用的配置都必须在这个顶层应用里面做。根据官方文档描述,我们把这个顶层应用称为主应用,所有要加载的微应用称之为子应用。
解读起点
虽然源码量并不是很大,但是直接漫无目的的阅读还是很枯燥乏味的。我们按照文档的使用方式,从主应用的配置为起点,来去一步步解读微前端的整个过程。
主应用
我们以react作为开发框架的角度来去从主应用配置上来看,需要通过 AppRouter & AppRoute
去注册微应用,这点很像react-router
的配置方式,比较友好。根据官方文档上描述,icestark
运行的时候,只会有一个子应用,所以可以猜一下,主应用的核心应该是通过路由的变化来控制子应用的加载与卸载。
AppRouter组件
AppRouter组件的作用主要是为了注册子应用,并且把子应用加载进来。看一个组件我喜欢先看render函数,从render函数可以看到这个组件整体是在渲染什么。我们来先看render函数
render 函数
render() {
const {
NotFoundComponent,
ErrorComponent,
LoadingComponent,
children,
basename: frameworkBasename,
} = this.props;
const { url, appLoading, started } = this.state;
// 应用未加载之前渲染Loading组件,该Loading组件由开发者自行提供
if (!started) {
return renderComponent(LoadingComponent, {});
}
// 微应用加载过程中出现无匹配路由时渲染未发现页面组件,由开发者自行提供
if (url === ICESTSRK_NOT_FOUND) {
return renderComponent(NotFoundComponent, {});
// 微应用加载过程中出现错误时渲染 错误组件, 由开发者自行提供
} else if (url === ICESTSRK_ERROR) {
return renderComponent(ErrorComponent, { err: this.err });
}
let match = false; // 是否匹配到子应用
let element: React.ReactElement; // 记录匹配到的子应用
// 循环遍历拿到 AppRouter 的children,也就是 AppRoute,从而获取到要加载的子应用
React.Children.forEach(children, (child) => {
if (!match && React.isValidElement(child)) {
const { path, activePath, exact, strict, sensitive, hashType } = child.props;
const compatPath = mergeFrameworkBaseToPath(
formatPath(activePath || path, {
exact,
strict,
sensitive,
hashType,
}),
frameworkBasename,
);
element = child; // 找到了子应用
match = !!findActivePath(compatPath)(url);
}
});
if (match) {
const { name, activePath, path } = element.props as AppRouteProps;
if (isFunction(activePath) && !name) {
const err = new Error('[icestark]: name is required in AppConfig');
console.error(err);
return renderComponent(ErrorComponent, { err });
}
this.appKey = name || converArray2String((activePath || path) as AppRoutePath); // 拿到子应用的唯一标识
// 子应用的属性
const componentProps: AppRouteComponentProps = {
location: urlParse(url, true),
match,
history: appHistory, // 子应用的history更改为拦截后的history对象
};
return (
<div>
{appLoading === this.appKey ? renderComponent(LoadingComponent, {}) : null}
{React.cloneElement(element, {
key: this.appKey,
name: this.appKey,
componentProps,
cssLoading: appLoading === this.appKey,
onAppEnter: this.props.onAppEnter,
onAppLeave: this.props.onAppLeave,
})}
</div>
);
}
return renderComponent(NotFoundComponent, {});
}
从上面可以看出,render函数
无非就是根据判断条件, 去渲染NotFoundComponent
ErrorComponent
LoadingComponent
children
其中的某一个。其中children
其实就是AppRoute
所加载的子应用。
所以弄清楚判断条件就尤为重要,判断条件由 AppRouter
组件自身的state
props
以及组件自身的常量this.appKey
组成。 所以现在我们的目标转向 constructor
componentDidMount
。
constructor函数
constructor(props) {
super(props);
this.state = {
url: '', // 记录子应用的路由url
appLoading: '', // 记录正在加载的子应用唯一标识
started: false, // 记录icestark是否启动
};
const { fetch, prefetch: strategy, children } = props;
// prefetch判断是否要对子应用进行预加载
if (strategy) {
this.prefetch(strategy, children, fetch);
}
}
从上面可以看出,主要是定义一些子应用的唯一标识
子应用路由的url
icestark是否处于启动之中
。唯一的逻辑对子应用进行预加载 prefetch
。
prefetch(预加载)
其原理是利用 window.fetch
方法,然后结合 window.requestIdleCallback
,利用浏览器空余时间去获取子应用的js和css。
- prefetch 函数 (给子应用添加唯一标识name属性)
/**
* prefetch for resources.
* no worry to excute `prefetch` many times, for all prefetched resources have been cached, and never request twice.
*/
prefetch = (strategy: Prefetch, children: React.ReactNode, fetch: Fetch = window.fetch) => {
const apps: AppConfig[] = React.Children // 对AppRouter组件包裹的AppRoute对应的子应用进行遍历,然后给子应用设置一个唯一标识name
/**
* we can do prefetch for url, entry and entryContent.
*/
.map(children, (childElement) => {
if (React.isValidElement(childElement)) {
const { url, entry, entryContent, name, path } = childElement.props as AppRouteProps;
if (url || entry || entryContent) {
return {
...childElement.props,
/**
* name of AppRoute may be not provided, use `path` instead.
*/
name: name || converArray2String(path), // AppRoute 如果没指定name属性的话,就用path来作为子应用的唯一标识
};
}
}
return false;
})
.filter(Boolean);
// 经过上一边给子应用赋值唯一标识的操作之后,开始进行预加载处理
doPrefetch(apps as MicroApp[], strategy, fetch);
};
- doPrefetch 函数 (开启获取子应用资源的idle任务)
doPrefetch
函数主要是开启 requestIdleCallback
任务,遍历子应用数组,对每一个子应用都开启一个 requestIdleCallback
任务。其核心逻辑如下:
function prefetchIdleTask(fetch = window.fetch) {
return (app: MicroApp) => {
window.requestIdleCallback(async () => {
const { url, entry, entryContent, name } = app;
const { jsList, cssList } = url ? getUrlAssets(url) : await getEntryAssets({
entry,
entryContent,
assetsCacheKey: name,
fetch,
});
window.requestIdleCallback(() => fetchScripts(jsList, fetch));
window.requestIdleCallback(() => fetchStyles(cssList, fetch));
});
};
}
componentDidMount函数
componentDidMount
生命周期函数主要做了三件事
- 监听
icestark:not-found
自定义事件,从而在icestark
未发现子应用的时候去触发渲染NotFoundComponent
组件。icestark:not-found
自定义事件,是由new CustomEvent()
进行创建,然后经过window.dispatchEvent
去触发
核心实现如下:
window.dispatchEvent(new CustomEvent('icestark:not-found'))
- 执行
start
函数 (劫持路由变化,触发子应用的挂载与卸载) - 设置
icestark
的启动状态为true
componentDidMount() {
// render NotFoundComponent eventListener
window.addEventListener('icestark:not-found', this.triggerNotFound);
/** lifecycle `componentWillUnmount` of pre-rendering executes later then
* `constructor` and `componentWilllMount` of next-rendering, whereas `start` should be invoked before `unload`.
* status `started` used to make sure parent's `componentDidMount` to be invoked eariler then child's,
* for mounting child component needs global configuration be settled.
*/
const { shouldAssetsRemove, onAppEnter, onAppLeave, fetch, basename } = this.props;
start({
onAppLeave, // 子应用卸载前的回调
onAppEnter, // 子应用渲染前的回调
onLoadingApp: this.loadingApp, // 子应用开始加载的回调
onFinishLoading: this.finishLoading, // 子应用完成加载的回调
onError: this.triggerError, // 子应用加载过程发生错误的回调
reroute: this.handleRouteChange, // 路由变化的回调
fetch, // window.fetch
basename,
...(shouldAssetsRemove ? { shouldAssetsRemove } : {}), // 页面资源是否持久化保留
});
this.setState({ started: true }); // 设置 `icestark`的启动状态为 `true`
}
start函数
function start(options?: StartConfiguration) {
// See https://github.com/ice-lab/icestark/issues/373#issuecomment-971366188
// todos: remove it from 3.x
if (options?.shouldAssetsRemove && !temporaryState.shouldAssetsRemoveConfigured) {
temporaryState.shouldAssetsRemoveConfigured = true;
}
if (started) {
console.log('icestark has been already started');
return;
}
started = true; // 设置icestark的启动状态为true
recordAssets(); // 此时子应用未加载进来,通过'style', 'link', 'script'标签 找到document文档树上主应用的静态资源元素,并添加icestark=static属性
// update globalConfiguration
globalConfiguration.reroute = reroute; // 路由变化事件
Object.keys(options || {}).forEach((configKey) => {
globalConfiguration[configKey] = options[configKey];
});
const { prefetch, fetch } = globalConfiguration;
if (prefetch) { // 说明要进行子应用的预加载
doPrefetch(getMicroApps(), prefetch, fetch);
}
// hajack history & eventListener
hijackHistory(); // 改写pushState 和 replaceState事件
hijackEventListener(); // 改写原生的事件监听以及原生的移除事件监听
// 初始化的时候,触发一次路由的变化事件
globalConfiguration.reroute(location.href, 'init');
}
通过 start 劫持路由变化,触发子应用的挂载/卸载, 这里我们重点关注下,如何劫持路由变化,以及路由变化如何触发子应用的挂载与卸载
hijackHistory & hijackEventListener
- hijackHistory
劫持history
const originalPush: OriginalStateFunction = window.history.pushState; // 储存原始pushState方法
const originalReplace: OriginalStateFunction = window.history.replaceState; // 储存原始replaceState方法
/**
* 路由发生变化会执行的事件
*/
const handleStateChange = (event: PopStateEvent, url: string, method: RouteType) => {
setHistoryEvent(event);
globalConfiguration.reroute(url, method);
};
/**
* 路由发生变化会执行的事件
*/
const urlChange = (event: PopStateEvent | HashChangeEvent): void => {
setHistoryEvent(event);
globalConfiguration.reroute(location.href, event.type as RouteType);
};
/**
* 改写 window.history
*/
const hijackHistory = (): void => {
window.history.pushState = (state: any, title: string, url?: string, ...rest) => {
originalPush.apply(window.history, [state, title, url, ...rest]);
const eventName = 'pushState';
// 触发popstate事件
handleStateChange(createPopStateEvent(state, eventName), url, eventName);
};
window.history.replaceState = (state: any, title: string, url?: string, ...rest) => {
originalReplace.apply(window.history, [state, title, url, ...rest]);
const eventName = 'replaceState';
// 触发popstate事件
handleStateChange(createPopStateEvent(state, eventName), url, eventName);
};
window.addEventListener('popstate', urlChange, false);
window.addEventListener('hashchange', urlChange, false);
};
// inspired by https://github.com/single-spa/single-spa/blob/master/src/navigation/navigation-events.js#L107
export function createPopStateEvent(state, originalMethodName) {
// We need a popstate event even though the browser doesn't do one by default when you call replaceState, so that
// all the applications can reroute.
let evt;
try {
evt = new PopStateEvent('popstate', { state });
} catch (err) {
// IE 11 compatibility
// https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-html5e/bd560f47-b349-4d2c-baa8-f1560fb489dd
evt = document.createEvent('PopStateEvent');
evt.initPopStateEvent('popstate', false, false, state);
}
evt.icestark = true;
evt.icestarkTrigger = originalMethodName;
return evt;
}
从代码上来看,对pushState
与replaceState
的改写,无非就是在执行原始pushState
replaceState
方法之后,去主动触发 popstate
事件(handleStateChange
和 urlChange
函数作用相同,都是去执行reroute
函数)。
注意:直接调用原生的pushState
&replaceState
事件是不会触发 popstate
事件, 只有在点击浏览器的前进/回退按钮才会触发。
- hijackEventListener
const originalAddEventListener = window.addEventListener; // 储存原始事件监听方法
const originalRemoveEventListener = window.removeEventListener; // 储存原始移除事件监听方法
/**
* Hijack eventListener
*/
const hijackEventListener = (): void => {
window.addEventListener = (eventName, fn, ...rest) => {
if (
typeof fn === 'function' &&
routingEventsListeningTo.indexOf(eventName) >= 0 &&
!isInCapturedEventListeners(eventName, fn)
) {
addCapturedEventListeners(eventName, fn);
return;
}
return originalAddEventListener.apply(window, [eventName, fn, ...rest]);
};
window.removeEventListener = (eventName, listenerFn, ...rest) => {
if (typeof listenerFn === 'function' && routingEventsListeningTo.indexOf(eventName) >= 0) {
removeCapturedEventListeners(eventName, listenerFn);
return;
}
return originalRemoveEventListener.apply(window, [eventName, listenerFn, ...rest]);
};
};
从上面代码来看,结合源码中的 capturedListeners.ts
文件,对原生的addEventListener
与removeEventListener
的改写,其实就是将开发者自己绑定的 popstate
与 hashchange
的事件放在一个全局对象中,单独去触发,防止与icestark内部监听的popstate
与 hashchange
事件有影响。
reroute函数
该函数的作用是判断前后url是否发生变化,以此来控制子应用的加载与卸载。当路由发生变化时,就会触发该函数的执行。
let lastUrl = null; // 记录上次浏览器输入的的url
/**
* 监听到路由的变化之后,比对路由前后是否发生变化,以此来控制子应用的加载与卸载
* @param url
* @param type
*/
export function reroute(url: string, type: RouteType | 'init' | 'popstate' | 'hashchange') {
const { pathname, query, hash } = urlParse(url, true); // 解析出url中的参数
if (lastUrl !== url) { // 前后路由进行比对
globalConfiguration.onRouteChange(url, pathname, query, hash, type); // 触发路由变化事件
const unmountApps = []; // 储存要卸载的子应用
const activeApps = []; // 储存要加载的子应用
// 获取全局中储存的所有子应用进行遍历,分出哪些子应用要卸载,哪些子应用要加载
getMicroApps().forEach((microApp: AppConfig) => {
const shouldBeActive = !!microApp.findActivePath(url);
if (shouldBeActive) {
activeApps.push(microApp);
} else {
unmountApps.push(microApp);
}
});
// 子应用开始被激活的回调
globalConfiguration.onActiveApps(activeApps);
// call captured event after app mounted
Promise.all(
// call unmount apps
unmountApps.map(async (unmountApp) => {
if (unmountApp.status === MOUNTED || unmountApp.status === LOADING_ASSETS) {
globalConfiguration.onAppLeave(unmountApp); // 子应用卸载前的回调
}
// 根据子应用唯一标识去卸载子应用
await unmountMicroApp(unmountApp.name);
}).concat(activeApps.map(async (activeApp) => {
if (activeApp.status !== MOUNTED) {
globalConfiguration.onAppEnter(activeApp); // 子应用渲染前的回调
}
// 加载子应用
await createMicroApp(activeApp);
})),
).then(() => {
// 子应用发生了卸载与加载,说明路由变化了,故在这里要去调用下开发者自己对popostate以及hashchange的监听事件
callCapturedEventListeners();
});
}
lastUrl = url;
}
总结
本章我们只是整体概览下icestark
的执行过程,其本质是:history路由模式下通过改写history
api 然后监听popstate
事件,hash路由模式下,监听 hashchange
事件, 然后根据路由的变化,来对子应用进行加载与卸载的控制。子应用的加载与卸载将在下一章节详细阐述。