import-html-entry 是 qiankun 中一个举足轻重的依赖,用于获取子应用的 HTML 和 JS,同时对 HTML 和 JS 进行了各自的处理,以便于子应用在父应用中加载。
在微前端中,使用此依赖可以直接获取到子应用 (某 url ) 对应的 html 且此 html 上已经嵌好了所有的 css,同时还可以直接执行子应用的所有 js 脚本且此脚本还为 js 隔离(避免污染全局)做了预处理。
如下的代码就是将所有的 stylesheet href 对应的 css 嵌入到 html 后的结果,同样本身是字符串,在这里为了清晰做了格式化。
<head>
<style>
/* https://https://zhoulujun.net/css/brands.css */
@font-face {
font-family: 'Font Awesome 5 Brands';
...
}
.fab {
font-family: 'Font Awesome 5 Brands';
font-weight: 400;
}
</style>
<style>
h1 { font-size: 40px; }
</style>
</head>
<body>
<h1>Zhang Pao Pao</h1>
<!-- script https://zhoulujun.net//js/brands.js replaced by import-html-entry -->
<!-- inline scripts replaced by import-html-entry -->
<!-- script http://localhost:7101/main.js replaced by import-html-entry -->
</body>
下面是拉取 HTML 并处理的主要代码,整体的内容可到 import-html-entry 中查看。
// 代码片段2,所属文件:src/index.js
export default function importHTML(url, opts = {}) {
// 1. 通过 fetch 获取到 url 对应的 html
return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url)
.then(html => {
// 2. 从返回的结果中解析出以下内容a.经过初步处理后的 html, b.由所有 "script" 组成的数组, c.由所有 "style" 组成的数组
const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath, postProcessTemplate);
// 3. 将所有的 css 嵌入到上述经过初步处理后的 html 中
return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({
template: embedHTML,
assetPublicPath,
getExternalScripts: () => getExternalScripts(scripts, fetch),
getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => {
if (!scripts.length) {
return Promise.resolve();
}
return execScripts(entry, scripts, proxy, {
fetch,
strictGlobal,
beforeExec: execScriptsHooks.beforeExec,
afterExec: execScriptsHooks.afterExec,
});
},
}));
}));
}
}
embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url)这种使用缓存和给缓存赋值的方式,在日常开发中可以借鉴。
getEmbedHTML实际上主要做了两件事:
function getEmbedHTML(template, styles, opts = {}) {
// 1. fetch "style" 数组里面对应的 css
return getExternalStyleSheets(styles, fetch)
.then(styleSheets => {
embedHTML = styles.reduce((html, styleSrc, i) => {
// 2. 将拉取到的每一个 href 对应的 css 通过 `<style> </style>` 包裹起来且嵌入到 html 中
html = html.replace(genLinkReplaceSymbol(styleSrc), `<style>/* ${styleSrc} */${styleSheets[i]}</style>`);
return html;
}, embedHTML);
});
}
export function getExternalStyleSheets(styles, fetch = defaultFetch) {
return Promise.all(styles.map(styleLink => {
if (isInlineCode(styleLink)) {
// if it is inline style
return getInlineCode(styleLink);
} else {
// external styles
return styleCache[styleLink] ||
(styleCache[styleLink] = fetch(styleLink).then(response => response.text()));
}
},
));
}
export function execScripts(entry, scripts, proxy = window, opts = {}) {
// 此处省略许多代码...
return getExternalScripts(scripts, fetch, error)// 和获取js资源链接对应的内容
.then(scriptsText => {
const geval = (scriptSrc, inlineScript) => {
// 此处省略许多代码...
// 这里主要是把js代码进行一定处理,然后拼装成一个自执行函数,然后用eval执行
// 这里最关键的是调用了getExecutableScript,绑定了window.proxy改变js代码中的this引用
};
function exec(scriptSrc, inlineScript, resolve) {
// 这里省略许多代码...
// 根据不同的条件,在不同的时机调用geval函数执行js代码,并将入口函数执行完暴露的含有微应用生命周期函数的对象返回
// 这里省略许多代码...
}
function schedule(i, resolvePromise) {
// 这里省略许多代码...
// 依次调用exec函数执行js资源对应的代码
}
return new Promise(resolve => schedule(0, success || resolve));
});
}
关于processTpl的代码,我不打算逐行进行分析,相反我会讲其中一个原本不应该是重要的点,那就是其中涉及到的正则表达式,这部分虽然看起来很基础,但实际上是理解函数processTpl的关键所在。我将在下面代码片段中注释上各个正则表达式可能匹配的内容,再整体描述一下主要逻辑,有了这些介绍,相信朋友们可以自己读懂该函数剩下的代码。
// 代码片段3,所属文件:src/process-tpl.js
/*
匹配整个script标签及其包含的内容,比如 <script>xxxxx</script>或<script xxx>xxxxx</script>
[\s\S] 匹配所有字符。\s 是匹配所有空白符,包括换行,\S 非空白符,不包括换行
* 匹配前面的子表达式零次或多次
+ 匹配前面的子表达式一次或多次
正则表达式后面的全局标记 g 指定将该表达式应用到输入字符串中能够查找到的尽可能多的匹配。
表达式的结尾处的不区分大小写 i 标记指定不区分大小写。
*/
const ALL_SCRIPT_REGEX = /(<script[\s\S]*?>)[\s\S]*?<\/script>/gi;
/*
. 匹配除换行符 \n 之外的任何单字符
? 匹配前面的子表达式零次或一次,或指明一个非贪婪限定符。
圆括号会有一个副作用,使相关的匹配会被缓存,此时可用 ?: 放在第一个选项前来消除这种副作用。
其中 ?: 是非捕获元之一,还有两个非捕获元是 ?= 和 ?!, ?=为正向预查,在任何开始匹配圆括
号内的正则表达式模式的位置来匹配搜索字符串,?!为负向预查,在任何开始不匹配该正则表达式模
式的位置来匹配搜索字符串。
举例:exp1(?!exp2):查找后面不是 exp2 的 exp1。
所以这里的真实含义是匹配script标签,但type不能是text/ng-template
*/
const SCRIPT_TAG_REGEX = /<(script)\s+((?!type=('|")text\/ng-template\3).)*?>.*?<\/\1>/is;
/*
* 匹配包含src属性的script标签
^ 匹配输入字符串的开始位置,但在方括号表达式中使用时,表示不接受该方括号表达式中的字符集合。
*/
const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/;
// 匹配含 type 属性的标签
const SCRIPT_TYPE_REGEX = /.*\stype=('|")?([^>'"\s]+)/;
// 匹配含entry属性的标签//
const SCRIPT_ENTRY_REGEX = /.*\sentry\s*.*/;
// 匹配含 async属性的标签
const SCRIPT_ASYNC_REGEX = /.*\sasync\s*.*/;
// 匹配向后兼容的nomodule标记
const SCRIPT_NO_MODULE_REGEX = /.*\snomodule\s*.*/;
// 匹配含type=module的标签
const SCRIPT_MODULE_REGEX = /.*\stype=('|")?module('|")?\s*.*/;
// 匹配link标签
const LINK_TAG_REGEX = /<(link)\s+.*?>/isg;
// 匹配含 rel=preload或rel=prefetch 的标签, 小提示:rel用于规定当前文档与被了链接文档之间的关系,比如rel=“icon”等
const LINK_PRELOAD_OR_PREFETCH_REGEX = /\srel=('|")?(preload|prefetch)\1/;
// 匹配含href属性的标签
const LINK_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/;
// 匹配含as=font的标签
const LINK_AS_FONT = /.*\sas=('|")?font\1.*/;
// 匹配style标签
const STYLE_TAG_REGEX = /<style[^>]*>[\s\S]*?<\/style>/gi;
// 匹配rel=stylesheet的标签
const STYLE_TYPE_REGEX = /\s+rel=('|")?stylesheet\1.*/;
// 匹配含href属性的标签
const STYLE_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/;
// 匹配注释
const HTML_COMMENT_REGEX = /<!--([\s\S]*?)-->/g;
// 匹配含ignore属性的 link标签
const LINK_IGNORE_REGEX = /<link(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;
// 匹配含ignore属性的style标签
const STYLE_IGNORE_REGEX = /<style(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;
// 匹配含ignore属性的script标签
const SCRIPT_IGNORE_REGEX = /<script(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;
了解了这些正则匹配规则,为我们接下来的分析做好了准备,由于源码中processTpl内容比较丰富,为了方便理解,接下来我会将源码中实际的代码替换成我的注释。
// 代码片段4,所属文件:src/process-tpl.js
export default function processTpl(tpl, baseURI, postProcessTemplate) {
// 这里省略许多代码...
let styles = [];
const template = tpl
.replace(HTML_COMMENT_REGEX, '') // 删掉注释
.replace(LINK_TAG_REGEX, match => {
// 这里省略许多代码...
// 如果link标签中有ignore属性,则替换成占位符`<!-- ignore asset ${ href || 'file'} replaced by import-html-entry -->`
// 如果link标签中没有ignore属性,将标签替换成占位符`<!-- ${preloadOrPrefetch ? 'prefetch/preload' : ''} link ${linkHref} replaced by import-html-entry -->`
})
.replace(STYLE_TAG_REGEX, match => {
// 这里省略许多代码...
// 如果style标签有ignore属性,则将标签替换成占位符`<!-- ignore asset style file replaced by import-html-entry -->`
})
.replace(ALL_SCRIPT_REGEX, (match, scriptTag) => {
// 这里省略许多代码...
// 这里虽然有很多代码,但可以概括为匹配正则表达式,替换成相应的占位符
});
// 这里省略一些代码...
let tplResult = {
template,
scripts,
styles,
entry: entry || scripts[scripts.length - 1],
};
// 这里省略一些代码...
return tplResult;
}
从上面代码中可以看出,在将相应的标签被替换成占位符后,最终返回了一个tplResult对象。该对象中的scripts、styles都是是数组,保存的是一个个链接,也就是被占位符替换的标签原有的href对应的值。
通过 1.2.b 可以获取到 url 文件下对应的由所有 “script” 组成的数组 ,其中包含两部分内容:
获取到所有的 script code
export function getExternalScripts(scripts, fetch) {
// 根据 script src 的 url fetch js
const fetchScript = scriptUrl => fetch(scriptUrl).then(response => (...)));
return Promise.all(scripts.map(script => {
// 如果是页级 script ,直接返回
if (isInlineCode(script)) {
return getInlineCode(script);
} else {
// 如果不是,那么通过 fetch 获取
return fetchScript(script);
}
},
));
}
将获取到的 js code 处理成 IIFE 字符串,并且为后续实现应用与应用之间隔离做处理
其实这里描述成 “处理成 IIFE 字符串” 不是非常正确,因为 IIFE 指的是立即执行函数,是一个函数,而这里只是把 js code 包裹在 (function(xxx){ code })(xxx) 中,但的确没有想到更好的描述方式,所以暂时这样描述吧!!
function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;
// 通过这种方式获取全局 window,具体原因可参考源码在这里的注释
const globalWindow = (0, eval)('window');
// 如果这里的 proxy 为 window 沙箱,那么就可以实现应用隔离
globalWindow.proxy = proxy;
// 利用 IIFE 将 code 里会使用到的 window, self, globalThis 传递进去,为后续的应用与应用之间隔离做处理
return strictGlobal
? `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
: `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}
这里的代码非常的有意思(但实际开发千万不要用,感觉用了要挨锤)
示例中页级 script 得到的 IIFE 字符串(同样本身是字符串,在这里为了清晰做了格式化)
;(
function(window, self, globalThis){
;console.log('this is script in-line');
}
).bind(window.proxy)
(window.proxy, window.proxy, window.proxy);
当然,外联的 script 得到的也是同样 IIFE 字符串,只是其中内容不同。
执行上述的 IIFE 字符串,实际上就是执行所有的 js code
export function evalCode(scriptSrc, code) {
const key = scriptSrc;
if (!evalCache[key]) {
// 将 IIFE 字符串包裹在 function 中
const functionWrappedCode = `window.__TEMP_EVAL_FUNC__ = function(){${code}}`;
// window.__TEMP_EVAL_FUNC__ = function(){...} eval 将上面的字符串转换成代码
(0, eval)(functionWrappedCode);
evalCache[key] = window.__TEMP_EVAL_FUNC__;
delete window.__TEMP_EVAL_FUNC__;
}
const evalFunc = evalCache[key];
// 执行上面得到的匿名函数,其中内容为第二点的 IIFE ,因此也就是执行了 js code
// 这里是真正的执行
evalFunc.call(window);
}
对于 CSS 沙箱,常见的实现有三种模式,我们称之为 Dynamic Style 模式 , ShadowDOM 模式与Scoped 模式。以下,对每种模式做一个简单的分析。
由此可见,与 JS 沙箱相似,CSS 沙箱的常见做法中每个模式都会有一部分问题无法很好的解决,那是否我们就无法得到一个安全隔离的运行环境了呢? 我们是否能够限制不可控的范围呢?
参考文章:
揭开 import-html-entry 面纱 https://blog.csdn.net/qq_41800366/article/details/122093720
转载本站文章《微前端学习笔记(5):从import-html-entry发微DOM/JS/CSS隔离》, 请注明出处:https://www.zhoulujun.cn/html/webfront/engineer/Architecture/9066.html
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。