import-html-entry源码分析

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.jsb.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在这里做了两件事情:

  1. 参数的标准化处理
  2. 使用fetch获取html资源,并且resolve的结果为一个对象,对象包含template 、assetPublicPath 、getExternalScripts 、getExternalStyleSheets 、execScripts5个属性,
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函数主要做了以下几件事情:

  1. 移除注释
  2. 提取css
  3. 提取script
  4. 提取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已经分析完成,我们总结下他的执行流程:

  1. 通过fetch函数获取html模板
  2. 通过processTpl函数处理模板,提取出css和script
  3. 通过getEmbedHTML函数将外链css转换为style标签中的css
  4. 将处理后的结果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函数、strictGlobalsuccesserrorbeforeExecafterExecscopedGlobalVariables

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,这里有两行代码比较关键:

    js 复制代码
    const 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);
  1. 首先这是一个自执行函数
  2. 其次,这个自执行函数的函数体是一个匿名函数,通过bind绑定了window.proxy,这里的window.proxy就是我们传入的代理对象
  3. 最后,这个匿名函数的入参是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的使用确实秀到了我,大写的佩服,还有半个小时下班,各位看官周末愉快,下周见!

复制代码
相关推荐
慧一居士17 分钟前
flex 布局完整功能介绍和示例演示
前端
DoraBigHead19 分钟前
小哆啦解题记——两数失踪事件
前端·算法·面试
一斤代码6 小时前
vue3 下载图片(标签内容可转图)
前端·javascript·vue
中微子6 小时前
React Router 源码深度剖析解决面试中的深层次问题
前端·react.js
光影少年6 小时前
从前端转go开发的学习路线
前端·学习·golang
中微子6 小时前
React Router 面试指南:从基础到实战
前端·react.js·前端框架
3Katrina6 小时前
深入理解 useLayoutEffect:解决 UI "闪烁"问题的利器
前端·javascript·面试
前端_学习之路7 小时前
React--Fiber 架构
前端·react.js·架构
伍哥的传说8 小时前
React 实现五子棋人机对战小游戏
前端·javascript·react.js·前端框架·node.js·ecmascript·js
qq_424409198 小时前
uniapp的app项目,某个页面长时间无操作,返回首页
前端·vue.js·uni-app