1. 为什么要分析import-html-entry
在qiankun中,子应用的入口文件是通过import-html-entry来加载的,作为qiankun一个重要的依赖,我们有必要了解import-html-entry的实现原理,才能更好的理解qiankun。
2. 一个简单的例子
这里的例子是import-html-entry
库中的一个例子,这里我稍微做了下改动,用src中的index.js
为入口文件,便于我们分析代码。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>index</title>
</head>
<body>
<!--<script src="../dist/import-html-entry.js"></script>-->
<script type="module">
import importHTML from '../src/index.js';
importHTML('./template.html').then(res => {
console.log(res.template);
res.execScripts().then(exports => {
console.log(exports);
});
});
</script>
</body>
</html>
至于template.html
文件,和库中保持一致
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>test</title>
<link href="https://unpkg.com/antd@3.13.6/dist/antd.min.css" rel="stylesheet">
<link href="https://unpkg.com/bootstrap@4.3.1/dist/css/bootstrap-grid.min.css" rel="stylesheet">
</head>
<body>
<script src="./a.js"></script>
<script ignore>alert(1)</script>
<script src="./b.js"></script>
<script src="https://unpkg.com/react@16.4.2/umd/react.production.min.js"></script>
<script src="https://unpkg.com/mobx@5.0.3/lib/mobx.umd.js"></script>
</body>
</html>
a.js
和b.js
文件内容也无变化,如下
javascript
// a.js
window.React;
console.log('a');
javascript
// b.js
console.log('b');
在浏览器控制台输出的结果如下
stdout
// 控制台输出
<!DOCTYPE html>
...
<style>
// antd.min.css antd样式
...
</style>
<style>
// bootstrap-grid.min.css bootstrap样式
...
</style>
<!-- script http://localhost:63342/import-html-entry/example/a.js replaced by import-html-entry -->
<!-- ignore asset js file replaced by import-html-entry -->
<!-- script http://localhost:63342/import-html-entry/example/b.js replaced by import-html-entry -->
<!-- script https://unpkg.com/react@16.4.2/umd/react.production.min.js replaced by import-html-entry -->
<!-- script https://unpkg.com/mobx@5.0.3/lib/mobx.umd.js replaced by import-html-entry -->
</html>
a
b
{$mobx:...}
接下来的旅程,我们将从这个例子开始,一步步分析import-html-entry
的实现原理。
3. 源码分析
3.1 import-html-entry
概述
- 以html文件为入口,加载html依赖的资源,比如css,js等
- 从入口脚本中获取导出的内容
在我们上面的例子中,导出的内容就是mobx
3.2 import-html-entry
的实现
我们以示例中的代码为突破口,来深入分析import-html-entry
的执行机制,
3.2.1 importHTML
那么首先要分析的就是importHTML('./template.html')
这个函数,源码如下:
js
export default function importHTML(url, opts = {}) {
let fetch = defaultFetch;
let autoDecodeResponse = false;
let getPublicPath = defaultGetPublicPath;
let getTemplate = defaultGetTemplate;
const { postProcessTemplate } = opts;
// compatible with the legacy importHTML api
if (typeof opts === 'function') {
fetch = opts;
} else {
// fetch option is availble
if (opts.fetch) {
// ...
}
// 赋值 getPublicPath getTemplate
getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath;
getTemplate = opts.getTemplate || defaultGetTemplate;
}
// 获取html资源,返回一个promise
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);
// 返回一个promise
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,
});
},
}));
}));
}
importHTML
在这里做了两件事情:
- 参数的标准化处理
- 使用
fetch
获取html资源,并且resolve
的结果为一个对象,对象包含template 、assetPublicPath 、getExternalScripts 、getExternalStyleSheets 、execScripts
5个属性,
processTpl
在返回的结果中,我们依赖processTpl
的调用结果,这里我们再分析下processTpl
这个函数:
javascript
export default function processTpl(tpl, baseURI, postProcessTemplate) {
// 初始化scripts,styles,entry
let scripts = [];
const styles = [];
let entry = null;
const moduleSupport = isModuleScriptSupported();
// 对模板资源进行资源提取
const template = tpl
// 1. 移除注释
.replace(HTML_COMMENT_REGEX, '')
// 2. 提取css
.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)) {
// baseUrl:"http://localhost:63342/import-html-entry/example/"
newHref = getEntirePath(href, baseURI);
}
if (styleIgnore) {
return genIgnoreAssetReplaceSymbol(newHref);
}
newHref = parseUrl(newHref);
// 将css资源提取处理塞入styles数组中
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;
})
// 匹配style标签
.replace(STYLE_TAG_REGEX, match => {
if (STYLE_IGNORE_REGEX.test(match)) {
return genIgnoreAssetReplaceSymbol('style file');
}
return match;
})
// 匹配script标签
.replace(ALL_SCRIPT_REGEX, (match, scriptTag) => {
const scriptIgnore = scriptTag.match(SCRIPT_IGNORE_REGEX);
const moduleScriptIgnore =
(moduleSupport && !!scriptTag.match(SCRIPT_NO_MODULE_REGEX)) ||
(!moduleSupport && !!scriptTag.match(SCRIPT_MODULE_REGEX));
// in order to keep the exec order of all javascripts
const matchedScriptTypeMatch = scriptTag.match(SCRIPT_TYPE_REGEX);
const matchedScriptType = matchedScriptTypeMatch && matchedScriptTypeMatch[2];
if (!isValidJavaScriptType(matchedScriptType)) {
return match;
}
// if it is a external script
if (SCRIPT_TAG_REGEX.test(match) && scriptTag.match(SCRIPT_SRC_REGEX)) {
/*
collect scripts and replace the ref
*/
const matchedScriptEntry = scriptTag.match(SCRIPT_ENTRY_REGEX);
const matchedScriptSrcMatch = scriptTag.match(SCRIPT_SRC_REGEX);
let matchedScriptSrc = matchedScriptSrcMatch && matchedScriptSrcMatch[2];
if (entry && matchedScriptEntry) {
throw new SyntaxError('You should not set multiply entry script!');
}
// 将baseUrl和scriptSrc进行拼接
if (matchedScriptSrc) {
// append the domain while the script not have a protocol prefix
// 拼接端口号
if (!hasProtocol(matchedScriptSrc)) {
matchedScriptSrc = getEntirePath(matchedScriptSrc, baseURI);
}
matchedScriptSrc = parseUrl(matchedScriptSrc);
}
entry = entry || matchedScriptEntry && matchedScriptSrc;
if (scriptIgnore) {
return genIgnoreAssetReplaceSymbol(matchedScriptSrc || 'js file');
}
if (moduleScriptIgnore) {
return genModuleScriptReplaceSymbol(matchedScriptSrc || 'js file', moduleSupport);
}
if (matchedScriptSrc) {
const asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX);
const crossOriginScript = !!scriptTag.match(SCRIPT_CROSSORIGIN_REGEX);
// 将scriptSrc插入scripts数组中
scripts.push((asyncScript || crossOriginScript) ? { async: asyncScript, src: matchedScriptSrc, crossOrigin: crossOriginScript } : matchedScriptSrc);
return genScriptReplaceSymbol(matchedScriptSrc, asyncScript, crossOriginScript);
}
return match;
} else {
if (scriptIgnore) {
return genIgnoreAssetReplaceSymbol('js file');
}
if (moduleScriptIgnore) {
return genModuleScriptReplaceSymbol('js file', moduleSupport);
}
// if it is an inline script
const code = getInlineCode(match);
// remove script blocks when all of these lines are comments.
const isPureCommentBlock = code.split(/[\r\n]+/).every(line => !line.trim() || line.trim().startsWith('//'));
if (!isPureCommentBlock) {
scripts.push(match);
}
return inlineScriptReplaceSymbol;
}
});
scripts = scripts.filter(function (script) {
// filter empty script
return !!script;
});
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;
}
上面的代码比较多,但是核心要处理的事情比较清晰,我们可以看到,processTpl函数主要做了以下几件事情:
- 移除注释
- 提取css
- 提取script
- 提取entry入口文件:如果没有指定入口文件,那么默认使用最后一个script作为入口文件
那么最后我们得到的tplResult
就是一个对象,在我们这个示例中,这个函数最终返回的如下:
json
{
"template": "<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>test</title>
<!-- link https://unpkg.com/antd@3.13.6/dist/antd.min.css replaced by import-html-entry -->
<!-- link https://unpkg.com/bootstrap@4.3.1/dist/css/bootstrap-grid.min.css replaced by import-html-entry -->
</head>
<body>
<!-- script http://localhost:63342/import-html-entry/example/a.js replaced by import-html-entry -->
<!-- ignore asset js file replaced by import-html-entry -->
<!-- script http://localhost:63342/import-html-entry/example/b.js replaced by import-html-entry -->
<!-- script https://unpkg.com/react@16.4.2/umd/react.production.min.js replaced by import-html-entry -->
<!-- script https://unpkg.com/mobx@5.0.3/lib/mobx.umd.js replaced by import-html-entry -->
</body>
</html>
",
"scripts": [
"http://localhost:63342/import-html-entry/example/a.js",
"http://localhost:63342/import-html-entry/example/b.js",
"https://unpkg.com/react@16.4.2/umd/react.production.min.js",
"https://unpkg.com/mobx@5.0.3/lib/mobx.umd.js"
],
"styles": [
"https://unpkg.com/antd@3.13.6/dist/antd.min.css",
"https://unpkg.com/bootstrap@4.3.1/dist/css/bootstrap-grid.min.css"
],
"entry": "https://unpkg.com/mobx@5.0.3/lib/mobx.umd.js"
}
回过头我们继续分析在执行完processTpl函数后,我们调用了getEmbedHTML
函数,最后将它的结果resolve出去
getEmbedHTML
这个函数是用来加载css的,我们来看一下它的实现:
js
function getEmbedHTML(template, styles, opts = {}) {
const { fetch = defaultFetch } = opts;
let embedHTML = template;
// 使用fetch函数加载css
return getExternalStyleSheets(styles, fetch)
// 这里的styleSheets是所有外链css的内容数组,['antd v3.13.6 css...', 'bootstrap v4.3.1 css...']
.then(styleSheets => {
embedHTML = styles.reduce((html, styleSrc, i) => {
// 将css外链替换为style标签
html = html.replace(genLinkReplaceSymbol(styleSrc), isInlineCode(styleSrc) ? `${styleSrc}` : `<style>/* ${styleSrc} */${styleSheets[i]}</style>`);
return html;
}, embedHTML);
return 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()));
}
},
));
}
getEmbedHTML
的目的就是为了将我们的外链css转换为style标签中的css。
目前为止,importHTML
已经分析完成,我们总结下他的执行流程:
- 通过
fetch
函数获取html模板 - 通过
processTpl
函数处理模板,提取出css和script - 通过
getEmbedHTML
函数将外链css转换为style标签中的css - 将处理后的结果resolve出去
接下来我们将继续分析execScripts
函数
3.2.2 execScripts
这个函数是用来执行script
的,源码如下:
js
export function execScripts(entry, scripts, proxy = window, opts = {}) {
const {
fetch = defaultFetch, strictGlobal = false, success, error = () => {
}, beforeExec = () => {
}, afterExec = () => {
},
scopedGlobalVariables = [],
} = opts;
return getExternalScripts(scripts, fetch, error)
.then(scriptsText => {
const geval = (scriptSrc, inlineScript) => {
const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript;
const code = getExecutableScript(scriptSrc, rawCode, { proxy, strictGlobal, scopedGlobalVariables });
evalCode(scriptSrc, code);
afterExec(inlineScript, scriptSrc);
};
function exec(scriptSrc, inlineScript, resolve) {
const markName = `Evaluating script ${scriptSrc}`;
const measureName = `Evaluating Time Consuming: ${scriptSrc}`;
if (scriptSrc === entry) {
noteGlobalProps(strictGlobal ? proxy : window);
// bind window.proxy to change `this` reference in script
geval(scriptSrc, inlineScript);
const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
resolve(exports);
} else {
if (typeof inlineScript === 'string') {
// bind window.proxy to change `this` reference in script
geval(scriptSrc, inlineScript);
} else {
// external script marked with async
// 处理其他形式的script标签
}
}
}
function schedule(i, resolvePromise) {
if (i < scripts.length) {
const scriptSrc = scripts[i];
const inlineScript = scriptsText[i];
exec(scriptSrc, inlineScript, resolvePromise);
// resolve the promise while the last script executed and entry not provided
if (!entry && i === scripts.length - 1) {
resolvePromise();
} else {
schedule(i + 1, resolvePromise);
}
}
}
return new Promise(resolve => schedule(0, success || resolve));
});
}
execScripts
有这样几个入参:
- entry:入口script,在本例中为最后一个script,
https://unpkg.com/mobx@5.0.3
- scripts:所有的script,包括外链script和内联script
- proxy:代理对象,用来代理window对象,默认为window
- opts:配置项,包括
fetch
函数、strictGlobal
、success
、error
、beforeExec
、afterExec
、scopedGlobalVariables
execScripts
的目的就是为了执行所有的script,首先是调用了getExternalScripts
,这个函数不展开分析了,是用fetch来获取script链接的内容,之后内部定义了三个函数:geval
,exec
,schedule
,下面我们按照执行顺序来分析这三个函数
-
schedule: 用来执行所有的script,这里使用了递归的方式,每次执行一个script,直到所有的script都执行完毕,这里有一个细节,就是在执行最后一个script的时候,如果没有entry,那么就会resolve出去,这里的entry就是我们的入口script,如果没有入口script,那么就会在最后一个script执行完毕后resolve出去
-
exec:在每一轮递归中执行script,这个函数有三个入参:
scriptSrc
是js代码对应的src链接地址,inlineScript
是使用fetch获取到的js代码,resolve就是外层的resole,函数内部的逻辑有两条线:- 当scriptSrc等于entry的时候,说明是入口script,我们稍后再分析这里的逻辑
- 当scriptSrc不等于entry的时候,说明是其他的script,,那么就会执行
geval
函数
-
geval:用来绑定window.proxy,然后执行script,这里有两行代码比较关键:
jsconst code = getExecutableScript(scriptSrc, rawCode, { proxy, strictGlobal, scopedGlobalVariables }); evalCode(scriptSrc, code);
getExecutableScript
第一步调用getExecutableScript
函数,这个函数的作用是将script的内容包裹在一个函数中,这个函数的作用是为了将script的作用域隔离开,这样就不会污染全局作用域了
js
function getExecutableScript(scriptSrc, scriptText, opts = {}) {
const { proxy, strictGlobal, scopedGlobalVariables = [] } = opts;
const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;
// 将 scopedGlobalVariables 拼接成变量声明,用于缓存全局变量,避免每次使用时都走一遍代理
const scopedGlobalVariableDefinition = scopedGlobalVariables.length ? `const {${scopedGlobalVariables.join(',')}}=this;` : '';
// 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
// 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
const globalWindow = (0, eval)('window');
globalWindow.proxy = proxy;
// TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并
return strictGlobal
? (
scopedGlobalVariableDefinition
? `;(function(){with(this){${scopedGlobalVariableDefinition}${scriptText}\n${sourceUrl}}}).bind(window.proxy)();`
: `;(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);`;
}
这里有一个骚操作,const globalWindow = (0, eval)('window');
,这里的作用在代码的注释例写的也很清楚,至于深层次的原因(eval的直接调用和间接调用),大家可以自行百度了解一下。之后就return了一个 模板字符串,乍一看这一段代码很难懂,我们不妨代入我们要执行的代码,并且把他格式化一下看看:
js
;(function(window, self, globalThis){
// 这里是我们要执行的代码
console.log('hello world')
}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);
- 首先这是一个自执行函数
- 其次,这个自执行函数的函数体是一个匿名函数,通过bind绑定了
window.proxy
,这里的window.proxy
就是我们传入的代理对象 - 最后,这个匿名函数的入参是
window
,self
,globalThis
,他们都是指向window.proxy
的,也就是我们传入的代理对象
evalCode
我们继续分析第二步,这里调用evalCode
函数,这个函数的作用是执行script,这里有一个细节,就是在执行script的时候,会将script的内容包裹在一个函数中,这个函数的作用是为了将script的作用域隔离开,这样就不会污染全局作用域了
js
export 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
的最后就是执行我们的script代码
小结:目前我们已经分析了一个script从拉取到执行的全过程
我们再来看看入口script的逻辑,入口script的逻辑和其他script的逻辑是一样的,只是多了一些额外的逻辑,我们来看看这些额外的逻辑:
js
// 这是额外的逻辑
noteGlobalProps(strictGlobal ? proxy : window);
// bind window.proxy to change `this` reference in script
geval(scriptSrc, inlineScript);
// 这也是额外的逻辑
const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
resolve(exports);
noteGlobalProps
noteGlobalProps
:这个函数的作用是获取window上的最后一个属性
js
export function noteGlobalProps(global) {
// alternatively Object.keys(global).pop()
// but this may be faster (pending benchmarks)
firstGlobalProp = secondGlobalProp = undefined;
for (let p in global) {
if (shouldSkipProperty(global, p))
continue;
if (!firstGlobalProp)
firstGlobalProp = p;
else if (!secondGlobalProp)
secondGlobalProp = p;
lastGlobalProp = p;
}
return lastGlobalProp;
}
- 然后是继续执行
geval
,因为是入口文件,且mobx会在全局添加一个变量mobx - 第三步就是获取我们需要暴露出去的全局变量,这里用到了
getGlobalProp
,下面的代码中我们的lastProp就是mobx
js
export function getGlobalProp(global) {
let cnt = 0;
let lastProp;
let hasIframe = false;
for (let p in global) {
if (shouldSkipProperty(global, p))
continue;
if (!hasIframe && (cnt === 0 && p !== firstGlobalProp || cnt === 1 && p !== secondGlobalProp))
return p;
cnt++;
lastProp = p;
}
if (lastProp !== lastGlobalProp)
return lastProp;
那么最后理所当然的返回了mobx,也就是我们的全局变量
4总结
大结:import-html-entry
的大致流程就分享到这里了,代码并不多,但是eval的使用确实秀到了我,大写的佩服,还有半个小时下班,各位看官周末愉快,下周见!