single-spa v5.9.3
通过轻量级路由劫持和状态机设计,实现微前端的动态加载与隔离,主要实现
- 路由管理:
hashchange
、popstate
、history.pushState
、history.replaceState
进行劫持,路由变化时,触发reroute()
子应用状态管理:不同执行逻辑转化不同的状态,比如
- 加载流程:
toLoadPromise
→toBootstrapPromise
→toMountPromise
- 卸载流程:
toUnmountPromise
→toUnloadPromise
- 加载流程:
子应用生命周期触发:
app.bootstrap()
:初始化时仅执行一次app.mount()
:应用激活时触发app.unmount()
:应用从激活变为非激活状态时触发app.unload()
:最终卸载时触发一次
single-spa 采用 JS Entry 的方式接入微前端
我们需要在基座中注册子应用,比如下面代码中,我们注册了对应的映射路径 path 以及对应的加载的方法
registerApplication({
name: "app1",
app: loadApp(url),
activeWhen: activeWhen("/app1"),
customProps: {},
});
整体流程图
1. registerApplication()
在基座初始化时,会调用 registerApplication() 进行子应用的注册
从下面的源码我可以知道,主要执行:
- 格式化用户传递的子应用配置参数:
sanitizeArguments()
- 将子应用加入到 apps 中
如果是浏览器,则触发
ensureJQuerySupport()
:增加JQuery
的支持reroute()
:统一处理路由的方法
function registerApplication(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
) {
var registration = sanitizeArguments(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
);
apps.push(
assign(
{
loadErrorTime: null,
status: NOT_LOADED,
parcels: {},
devtools: {
overlays: {
options: {},
selectors: [],
},
},
},
registration
)
);
if (isInBrowser) {
ensureJQuerySupport();
reroute();
}
}
reroute()
- 状态计算:通过
getAppChanges()
根据当前的URL
筛选出需要 加载/卸载 的应用,主要分为 4 种类型 根据是否已经触发
start()
,从而决定要触发loadApps()
: 加载应用资源,没有其他逻辑performAppChanges()
:卸载非 active 状态的应用(调用umount
生命周期) + 加载并挂载 active 子应用
function reroute() {
if (appChangeUnderway) {
return new Promise(function (resolve, reject) {
peopleWaitingOnAppChange.push({
resolve: resolve,
reject: reject,
eventArguments: eventArguments,
});
});
}
var { appsToUnload, appsToUnmount, appsToLoad, appsToMount } =
getAppChanges();
if (isStarted()) {
appChangeUnderway = true;
appsThatChanged = appsToUnload.concat(
appsToLoad,
appsToUnmount,
appsToMount
);
return performAppChanges();
} else {
appsThatChanged = appsToLoad;
return loadApps();
}
}
1.1 getAppChanges()
根据目前 app.status
的状态进行不同数组数据的组装
appsToLoad
appsToUnload
appsToMount
appsToUnmount
apps.forEach(function (app) {
var appShouldBeActive =
app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
switch (app.status) {
case LOAD_ERROR:
if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
appsToLoad.push(app);
}
break;
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// all other statuses are ignored
}
});
1.2 loadApps()
在 loadApps()
中,就是遍历 appsToLoad
数组 => toLoadPromise(app)
,本质就是触发 app.loadApp()
进行子应用的加载
function loadApps() {
return Promise.resolve().then(function () {
var loadPromises = appsToLoad.map(toLoadPromise);
return (
Promise.all(loadPromises)
.then(callAllEventListeners)
// there are no mounted apps, before start() is called, so we always return []
.then(function () {
return [];
})
.catch(function (err) {
callAllEventListeners();
throw err;
})
);
});
}
1.2.1 toLoadPromise()
触发 app.loadApp()
进行子应用的加载
需要子应用提供一个 loadApp()
并且返回 Promise
状态改为 NOT_BOOTSTRAPPED
function toLoadPromise(app) {
return Promise.resolve().then(function () {
if (app.loadPromise) {
return app.loadPromise;
}
if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
return app;
}
app.status = LOADING_SOURCE_CODE;
var appOpts, isUserErr;
return (app.loadPromise = Promise.resolve()
.then(function () {
var loadPromise = app.loadApp(getProps(app));
return loadPromise.then(function (val) {
app.loadErrorTime = null;
appOpts = val;
app.status = NOT_BOOTSTRAPPED;
app.bootstrap = flattenFnArray(appOpts, "bootstrap");
app.mount = flattenFnArray(appOpts, "mount");
app.unmount = flattenFnArray(appOpts, "unmount");
app.unload = flattenFnArray(appOpts, "unload");
app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);
delete app.loadPromise;
return app;
});
})
.catch(function (err) {
//...
}));
});
}
2. 监听路由变化
single-spa 源码中有自动执行的一系列代码:
- 监听
hashchange
和popstate
变化,触发urlReroute()
->reroute()
- 劫持
window.addEventListener
和window.removeEventListener
,将外部应用通过注册的["hashchange", "popstate"]
的监听方法 放入到capturedEventListeners
中,在下面的unmountAllPromise.then()
之后才会调用capturedEventListeners
存储的方法执行 - 重写
history.pushState()
和history.replaceState()
方法,在原来的基础上增加window.dispatchEvent(createPopStateEvent(window.history.state, methodName))
,从而触发第一步的popstate
监听,从而触发urlReroute()
->reroute()
进行子应用路由的状态同步
总结:
- 路由变化触发微前端子应用加载
- pushState 和 replaceState 改变路由触发微前端子应用加载
- 阻止外部的
hashchange
、popstate
对应的监听方法直接执行,而是等待微前端执行后才触发这些方法
var routingEventsListeningTo = ["hashchange", "popstate"];
if (isInBrowser) {
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
var originalAddEventListener = window.addEventListener;
var originalRemoveEventListener = window.removeEventListener;
window.addEventListener = function (eventName, fn) {
if (typeof fn === "function") {
if (
routingEventsListeningTo.indexOf(eventName) >= 0 &&
!find(capturedEventListeners[eventName], function (listener) {
return listener === fn;
})
) {
capturedEventListeners[eventName].push(fn);
return;
}
}
return originalAddEventListener.apply(this, arguments);
};
window.removeEventListener = function (eventName, listenerFn) {
//...
};
window.history.pushState = patchedUpdateState(
window.history.pushState,
"pushState"
);
window.history.replaceState = patchedUpdateState(
window.history.replaceState,
"replaceState"
);
if (window.singleSpaNavigate) {
//...
} else {
window.singleSpaNavigate = navigateToUrl;
}
}
function urlReroute() {
reroute([], arguments);
}
function callAllEventListeners() {
pendingPromises.forEach(function (pendingPromise) {
callCapturedEventListeners(pendingPromise.eventArguments);
});
callCapturedEventListeners(eventArguments);
}
3. start()启动开始状态
当基座主动触发 single-spa 的 start()
方法时
function start(opts) {
started = true;
if (opts && opts.urlRerouteOnly) {
setUrlRerouteOnly(opts.urlRerouteOnly);
}
if (isInBrowser) {
reroute();
}
}
此时已经在监听路由变化,然后进行 active 子应用的挂载 performAppChanges()
function reroute() {
if (appChangeUnderway) {
return new Promise(function (resolve, reject) {
peopleWaitingOnAppChange.push({
resolve: resolve,
reject: reject,
eventArguments: eventArguments,
});
});
}
var { appsToUnload, appsToUnmount, appsToLoad, appsToMount } =
getAppChanges();
if (isStarted()) {
appChangeUnderway = true;
appsThatChanged = appsToUnload.concat(
appsToLoad,
appsToUnmount,
appsToMount
);
return performAppChanges();
} else {
appsThatChanged = appsToLoad;
return loadApps();
}
}
3.1 getAppChanges()
根据目前 app.status
的状态进行不同数组数据的组装
appsToLoad
appsToUnload
appsToMount
appsToUnmount
apps.forEach(function (app) {
var appShouldBeActive =
app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
switch (app.status) {
case LOAD_ERROR:
if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
appsToLoad.push(app);
}
break;
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// all other statuses are ignored
}
});
3.2 performAppChanges()
而当 start()
方法触发后,started
设置为 true
, 标志着应用从 初始化注册应用(加载应用)的模式进入到 运行阶段(监听路由变化)
此时触发 reroute()
,则进入 performAppChanges()
urlRerouteOnly
控制路由触发规则:
urlRerouteOnly=true
:用户点击或者使用 API 才会触发reroute()
urlRerouteOnly=false
:任何history.pushState()
的调用都会触发reroute()
function start(opts) {
started = true;
if (opts && opts.urlRerouteOnly) {
setUrlRerouteOnly(opts.urlRerouteOnly);
}
if (isInBrowser) {
reroute();
}
}
在 performAppChanges()
中,先组装出需要卸载的子应用
var unloadPromises = appsToUnload.map(toUnloadPromise);
var unmountUnloadPromises = appsToUnmount
.map(toUnmountPromise)
.map(function (unmountPromise) {
return unmountPromise.then(toUnloadPromise);
});
var allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
var unmountAllPromise = Promise.all(allUnmountPromises);
再组装出需要加载的应用
/* We load and bootstrap apps while other apps are unmounting, but we
* wait to mount the app until all apps are finishing unmounting
*/
var loadThenMountPromises = appsToLoad.map(function (app) {
return toLoadPromise(app).then(function (app) {
return tryToBootstrapAndMount(app, unmountAllPromise);
});
});
/* These are the apps that are already bootstrapped and just need
* to be mounted. They each wait for all unmounting apps to finish up
* before they mount.
*/
var mountPromises = appsToMount
.filter(function (appToMount) {
return appsToLoad.indexOf(appToMount) < 0;
})
.map(function (appToMount) {
return tryToBootstrapAndMount(appToMount, unmountAllPromise);
});
先触发 unmountAllPromise
,然后再触发 loadThenMountPromises.concat(mountPromises)
,最终全部完成后触发finishUpAndReturn
return unmountAllPromise
.catch(function (err) {
callAllEventListeners();
throw err;
})
.then(function () {
/* Now that the apps that needed to be unmounted are unmounted, their DOM navigation
* events (like hashchange or popstate) should have been cleaned up. So it's safe
* to let the remaining captured event listeners to handle about the DOM event.
*/
callAllEventListeners();
return Promise.all(loadThenMountPromises.concat(mountPromises))
.catch(function (err) {
pendingPromises.forEach(function (promise) {
return promise.reject(err);
});
throw err;
})
.then(finishUpAndReturn);
});
在上面的方法中,我们看到了很多封装的方法,比如toLoadPromise()
、tryToBootstrapAndMount()
、toUnloadPromise()
、finishUpAndReturn()
,接下来我们将展开分析
3.2.1 tryToBootstrapAndMount()
当用户目前的路由是 /app1
,导航到 /app2
时:
- 调用
app.activeWhen()
进行子应用状态的检测(需要子应用提供实现方法),shouldBeActive(app2)
返回true
- 触发
toBootstrapPromise(app2)
更改状态为BOOTSTRAPPING
,并且触发子应用提供的app2.bootstrap()
生命周期方法 => 更改状态为NOT_MOUNTED
- 触发传入的
unmountAllPromise
,进行/app1
卸载,然后再触发toMountPromise(app2)
执行子应用提供的app2.mount()
生命周期方法,然后更改状态为MOUNTED
如果卸载完成/app1
后,我们再次检测shouldBeActive(app2)
的时候发现路由改变,不是/app2
,那么app2
停止挂载,直接返回app2
,状态仍然保留在toBootstrapPromise(app2)
时的NOT_MOUNTED
function tryToBootstrapAndMount(app, unmountAllPromise) {
if (shouldBeActive(app)) {
return toBootstrapPromise(app).then(function (app) {
return unmountAllPromise.then(function () {
return shouldBeActive(app) ? toMountPromise(app) : app;
});
});
} else {
return unmountAllPromise.then(function () {
return app;
});
}
}
function shouldBeActive(app) {
return app.activeWhen(window.location);
}
function toBootstrapPromise(appOrParcel, hardFail) {
return Promise.resolve().then(function () {
if (appOrParcel.status !== NOT_BOOTSTRAPPED) {
return appOrParcel;
}
appOrParcel.status = BOOTSTRAPPING;
if (!appOrParcel.bootstrap) {
// Default implementation of bootstrap
return Promise.resolve().then(successfulBootstrap);
}
return reasonableTime(appOrParcel, "bootstrap")
.then(successfulBootstrap)
.catch(function (err) {
//...
});
});
function successfulBootstrap() {
appOrParcel.status = NOT_MOUNTED;
return appOrParcel;
}
}
function toMountPromise(appOrParcel, hardFail) {
return Promise.resolve().then(function () {
return reasonableTime(appOrParcel, "mount")
.then(function () {
appOrParcel.status = MOUNTED;
//...
return appOrParcel;
})
.catch(function (err) {
//...
});
});
}
3.2.2 toUnloadPromise()
逻辑也非常简单,就是触发子应用提供的 app2.unload()
生命周期方法,将状态改为 UNLOADING
=> 将状态改为 NOT_LOADED
var appsToUnload = {};
function toUnloadPromise(app) {
return Promise.resolve().then(function () {
var unloadInfo = appsToUnload[toName(app)];
if (app.status === NOT_LOADED) {
finishUnloadingApp(app, unloadInfo);
return app;
}
if (app.status === UNLOADING) {
return unloadInfo.promise.then(function () {
return app;
});
}
if (app.status !== NOT_MOUNTED && app.status !== LOAD_ERROR) {
return app;
}
var unloadPromise =
app.status === LOAD_ERROR
? Promise.resolve()
: reasonableTime(app, "unload");
app.status = UNLOADING;
return unloadPromise
.then(function () {
finishUnloadingApp(app, unloadInfo);
return app;
})
.catch(function (err) {
errorUnloadingApp(app, unloadInfo, err);
return app;
});
});
}
function finishUnloadingApp(app, unloadInfo) {
delete appsToUnload[toName(app)];
delete app.bootstrap;
delete app.mount;
delete app.unmount;
delete app.unload;
app.status = NOT_LOADED;
unloadInfo.resolve();
}
3.3.3 finishUpAndReturn()
- 返回已经挂载应用的列表
- 处理等待中的 pendingPromises
- 触发全局事件通知
- 重置全局状态
appChangeUnderway = false
- 检测是否有未处理的后续请求,如果有,则重新触发
reroute()
处理
function finishUpAndReturn() {
var returnValue = getMountedApps();
pendingPromises.forEach(function (promise) {
return promise.resolve(returnValue);
});
var appChangeEventName =
appsThatChanged.length === 0
? "single-spa:no-app-change"
: "single-spa:app-change";
window.dispatchEvent(
new customEvent(appChangeEventName, getCustomEventDetail())
);
window.dispatchEvent(
new customEvent("single-spa:routing-event", getCustomEventDetail())
);
appChangeUnderway = false;
if (peopleWaitingOnAppChange.length > 0) {
var nextPendingPromises = peopleWaitingOnAppChange;
peopleWaitingOnAppChange = [];
reroute(nextPendingPromises);
}
return returnValue;
}
import-html-entry v1.17.0
假设我们要转化的 index.html 为:
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Hello Micro Frontend</h1>
<script src="app.js" entry></script>
<script src="async.js" async></script>
<script>console.log('Inline script');</script>
</body>
</html>
style.css
的具体内容为:
body { background-color: lightblue; }
app.js
内容为:
// 子应用导出的生命周期钩子
export function bootstrap() {
console.log("Sub app bootstrap");
}
export function mount() {
console.log("Sub app mounted");
}
bootstrap();
**async.js**
内容为:
console.log("Async script loaded");
我们在外部使用这个库一般直接使用 importEntry()
获取子应用的数据
在我们这个示例中,会传入一个 entry = "index.html"
,因此会直接走 importHTML()
function importEntry(entry, opts = {}) {
const {
fetch = defaultFetch,
getTemplate = defaultGetTemplate,
postProcessTemplate,
} = opts;
const getPublicPath =
opts.getPublicPath || opts.getDomain || defaultGetPublicPath;
// html entry
if (typeof entry === "string") {
return importHTML(entry, {
fetch,
getPublicPath,
getTemplate,
postProcessTemplate,
});
}
// config entry
if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) {
//...
} else {
throw new SyntaxError("entry scripts or styles should be array!");
}
}
从下面代码可以知道,主要分为几个步骤:
- 获取 HTML 内容:通过
fetch
直接请求对应的https://xxxxx
得到对应的 HTML 字符串(也就是我们上面示例的 index.html 内容) 解析 HTML:调用
processTpl()
解析 HTML 得到scripts = ["https://sub-app.com/app.js ", "<script>console.log('Inline script');</script>"]
entry = "[https://sub-app.com/app.js](https://sub-app.com/app.js) "
styles = ["https://sub-app.com/style.css "]
- 将 CSS 样式进行内联:在
getEmbedHTML()
下载style.css
的内容,替换 template 模板中<link>
为<style>
标签
function importHTML(url, opts = {}) {
//...
return (
embedHTMLCache[url] ||
(embedHTMLCache[url] = fetch(url)
.then((response) => readResAsString(response, autoDecodeResponse))
.then((html) => {
const assetPublicPath = getPublicPath(url);
const { template, scripts, entry, styles } = processTpl(
getTemplate(html),
assetPublicPath,
postProcessTemplate
);
return getEmbedHTML(template, styles, { fetch }).then((embedHTML) => ({
template: embedHTML,
assetPublicPath,
getExternalScripts: () => getExternalScripts(scripts, fetch),
getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
execScripts: (proxy, strictGlobal, opts = {}) => {
if (!scripts.length) {
return Promise.resolve();
}
return execScripts(entry, scripts, proxy, {
fetch,
strictGlobal,
...opts,
});
},
}));
}))
);
}
1. processTpl()
processTpl()
在上面的分析中,我们可以知道,就是用来解析 HTML,得到:
scripts = ["https://sub-app.com/app.js ", "<script>console.log('Inline script');</script>"]
entry = "[https://sub-app.com/app.js](https://sub-app.com/app.js) "
styles = ["https://sub-app.com/style.css "]
那具体是如何运行的呢?
1.1 移除 HTML 注释
const HTML_COMMENT_REGEX = /<!--([\s\S]*?)-->/g;
.replace(HTML_COMMENT_REGEX, '')
1.2 处理 <link>
标签
从下面的正则表达式可以知道,主要分为:
处理
<link rel="stylesheet">
:提取出href
并且转为绝对路径,然后进行两种类型的注释代码转化:- 检测是否有
ignore
标记,通过genIgnoreAssetReplaceSymbol()
转化为<!-- ignore asset ${url || 'file'} replaced by import-html-entry -->
- 添加到 styles 数组中,然后通过
genLinkReplaceSymbol()
转化为<!-- ${preloadOrPrefetch ? 'prefetch/preload' : ''} link ${linkHref} replaced by import-html-entry -->
- 检测是否有
- 处理
预加载/预取资源
:若匹配到rel="preload"
或rel="prefetch"
且非字体资源,则通过genLinkReplaceSymbol()
转化为占位符注释
const LINK_TAG_REGEX = /<(link)\s+.*?>/isg;
const STYLE_TYPE_REGEX = /\s+rel=('|")?stylesheet\1.*/;
const LINK_IGNORE_REGEX = /<link(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;
const LINK_PRELOAD_OR_PREFETCH_REGEX = /\srel=('|")?(preload|prefetch)\1/;
const LINK_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/;
const LINK_AS_FONT = /.*\sas=('|")?font\1.*/;
.replace(LINK_TAG_REGEX, match => {
/*
change the css link
*/
const styleType = !!match.match(STYLE_TYPE_REGEX);
if (styleType) {
const styleHref = match.match(STYLE_HREF_REGEX);
const styleIgnore = match.match(LINK_IGNORE_REGEX);
if (styleHref) {
const href = styleHref && styleHref[2];
let newHref = href;
if (href && !hasProtocol(href)) {
newHref = getEntirePath(href, baseURI);
}
if (styleIgnore) {
return genIgnoreAssetReplaceSymbol(newHref);
}
newHref = parseUrl(newHref);
styles.push(newHref);
return genLinkReplaceSymbol(newHref);
}
}
const preloadOrPrefetchType = match.match(LINK_PRELOAD_OR_PREFETCH_REGEX) && match.match(LINK_HREF_REGEX) && !match.match(LINK_AS_FONT);
if (preloadOrPrefetchType) {
const [, , linkHref] = match.match(LINK_HREF_REGEX);
return genLinkReplaceSymbol(linkHref, true);
}
return match;
})
1.3 处理 <style>
标签
如果 <style>
标签中包含 ignore
属性,则通过 genIgnoreAssetReplaceSymbol()
转化为 <!-- ignore asset ${url || 'file'} replaced by import-html-entry -->
,否则直接返回原来的值
const STYLE_TAG_REGEX = /<style[^>]*>[\s\S]*?<\/style>/gi;
.replace(STYLE_TAG_REGEX, match => {
if (STYLE_IGNORE_REGEX.test(match)) {
return genIgnoreAssetReplaceSymbol('style file');
}
return match;
})
1.4 处理 **<script>**
标签
分为external script
(匹配到src
属性或者匹配到<script>
并且不具备type="text/ng-template"
属性)和 inline script
两种情况进行分析
在解析 HTML 模板时,忽略 Angular 的 **ng-template**
标签 ,仅提取需要执行的脚本标签
const SCRIPT_TAG_REGEX = /<(script)\s+((?!type=('|")text\/ng-template\3).)*?>.*?<\/\1>/is;
const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/;
.replace(ALL_SCRIPT_REGEX, (match, scriptTag) => {
if (SCRIPT_TAG_REGEX.test(match) && scriptTag.match(SCRIPT_SRC_REGEX)) {
//...
} else {
//...
}
}
1.4.1 external script
将相对路径的 matchedScriptSrc
通过 getEntirePath()
转化为绝对路径,并且使用 parseUrl()
标准化拿到标准化后的 src
然后分为 3 种情况进行处理:
- 如果包含
ignore
属性,则通过genIgnoreAssetReplaceSymbol()
转化为注释代码 - 如果浏览器不支持
module
但是<script type="module">
或者浏览器支持module
但是<script nomodule>
,则通过genModuleScriptReplayceSymbol()
转化为注释代码 - 提取出
async
和crossorigin
属性,将当前<script>
添加到scripts
数组中,然后通过genScriptReplaceSymbol()
转化为注释代码
if (matchedScriptSrc) {
if (!hasProtocol(matchedScriptSrc)) {
matchedScriptSrc = getEntirePath(matchedScriptSrc, baseURI);
}
matchedScriptSrc = parseUrl(matchedScriptSrc);
}
entry = entry || (matchedScriptEntry && matchedScriptSrc);
if (scriptIgnore) {
return genIgnoreAssetReplaceSymbol(matchedScriptSrc || "js file");
}
const moduleScriptIgnore =
(moduleSupport && !!scriptTag.match(SCRIPT_NO_MODULE_REGEX)) ||
(!moduleSupport && !!scriptTag.match(SCRIPT_MODULE_REGEX));
if (moduleScriptIgnore) {
return genModuleScriptReplaceSymbol(
matchedScriptSrc || "js file",
moduleSupport
);
}
if (matchedScriptSrc) {
const asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX);
const crossOriginScript = !!scriptTag.match(SCRIPT_CROSSORIGIN_REGEX);
scripts.push(
asyncScript || crossOriginScript
? {
async: asyncScript,
src: matchedScriptSrc,
crossOrigin: crossOriginScript,
}
: matchedScriptSrc
);
return genScriptReplaceSymbol(
matchedScriptSrc,
asyncScript,
crossOriginScript
);
}
return match;
1.4.2 inline script
- 如果包含
ignore
属性,则通过genIgnoreAssetReplaceSymbol()
转化为注释代码 - 如果浏览器不支持
module
但是<script type="module">
或者浏览器支持module
但是<script nomodule>
,则通过genModuleScriptReplayceSymbol()
转化为注释代码 - 如果是纯注释的代码块
isPureCommentBlock()
,那么直接返回inlineScriptReplaceSymbol
,不做任何处理;否则添加到scripts
数组中
const isPureCommentBlock = code
.split(/[\r\n]+/)
.every((line) => !line.trim() || line.trim().startsWith("//"));
export const inlineScriptReplaceSymbol = `<!-- inline scripts replaced by import-html-entry -->`;
if (!isPureCommentBlock) {
scripts.push(match);
}
return inlineScriptReplaceSymbol;
1.4.3 触发 postProcessTemplate 钩子函数
let tplResult = {
template,
scripts,
styles,
// set the last script as entry if have not set
entry: entry || scripts[scripts.length - 1],
};
if (typeof postProcessTemplate === "function") {
tplResult = postProcessTemplate(tplResult);
}
return tplResult;
2. getEmbedHTML()
processTpl()
解析 HTML,得到:
scripts = ["https://sub-app.com/app.js ", "<script>console.log('Inline script');</script>"]
entry = "[https://sub-app.com/app.js](https://sub-app.com/app.js) "
styles = ["https://sub-app.com/style.css "]
源码中对应位置的代码已经被我们转化为注释代码,我们需要下载这些代码并且转化
我们通过 getExternalStyleSheets()
进行 fetch
请求拿到 styles = ["https://sub-app.com/style.css "]
的数据,返回 styleSheets=[{src, value}]
然后我们将 template
中对应的外部 CSS 替换为 <style>{styleSheet.value}</style>
的內联样式
function getEmbedHTML(template, styles, opts = {}) {
const { fetch = defaultFetch } = opts;
let embedHTML = template;
return getExternalStyleSheets(styles, fetch).then((styleSheets) => {
embedHTML = styleSheets.reduce((html, styleSheet) => {
const styleSrc = styleSheet.src;
const styleSheetContent = styleSheet.value;
html = html.replace(
genLinkReplaceSymbol(styleSrc),
isInlineCode(styleSrc)
? `${styleSrc}`
: `<style>/* ${styleSrc} */${styleSheetContent}</style>`
);
return html;
}, embedHTML);
return embedHTML;
});
}
3. 返回值对象解析
3.1 template
替换 template 模板中 <link>
为 <style>
标签后的内容,比如
从上面分析可以知道,我们会先使用 processTpl()
获取 scripts、styles 的数据,然后下载 styles 的数据,然后转化为 <style> 替换原来 template 数据
<html>...<style>body { background-color: lightblue; }</style>...</html>
3.2 assetPublicPath
微应用的路径,比如 assetPublicPath: "https://sub-app.com/xxxx/xxxx/"
(不包含最后的 index.html)
export function defaultGetPublicPath(entry) {
if (typeof entry === "object") {
return "/";
}
try {
const { origin, pathname } = new URL(entry, location.href);
const paths = pathname.split("/");
// 移除最后一个元素
paths.pop();
return `${origin}${paths.join("/")}/`;
} catch (e) {
console.warn(e);
return "";
}
}
3.3 getExternalScripts
遍历所有的 js 文件
- 如果遇到內联 js,直接返回:比如
<script>console.log('Inline script');</script>
- 如果遇到外部 js,使用
fetch
进行 js 文件的请求下载:比如<script src="app.js" entry></script>
- 如果遇到异步 js,也就是
<script async></script>
,通过requestIdleCallback
进行延迟加载
scripts.map(async (script) => {
if (typeof script === "string") {
if (isInlineCode(script)) {
// if it is inline script
return getInlineCode(script);
} else {
// external script
return fetchScript(script);
}
} else {
// use idle time to load async script
const { src, async, crossOrigin } = script;
const fetchOpts = crossOrigin ? { credentials: "include" } : {};
if (async) {
return {
src,
async: true,
content: new Promise((resolve, reject) =>
requestIdleCallback(() =>
fetchScript(src, fetchOpts).then(resolve, reject)
)
),
};
}
return fetchScript(src, fetchOpts);
}
});
最终返回值为
[
{
src: "https://sub-app.com/app.js ",
value: "export function bootstrap() ...bootstrap();",
},
{
src: "https://sub-app.com/async.js ",
async: true,
content: Promise.resolve("console.log('Async script loaded');"),
},
{
src: "<script>console.log('Inline script');</script>",
value: "console.log('Inline script');",
},
];
3.4 getExternalStyleSheets
styleCache[styleLink] ||
(styleCache[styleLink] = fetch(styleLink).then(response => {
if (response.status >= 400) {
throw new Error(`${styleLink} load failed with status ${response.status}`);
}
return response.text();
})
// 省略很多promise
if (result.status === 'fulfilled') {
result.value = {
src: styles[i],
value: result.value,
};
}
return result;
使用 fetch
进行样式文件的请求下载,最终形成
[
{
src: "https://sub-app.com/style.css ",
value: "body { background-color: lightblue; }",
},
];
3.5 execScripts
传入的 scripts 就是 上面分析的 getExternalScripts 的 scripts
if (!scripts.length) {
return Promise.resolve();
}
return execScripts(entry, scripts, proxy, {
fetch,
strictGlobal,
...opts,
});
使用 getExternalScripts()
进行 js 文件的下载并存入缓存中,然后对每一个 JS 都调用 geval(scriptSrc, inlineScript)
entry JS:
noteGlobalProps()
记录微应用在执行入口脚本前的全局变量,用于后续沙箱清理- 触发
geval()
生成沙箱代码并执行,确保 JS 在代理的上下文中运行,避免全局污染 - 触发
resolve(exports)
将 entry JS 导出对象(包含生命周期钩子函数)传递给外部
- 同步的 JS:触发
geval()
生成沙箱代码并执行 - 异步的 JS:在浏览器空闲时触发下载的方法然后再调用
geval()
生成沙箱代码并执行
export function execScripts(entry, scripts, proxy = window, opts = {}) {
//...
return getExternalScripts(scripts, fetch, entry).then((scriptsText) => {
const geval = (scriptSrc, inlineScript) => {
//...
};
function exec(scriptSrc, inlineScript, resolve) {
if (scriptSrc === entry) {
noteGlobalProps(strictGlobal ? proxy : window);
geval(scriptSrc, inlineScript);
const exports =
proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
resolve(exports);
} else {
if (typeof inlineScript === "string") {
if (scriptSrc?.src) {
geval(scriptSrc.src, inlineScript);
} else {
geval(scriptSrc, inlineScript);
}
} else {
// external script marked with async
inlineScript.async &&
inlineScript?.content.then((downloadedScriptText) =>
geval(inlineScript.src, downloadedScriptText)
);
}
}
}
function schedule(i, resolvePromise) {
if (i < scriptsText.length) {
const script = scriptsText[i];
const scriptSrc = script.src;
const inlineScript = script.value;
exec(scriptSrc, inlineScript, resolvePromise);
// resolve the promise while the last script executed and entry not provided
if (!entry && i === scriptsText.length - 1) {
resolvePromise();
} else {
schedule(i + 1, resolvePromise);
}
}
}
return new Promise((resolve) => schedule(0, success || resolve));
});
}
3.5.1 geval()生成沙箱代码
const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript;
const code = getExecutableScript(scriptSrc, rawCode, {
proxy,
strictGlobal,
scopedGlobalVariables,
});
evalCode(scriptSrc, code);
afterExec(inlineScript, scriptSrc);
通过 getExecutableScript()
生成 sandbox 代码
(function (window, self, globalThis) {
with (window) {
// app.js 的原始代码
export function bootstrap() {
console.log("Sub app bootstrap");
}
export function mount() {
console.log("Sub app mounted");
}
bootstrap();
}
}).bind(proxy)(proxy, proxy, proxy);
使用 evalCode()
转化代码并缓存到 evalCache
,然后强制该函数在全局上下文中执行
使用**(0, eval)**
是一种“间接调用”方式,强制**eval**
在全局作用域中执行,从而模拟浏览器对**<script>**
标签的执行行为
function evalCode(scriptSrc, code) {
const key = scriptSrc;
if (!evalCache[key]) {
const functionWrappedCode = `(function(){${code}})`;
evalCache[key] = (0, eval)(functionWrappedCode);
}
const evalFunc = evalCache[key];
evalFunc.call(window);
}
evalCode 示例
evalCode("app.js", 'console.log("Hello from eval"); var x = 42;');
比如上面这段代码,经过 evalCode()
转化得到 evalFunc
,然后直接触发 evalFunc
的执行
(function () {
console.log("Hello from eval");
var x = 42;
}).call(window);
4. 总结
我们通过 processTpl()
抽离出资源数据,并且将这些资源数据代码转化为注释代码,避免重复多次加载
scripts = ["https://sub-app.com/app.js ", "<script>console.log('Inline script');</script>"]
entry = "[https://sub-app.com/app.js](https://sub-app.com/app.js) "
styles = ["https://sub-app.com/style.css "]
然后调用 getEmbedHTML()
先把上面得到的 styles
下载并转化为 <style></style>
內联样式替换到 template
中,然后返回一个对象数据,包括
template
:替换了所有 styles 的模板数据assetPublicPath
:微应用的路径getExternalScripts()
:提供外部调用可以下载scripts = ["https://sub-app.com/app.js ", "<script>console.log('Inline script');</script>"]
的方法getExternalStyleSheets()
:提供外部调用可以下载styles = ["https://sub-app.com/style.css "]
的方法execScripts()
:执行getExternalScripts()
下载scripts
,然后调用geval()
生成沙箱代码并执行,确保 JS 在代理的上下文中运行,避免全局污染
function importHTML(url, opts = {}) {
//...
return (
embedHTMLCache[url] ||
(embedHTMLCache[url] = fetch(url)
.then((response) => readResAsString(response, autoDecodeResponse))
.then((html) => {
const assetPublicPath = getPublicPath(url);
const { template, scripts, entry, styles } = processTpl(
getTemplate(html),
assetPublicPath,
postProcessTemplate
);
return getEmbedHTML(template, styles, { fetch }).then((embedHTML) => ({
template: embedHTML,
assetPublicPath,
getExternalScripts: () => getExternalScripts(scripts, fetch),
getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
execScripts: (proxy, strictGlobal, opts = {}) => {
if (!scripts.length) {
return Promise.resolve();
}
return execScripts(entry, scripts, proxy, {
fetch,
strictGlobal,
...opts,
});
},
}));
}))
);
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。