概念

微前端是一种架构风格,它将一个大型的前端应用拆分成多个独立的微应用,这些微应用可以独立开发、部署和维护。微前端的目标是将一个复杂的前端应用拆分成多个小的、可独立开发和部署的应用,从而提高开发效率和应用的可维护性。
在实际场景中,我们常常需要再当前主应用,嵌入某个子应用,就不必自己开发,子应用可能有别的业务部门开发,技术栈亦不一致。
几种集成微前端的方式
npm 包
- 将子应用以
lib
模式构建,提供一个mount
应用挂载方法,提供一个打成一个npm
包。 - 主应用在项目中以依赖的方式安装子应用。
js
// micoapp.js
export function mount(app: HTMLElement) {
micoapp.render(app);
}
问题:
- 子应用需要单独维护一个 npm 包,一个
lib
的构建模式 - 构建子应用时,会需要打包对依赖进行控制。我们通常构建库模式不会希望打包所有依赖,依赖由主应用安装,但是子应用的依赖可会和主应用冲突,比如:主应用使用的是
vue@2.x
,库使用的是vue@3.x
:- 在构建工具层面会可能报错(与构建工具策略相关),比如查找构建
vue3
的 apiref,watch
时,会报错 - 运行时报错,在运行时,
vue@2.x
和vue@3.x
的全局命名空间都是Vue
会冲突,出现兼容性问题 - 所以需要知道冲突的依赖,进行
internal
内联构建处理
- 在构建工具层面会可能报错(与构建工具策略相关),比如查找构建
- 需要给子应用增加样式作用域,否则会出现样式污染;比如:
tailwindcss
需要加唯一prefix
- 子应用的路由需要单独管理,主应用需要根据子应用的路由进行跳转 ;
- 加载性能问题
打包成 umd
格式
将子应用打包成 umd
格式,将依赖全量构建。上传 cdn,可以解决每次更新子应用,都需要主应用升级依赖发布的问题。
js
const umd = {
format: "umd",
name: "SportShare",
entryFileNames: format,
// 输出目录
dir: "dist/umd",
};
使用 iframe
嵌入
直接利用原生 iframe
嵌入子应用的站点,可以有效的解决样式隔离和脚本隔离的问题,但是存在一些问题:
- 子路由跳转路由后,刷新页面,无法保持状态,即主应用和子应用的路由通信问题
- 子应用的
modal
始终是基于iframe
定位的,会和主应用割裂 iframe
的每次创建,会并发加载大量资源,造成性能损耗
微前端框架可以有效解决以上问题。
wujie 微前端框架原理解析

解析 html:
js
// 简化源码
const { template, getExternalScripts, getExternalStyleSheets } =
await importHTML({ url, html, opts: {} });
//fetch html text
function importHTML(params: {
url: string,
html?: string,
opts: ImportEntryOpts,
}): Promise<htmlParseResult> {
const getHtmlParseResult = (url, html, htmlLoader) =>
fetch(url)
.then((response) => {
return response.text();
})
.then((html) => {
const assetPublicPath = getPublicPath(url);
// 解析 html 中的 script 和 link 标签 template
const { template, scripts, styles } = processTpl(
htmlLoader(html),
assetPublicPath
);
return {
template: template,
getExternalScripts: () => getExternalScripts(scripts),
getExternalStyleSheets: () => getExternalStyleSheets(styles),
};
});
return getHtmlParseResult(url, html, htmlLoader);
}
样式隔离
将 style
转换成 inline style
,替换原来的 template
中的 link
,再将 template 插入到 shadow dom
中。
js
/**
* convert external css link to inline style for performance optimization
* @return embedHTML
*/
async function getEmbedHTML(
template,
styleResultList: StyleResultList
): Promise<string> {
let embedHTML = template;
return Promise.all(
styleResultList.map((styleResult, index) =>
styleResult.contentPromise.then((content) => {
if (styleResult.src) {
embedHTML = embedHTML.replace(
genLinkReplaceSymbol(styleResult.src),
styleResult.ignore
? `<link href="${styleResult.src}" rel="stylesheet" type="text/css">`
: `<style>/* ${styleResult.src} */${content}</style>`
);
} else if (content) {
embedHTML = embedHTML.replace(
getInlineStyleReplaceSymbol(index),
`<style>/* inline-style-${index} */${content}</style>`
);
}
})
)
).then(() => embedHTML);
}
/**
* 将template渲染到shadowRoot
*/
export async function renderTemplateToShadowRoot(
shadowRoot: ShadowRoot,
iframeWindow: Window,
template: string
): Promise<void> {
const html = renderTemplateToHtml(iframeWindow, template);
// 处理 css-before-loader 和 css-after-loader
const processedHtml = await processCssLoaderForTemplate(
iframeWindow.__WUJIE,
html
);
shadowRoot.appendChild(processedHtml);
}
脚本隔离 SandBox
js
// 创建一个iframe
class wujie{
constructor(attrs){
this.iframe = iframeGenerator(this, attrs, mainHostPath, appHostPath, appRoutePath);
}
start(){
const scriptResultList = await getExternalScripts();
// 同步代码
const syncScriptResultList: ScriptResultList = [];
// async代码无需保证顺序,所以不用放入执行队列
const asyncScriptResultList: ScriptResultList = [];
// defer代码需要保证顺序并且DOMContentLoaded前完成,这里统一放置同步脚本后执行
const deferScriptResultList: ScriptResultList = [];
scriptResultList.forEach((scriptResult) => {
if (scriptResult.defer) deferScriptResultList.push(scriptResult);
else if (scriptResult.async) asyncScriptResultList.push(scriptResult);
else syncScriptResultList.push(scriptResult);
});
// 同步代码
// fiber ? 则在浏览器空闲时间插入iframe
syncScriptResultList.concat(deferScriptResultList).forEach((scriptResult) => {
this.execQueue.push(() =>
scriptResult.contentPromise.then((content) =>
this.fiber
? this.requestIdleCallback(() => insertScriptToIframe({ ...scriptResult, content }, iframeWindow))
: insertScriptToIframe({ ...scriptResult, content }, iframeWindow)
)
);
});
}
// asyncScriptResultList 同上
}
proxy代理
js
/**
* 非降级情况下window、document、location代理
*/
export function proxyGenerator(
iframe: HTMLIFrameElement,
urlElement: HTMLAnchorElement,
mainHostPath: string,
appHostPath: string
): {
proxyWindow: Window;
proxyDocument: Object;
proxyLocation: Object;
} {
const proxyWindow = new Proxy(iframe.contentWindow, {
get: (target: Window, p: PropertyKey): any => {
// location进行劫持
if (p === "location") {
return target.__WUJIE.proxyLocation;
}
// 判断自身
if (p === "self" || (p === "window" && Object.getOwnPropertyDescriptor(window, "window").get)) {
return target.__WUJIE.proxy;
}
// 不要绑定this
if (p === "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__" || p === "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__") {
return target[p];
}
// https://262.ecma-international.org/8.0/#sec-proxy-object-internal-methods-and-internal-slots-get-p-receiver
const descriptor = Object.getOwnPropertyDescriptor(target, p);
if (descriptor?.configurable === false && descriptor?.writable === false) {
return target[p];
}
// 修正this指针指向
return getTargetValue(target, p);
},
set: (target: Window, p: PropertyKey, value: any) => {
checkProxyFunction(target, value);
target[p] = value;
return true;
},
has: (target: Window, p: PropertyKey) => p in target,
});
// proxy document
const proxyDocument = new Proxy(
{},
{
get: function (_fakeDocument, propKey) {
const document = window.document;
const { shadowRoot, proxyLocation } = iframe.contentWindow.__WUJIE;
// iframe初始化完成后,webcomponent还未挂在上去,此时运行了主应用代码,必须中止
if (!shadowRoot) stopMainAppRun();
const rawCreateElement = iframe.contentWindow.__WUJIE_RAW_DOCUMENT_CREATE_ELEMENT__;
const rawCreateTextNode = iframe.contentWindow.__WUJIE_RAW_DOCUMENT_CREATE_TEXT_NODE__;
// need fix
if (propKey === "createElement" || propKey === "createTextNode") {
return new Proxy(document[propKey], {
apply(_createElement, _ctx, args) {
const rawCreateMethod = propKey === "createElement" ? rawCreateElement : rawCreateTextNode;
const element = rawCreateMethod.apply(iframe.contentDocument, args);
patchElementEffect(element, iframe.contentWindow);
return element;
},
});
}
if (propKey === "documentURI" || propKey === "URL") {
return (proxyLocation as Location).href;
}
// from shadowRoot
if (
propKey === "getElementsByTagName" ||
propKey === "getElementsByClassName" ||
propKey === "getElementsByName"
) {
return new Proxy(shadowRoot.querySelectorAll, {
apply(querySelectorAll, _ctx, args) {
let arg = args[0];
if (_ctx !== iframe.contentDocument) {
return _ctx[propKey].apply(_ctx, args);
}
if (propKey === "getElementsByTagName" && arg === "script") {
return iframe.contentDocument.scripts;
}
if (propKey === "getElementsByClassName") arg = "." + arg;
if (propKey === "getElementsByName") arg = `[name="${arg}"]`;
// FIXME: This string must be a valid CSS selector string; if it's not, a SyntaxError exception is thrown;
// so we should ensure that the program can execute normally in case of exceptions.
// reference: https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll
let res: NodeList[] | [];
try {
res = querySelectorAll.call(shadowRoot, arg);
} catch (error) {
res = [];
}
return res;
},
});
}
if (propKey === "getElementById") {
return new Proxy(shadowRoot.querySelector, {
// case document.querySelector.call
apply(target, ctx, args) {
if (ctx !== iframe.contentDocument) {
return ctx[propKey]?.apply(ctx, args);
}
try {
return (
target.call(shadowRoot, `[id="${args[0]}"]`) ||
iframe.contentWindow.__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__.call(
iframe.contentWindow.document,
`#${args[0]}`
)
);
} catch (error) {
warn(WUJIE_TIPS_GET_ELEMENT_BY_ID);
return null;
}
},
});
}
if (propKey === "querySelector" || propKey === "querySelectorAll") {
const rawPropMap = {
querySelector: "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__",
querySelectorAll: "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__",
};
return new Proxy(shadowRoot[propKey], {
apply(target, ctx, args) {
if (ctx !== iframe.contentDocument) {
return ctx[propKey]?.apply(ctx, args);
}
// 二选一,优先shadowDom,除非采用array合并,排除base,防止对router造成影响
return (
target.apply(shadowRoot, args) ||
(args[0] === "base"
? null
: iframe.contentWindow[rawPropMap[propKey]].call(iframe.contentWindow.document, args[0]))
);
},
});
}
if (propKey === "documentElement" || propKey === "scrollingElement") return shadowRoot.firstElementChild;
if (propKey === "forms") return shadowRoot.querySelectorAll("form");
if (propKey === "images") return shadowRoot.querySelectorAll("img");
if (propKey === "links") return shadowRoot.querySelectorAll("a");
const { ownerProperties, shadowProperties, shadowMethods, documentProperties, documentMethods } =
documentProxyProperties;
if (ownerProperties.concat(shadowProperties).includes(propKey.toString())) {
if (propKey === "activeElement" && shadowRoot.activeElement === null) return shadowRoot.body;
return shadowRoot[propKey];
}
if (shadowMethods.includes(propKey.toString())) {
return getTargetValue(shadowRoot, propKey) ?? getTargetValue(document, propKey);
}
// from window.document
if (documentProperties.includes(propKey.toString())) {
return document[propKey];
}
if (documentMethods.includes(propKey.toString())) {
return getTargetValue(document, propKey);
}
},
}
);
// proxy location
const proxyLocation = new Proxy(
{},
{
get: function (_fakeLocation, propKey) {
const location = iframe.contentWindow.location;
if (
propKey === "host" ||
propKey === "hostname" ||
propKey === "protocol" ||
propKey === "port" ||
propKey === "origin"
) {
return urlElement[propKey];
}
if (propKey === "href") {
return location[propKey].replace(mainHostPath, appHostPath);
}
if (propKey === "reload") {
warn(WUJIE_TIPS_RELOAD_DISABLED);
return () => null;
}
if (propKey === "replace") {
return new Proxy(location[propKey], {
apply(replace, _ctx, args) {
return replace.call(location, args[0]?.replace(appHostPath, mainHostPath));
},
});
}
return getTargetValue(location, propKey);
},
set: function (_fakeLocation, propKey, value) {
// 如果是跳转链接的话重开一个iframe
if (propKey === "href") {
return locationHrefSet(iframe, value, appHostPath);
}
iframe.contentWindow.location[propKey] = value;
return true;
},
ownKeys: function () {
return Object.keys(iframe.contentWindow.location).filter((key) => key !== "reload");
},
getOwnPropertyDescriptor: function (_target, key) {
return { enumerable: true, configurable: true, value: this[key] };
},
}
);
return { proxyWindow, proxyDocument, proxyLocation };
}
预加载
其实就是先试用fetch
获取资源,后续资源就可以走缓存 如果开启了 fiber
,则在浏览器空闲时间插入资源,可以提高性能,避免占用主线程
js
// for prefetch
export function getExternalStyleSheets(
styles: StyleObject[],
fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response> = defaultFetch,
loadError: loadErrorHandler
): StyleResultList {
return styles.map(({ src, content, ignore }) => {
// 内联
if (content) {
return { src: "", contentPromise: Promise.resolve(content) };
} else if (isInlineCode(src)) {
// if it is inline style
return { src: "", contentPromise: Promise.resolve(getInlineCode(src)) };
} else {
// external styles
return {
src,
ignore,
contentPromise: ignore ? Promise.resolve("") : fetchAssets(src, styleCache, fetch, true, loadError),
};
}
});
}
// for prefetch
export function getExternalScripts(
scripts: ScriptObject[],
fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response> = defaultFetch,
loadError: loadErrorHandler,
fiber: boolean
): ScriptResultList {
// module should be requested in iframe
return scripts.map((script) => {
const { src, async, defer, module, ignore } = script;
let contentPromise = null;
// async
if ((async || defer) && src && !module) {
contentPromise = new Promise((resolve, reject) =>
fiber
? requestIdleCallback(() => fetchAssets(src, scriptCache, fetch, false, loadError).then(resolve, reject))
: fetchAssets(src, scriptCache, fetch, false, loadError).then(resolve, reject)
);
// module || ignore
} else if ((module && src) || ignore) {
contentPromise = Promise.resolve("");
// inline
} else if (!src) {
contentPromise = Promise.resolve(script.content);
// outline
} else {
contentPromise = fetchAssets(src, scriptCache, fetch, false, loadError);
}
// refer https://html.spec.whatwg.org/multipage/scripting.html#attr-script-defer
if (module && !async) script.defer = true;
return { ...script, contentPromise };
});
}
主子应用路由同步
js
/**
* 同步子应用路由到主应用路由
*/
export function syncUrlToWindow(iframeWindow: Window): void {
const { sync, id, prefix } = iframeWindow.__WUJIE;
let winUrlElement = anchorElementGenerator(window.location.href);
const queryMap = getAnchorElementQueryMap(winUrlElement);
// 非同步且url上没有当前id的查询参数,否则就要同步参数或者清理参数
if (!sync && !queryMap[id]) return (winUrlElement = null);
const curUrl = iframeWindow.location.pathname + iframeWindow.location.search + iframeWindow.location.hash;
let validShortPath = "";
// 同步
if (sync) {
queryMap[id] = window.encodeURIComponent(
validShortPath ? curUrl.replace(prefix[validShortPath], `{${validShortPath}}`) : curUrl
);
// 清理
} else {
delete queryMap[id];
}
const newQuery =
"?" +
Object.keys(queryMap)
.map((key) => key + "=" + queryMap[key])
.join("&");
winUrlElement.search = newQuery;
if (winUrlElement.href !== window.location.href) {
window.history.replaceState(null, "", winUrlElement.href);
}
winUrlElement = null;
}
/**
* 同步主应用路由到子应用
*/
export function syncUrlToIframe(iframeWindow: Window): void {
// 获取当前路由路径
const { pathname, search, hash } = iframeWindow.location;
const { id, url, sync, execFlag, prefix, inject } = iframeWindow.__WUJIE;
// 只在浏览器刷新或者第一次渲染时同步
const idUrl = sync && !execFlag ? getSyncUrl(id, prefix) : url;
// 排除href跳转情况
const syncUrl = (/^http/.test(idUrl) ? null : idUrl) || url;
const { appRoutePath } = appRouteParse(syncUrl);
const preAppRoutePath = pathname + search + hash;
if (preAppRoutePath !== appRoutePath) {
iframeWindow.history.replaceState(null, "", inject.mainHostPath + appRoutePath);
}
}