Bootstrap

微前端学习(上)

一、课程目标

  1. 微前端概念;
  2. 现有方案利弊;
  3. Single-spa实现原理;
  4. 掌握使用qiankun搭建微应用;

二、课程大纲

  1. 微前端背景
  2. 现在web应用面临的问题
  3. 微前端的价值
  4. 微前端应用具备哪些能力
  5. 微前端解决方案有哪些
  6. 基于qiankun的实践

1、微前端背景

2014年: Martin Fowler和James Lewis共同提出了 微服务 的概念。微服务是一种开发软件的架构和组织方法,其中软件由通过明确定义的API进行通信的 小型独立服务 组成。
微服务的主要思路是:

  • 将应用 分解 为小的、互相连接的微服务,一个微服务完成某个 特定功能。

  • 每个微服务都有自己的业务逻辑和适配器,不同的微服务,可以使用 不同的技术 去实现。

  • 使用 统一的网关 进行调用。
    由上可见微服务的主要思路是化繁为简,通过更加细致的划分,使得服务内部更加内聚,服务之间耦合性降低,有利于项目的团队开发和后期维护。把微服务的概念应用到前端,前端微服务/微前端服务 就诞生了,简称其为微前端

    微前端的概念是由Thoughtworks在2016年提出的。
    2018年:第一个微前端工具 single-spa 在 github 开源。
    2019年:基于 single-spa 的 qiankun 问世。
    2020年: Module Federation(webpack5) 把项目中模块分为本地模块和远程模块,远程模块不属于当前构建,在运行时从所谓的容器加载。加载远程模块是异步操作。当使用远程模块时这些异步操作将被放置在远程模块和入口之间的下一个chunk的加载操作中,从而实现微前端的构建。

接下来我们将围绕两大内容进行展开:

  1. 微前端解决的问题,微前端的特点,如何实现微前端,并以qiankun为例,手把手带大家把应用接入微前端。
  2. 微前端的核心实现原理,并手把手从0-1实现简单的微前端框架。

2、微前端是什么

微前端是一种架构风格,它允许可独立的前端应用程序被组合成一个更大的整体。
白话:多个小的前端应用拼成了一个更大的更完整的应用。

3、现在web应用面临的问题

  • DX(developer experience)
    • 业务领域的代码块不够独立和高度可用。
    • 相同的产品功能由多个团队开发 / 产品功能难以保持统一。
    • 新的产品理念无法在不同的应用中快速复用 / 实现。
    • 快速迭代新子业务 / 干净移除将被淘汰的子业务。
  • UX(user experience)
    • 性能体验
    • 页面跳转和用户体验问题
  • 多个系统在一个仓库应用中,不同子应用独立SPA模式。
  • 系统分为多个仓库,独立上线部署,采用MAP模式。

4、微前端的特点

  • 技术栈无关 主框架不限制接入应用的技术栈,子应用可自主选择技术栈;
  • 独立开发/部署 各个团队之间仓库独立,单独部署,互不依赖;
  • 增量升级 当一个应用庞大之后,技术升级或重构相当麻烦,而微应用具备渐进式升级的特性;
  • 独立运行时 微应用之间运行时互不依赖,有独立的状态管理;
  • 提升效率 应用越庞大,越难以维护,协作效率越低下。微应用可以很好拆分,提升效率;

由上可见,微前端给我们带来了前所未有的开发体验,它不限技术栈、易于迭代,光这两点就很让我兴奋。

5、微前端价值

 工程:独立开发/部署/交付 --> 平滑升级 --> 提升构建效率
 
 商业:团队自治 --> 产品动态化 --> 业务颗粒化

6、微前端应用具备哪些能力

  1. 预加载:空闲时加载子应用资源用户行为数据支持。
  2. 公共依赖加载
  3. 按需加载:切换页面时才加载相应的 HTML、CSS 和 JS。
  4. JS沙箱:子应用之间互不影响,包括全局变量、事件等处理。
  5. CSS隔离:互不影响,切换时装载和卸载。
  6. HTML Entry
  7. Config Entry
  8. 父子应用通信
  9. 子应用嵌套:微前端如何嵌微前端的进阶用法。
  10. 子应用并行

7、微前端解决方案有哪些

7.1 使用HTTP服务器的路由来重定向多个应用

7.2 基于 iframe 完全隔离的方案

iframe 就相当于页面内再开一个窗口加载别的页面

  • 优点:简单、完美隔离、不限制使用。
  • 缺点:
    • 每次进来都要加载,状态不能保留;
    • 父子应用交互难;
    • iframe 中的弹窗无法突破其本身;
    • 整个应用会全量资源加载,加载太慢。

7.3 EMP基于 webpack module federation

使用纯的 Web Components 构建应用

兼容性不好

7.4 业界主流微前端框架

7.4.1 基于 single-spa 路由劫持方案

single-spa 就做了两件事情:

  • 加载微应用 (加载方法还得用户自己来实现)
  • 管理微应用的状态(初始化、挂载、卸载)

single-spa 仅仅是一个子应用生命周期的调度着。
借鉴了组件生命周期的思想,为应用设置了针对路由的生命周期。

single-spa又约定了应用脚本包含以下生命周期:

  1. load:当应用匹配路由时就会加载脚本(非函数,只是一种状态)
  2. bootstrap:引导函数(对接html,应用内容首次挂载到页面前调用)
  3. mount:挂载函数
  4. unmount:卸载函数(须移除事件绑定等内容)
  5. unload:非必要(unload 之后会重新启动 bootstrap 流程;借助 unload 可实现热更新)
Register
Load
Bootstrap
Mount
Unmount
Unload

7.4.2 qiankun

基于 single-spa 的微前端实现库,旨在帮助大家能更简单无痛 的构建一个生产可用微前端架构系统。
孵化自蚂蚁金融科技,经过充分校验及打磨后,将其微前端内核抽取出来并开源。

  • 通过 import-html-enter 包解析 HTML 获取资源路径,然后对资源进行解析、加载。
  • 通过对执行环境的修改,他实现了JS沙箱、样式隔离等特性

7.4.3 import-html-entry

HTML Entryimport-html-entry 库实现的,通过 http 请求加载指定地址的首屏内容即 html 页面,然后解析这个 html 模版得到 template, scripts, entry, styles

7.4.3.1 importEntry
/**
 * 加载指定地址的首屏内容
 * @param {*} entry 可以是一个字符串格式的地址,比如 localhost:8080,也可以是一个配置对象,比如 { scripts, styles, html }
 * @param {*} opts
 * return importHTML 的执行结果
 */
export function importEntry(entry, opts = {}) {
    // 从 opt 参数中解析出 fetch 方法 和 getTemplate 方法,没有就用默认的
    const { fetch = defaultFetch, getTemplate = defaultGetTemplate } = opts;
    // 获取静态资源地址的一个方法
    const getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath;

    if (!entry) {
        throw new SyntaxError('entry should not be empty!');
    }

    // html entry,entry 是一个字符串格式的地址
    if (typeof entry === 'string') {
        return importHTML(entry, { fetch, getPublicPath, getTemplate });
    }

    // config entry,entry 是一个对象 = { scripts, styles, html }
    if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) {

        const { scripts = [], styles = [], html = '' } = entry;
        const setStylePlaceholder2HTML = tpl => styles.reduceRight((html, styleSrc) => `${genLinkReplaceSymbol(styleSrc)}${html}`, tpl);
        const setScriptPlaceholder2HTML = tpl => scripts.reduce((html, scriptSrc) => `${html}${genScriptReplaceSymbol(scriptSrc)}`, tpl);

        return getEmbedHTML(getTemplate(setScriptPlaceholder2HTML(setStylePlaceholder2HTML(html))), styles, { fetch }).then(embedHTML => ({
            template: embedHTML,
            assetPublicPath: getPublicPath(entry),
            getExternalScripts: () => getExternalScripts(scripts, fetch),
            getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
            execScripts: (proxy, strictGlobal) => {
                if (!scripts.length) {
                    return Promise.resolve();
                }
                return execScripts(scripts[scripts.length - 1], scripts, proxy, { fetch, strictGlobal });
            },
        }));

    } else {
        throw new SyntaxError('entry scripts or styles should be array!');
    }
}
7.4.3.2 importHTML
/**
 * 加载指定地址的首屏内容
 * @param {*} url 
 * @param {*} opts 
 * return Promise<{
      // template 是 link 替换为 style 后的 template
        template: embedHTML,
        // 静态资源地址
        assetPublicPath,
        // 获取外部脚本,最终得到所有脚本的代码内容
        getExternalScripts: () => getExternalScripts(scripts, fetch),
        // 获取外部样式文件的内容
        getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
        // 脚本执行器,让 JS 代码(scripts)在指定 上下文 中运行
        execScripts: (proxy, strictGlobal) => {
            if (!scripts.length) {
                return Promise.resolve();
            }
            return execScripts(entry, scripts, proxy, { fetch, strictGlobal });
        },
   }>
 */
export default function importHTML(url, opts = {}) {
    // 三个默认的方法
    let fetch = defaultFetch;
    let getPublicPath = defaultGetPublicPath;
    let getTemplate = defaultGetTemplate;

    if (typeof opts === 'function') {
        // if 分支,兼容遗留的 importHTML api,ops 可以直接是一个 fetch 方法
        fetch = opts;
    } else {
        // 用用户传递的参数(如果提供了的话)覆盖默认方法
        fetch = opts.fetch || defaultFetch;
        getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath;
        getTemplate = opts.getTemplate || defaultGetTemplate;
    }

    // 通过 fetch 方法请求 url,这也就是 qiankun 为什么要求你的微应用要支持跨域的原因
    return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url)
        // response.text() 是一个 html 模版
        .then(response => response.text())
        .then(html => {

            // 获取静态资源地址
            const assetPublicPath = getPublicPath(url);
            /**
          * 从 html 模版中解析出外部脚本的地址或者内联脚本的代码块 和 link 标签的地址
             * {
              *     template: 经过处理的脚本,link、script 标签都被注释掉了,
       *     scripts: [脚本的http地址 或者 { async: true, src: xx } 或者 代码块],
       *  styles: [样式的http地址],
          *     entry: 入口脚本的地址,要不是标有 entry 的 script 的 src,要不就是最后一个 script 标签的 src
              * }
             */
            const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath);

            // getEmbedHTML 方法通过 fetch 远程加载所有的外部样式,然后将对应的 link 注释标签替换为 style,即外部样式替换为内联样式,然后返回 embedHTML,即处理过后的 HTML 模版
            return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({
                // template 是 link 替换为 style 后的 template
                template: embedHTML,
                // 静态资源地址
                assetPublicPath,
                // 获取外部脚本,最终得到所有脚本的代码内容
                getExternalScripts: () => getExternalScripts(scripts, fetch),
                // 获取外部样式文件的内容
                getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
                // 脚本执行器,让 JS 代码(scripts)在指定 上下文 中运行
                execScripts: (proxy, strictGlobal) => {
                    if (!scripts.length) {
                        return Promise.resolve();
                    }
                    return execScripts(entry, scripts, proxy, { fetch, strictGlobal });
                },
            }));
        }));
}
7.4.3.3 processTpl
/**
 * 从 html 模版中解析出外部脚本的地址或者内联脚本的代码块 和 link 标签的地址
 * @param tpl html 模版
 * @param baseURI
 * @stripStyles whether to strip the css links
 * @returns {{template: void | string | *, scripts: *[], entry: *}}
 * return {
 *     template: 经过处理的脚本,link、script 标签都被注释掉了,
 *     scripts: [脚本的http地址 或者 { async: true, src: xx } 或者 代码块],
 *  styles: [样式的http地址],
 *     entry: 入口脚本的地址,要不是标有 entry 的 script 的 src,要不就是最后一个 script 标签的 src
 * }
 */
export default function processTpl(tpl, baseURI) {

    let scripts = [];
    const styles = [];
    let entry = null;
    // 判断浏览器是否支持 es module,<script type = "module" />
    const moduleSupport = isModuleScriptSupported();

    const template = tpl

        // 移除 html 模版中的注释内容 <!-- xx -->
        .replace(HTML_COMMENT_REGEX, '')

        // 匹配 link 标签
        .replace(LINK_TAG_REGEX, match => {
            /**
             * 将模版中的 link 标签变成注释,如果有存在 href 属性且非预加载的 link,则将地址存到 styles 数组,如果是预加载的 link 直接变成注释
             */
            // <link rel = "stylesheet" />
            const styleType = !!match.match(STYLE_TYPE_REGEX);
            if (styleType) {

                // <link rel = "stylesheet" href = "xxx" />
                const styleHref = match.match(STYLE_HREF_REGEX);
                // <link rel = "stylesheet" ignore />
                const styleIgnore = match.match(LINK_IGNORE_REGEX);

                if (styleHref) {

                    // 获取 href 属性值
                    const href = styleHref && styleHref[2];
                    let newHref = href;

                    // 如果 href 没有协议说明给的是一个相对地址,拼接 baseURI 得到完整地址
                    if (href && !hasProtocol(href)) {
                        newHref = getEntirePath(href, baseURI);
                    }
                    // 将 <link rel = "stylesheet" ignore /> 变成 <!-- ignore asset ${url} replaced by import-html-entry -->
                    if (styleIgnore) {
                        return genIgnoreAssetReplaceSymbol(newHref);
                    }

                    // 将 href 属性值存入 styles 数组
                    styles.push(newHref);
                    // <link rel = "stylesheet" href = "xxx" /> 变成 <!-- link ${linkHref} replaced by import-html-entry -->
                    return genLinkReplaceSymbol(newHref);
                }
            }

            // 匹配 <link rel = "preload or prefetch" href = "xxx" />,表示预加载资源
            const preloadOrPrefetchType = match.match(LINK_PRELOAD_OR_PREFETCH_REGEX) && match.match(LINK_HREF_REGEX) && !match.match(LINK_AS_FONT);
            if (preloadOrPrefetchType) {
                // 得到 href 地址
                const [, , linkHref] = match.match(LINK_HREF_REGEX);
                // 将标签变成 <!-- prefetch/preload link ${linkHref} replaced by import-html-entry -->
                return genLinkReplaceSymbol(linkHref, true);
            }

            return match;
        })
        // 匹配 <style></style>
        .replace(STYLE_TAG_REGEX, match => {
            if (STYLE_IGNORE_REGEX.test(match)) {
                // <style ignore></style> 变成 <!-- ignore asset style file replaced by import-html-entry -->
                return genIgnoreAssetReplaceSymbol('style file');
            }
            return match;
        })
        // 匹配 <script></script>
        .replace(ALL_SCRIPT_REGEX, (match, scriptTag) => {
            // 匹配 <script ignore></script>
            const scriptIgnore = scriptTag.match(SCRIPT_IGNORE_REGEX);
            // 匹配 <script nomodule></script> 或者 <script type = "module"></script>,都属于应该被忽略的脚本
            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

            // <script type = "xx" />
            const matchedScriptTypeMatch = scriptTag.match(SCRIPT_TYPE_REGEX);
            // 获取 type 属性值
            const matchedScriptType = matchedScriptTypeMatch && matchedScriptTypeMatch[2];
            // 验证 type 是否有效,type 为空 或者 'text/javascript', 'module', 'application/javascript', 'text/ecmascript', 'application/ecmascript',都视为有效
            if (!isValidJavaScriptType(matchedScriptType)) {
                return match;
            }

            // if it is a external script,匹配非 <script type = "text/ng-template" src = "xxx"></script>
            if (SCRIPT_TAG_REGEX.test(match) && scriptTag.match(SCRIPT_SRC_REGEX)) {
                /*
                collect scripts and replace the ref
                */

                // <script entry />
                const matchedScriptEntry = scriptTag.match(SCRIPT_ENTRY_REGEX);
                // <script src = "xx" />
                const matchedScriptSrcMatch = scriptTag.match(SCRIPT_SRC_REGEX);
                // 脚本地址
                let matchedScriptSrc = matchedScriptSrcMatch && matchedScriptSrcMatch[2];

                if (entry && matchedScriptEntry) {
                    // 说明出现了两个入口地址,即两个 <script entry src = "xx" />
                    throw new SyntaxError('You should not set multiply entry script!');
                } else {
                    // 补全脚本地址,地址如果没有协议,说明是一个相对路径,添加 baseURI
                    if (matchedScriptSrc && !hasProtocol(matchedScriptSrc)) {
                        matchedScriptSrc = getEntirePath(matchedScriptSrc, baseURI);
                    }

                    // 脚本的入口地址
                    entry = entry || matchedScriptEntry && matchedScriptSrc;
                }

                if (scriptIgnore) {
                    // <script ignore></script> 替换为 <!-- ignore asset ${url || 'file'} replaced by import-html-entry -->
                    return genIgnoreAssetReplaceSymbol(matchedScriptSrc || 'js file');
                }

                if (moduleScriptIgnore) {
                    // <script nomodule></script> 或者 <script type = "module"></script> 替换为
                    // <!-- nomodule script ${scriptSrc} ignored by import-html-entry --> 或
                    // <!-- module script ${scriptSrc} ignored by import-html-entry -->
                    return genModuleScriptReplaceSymbol(matchedScriptSrc || 'js file', moduleSupport);
                }

                if (matchedScriptSrc) {
                    // 匹配 <script src = 'xx' async />,说明是异步加载的脚本
                    const asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX);
                    // 将脚本地址存入 scripts 数组,如果是异步加载,则存入一个对象 { async: true, src: xx }
                    scripts.push(asyncScript ? { async: true, src: matchedScriptSrc } : matchedScriptSrc);
                    // <script src = "xx" async /> 或者 <script src = "xx" /> 替换为 
                    // <!-- async script ${scriptSrc} replaced by import-html-entry --> 或 
                    // <!-- script ${scriptSrc} replaced by import-html-entry -->
                    return genScriptReplaceSymbol(matchedScriptSrc, asyncScript);
                }

                return match;
            } else {
                // 说明是内部脚本,<script>xx</script>
                if (scriptIgnore) {
                    // <script ignore /> 替换为 <!-- ignore asset js file replaced by import-html-entry -->
                    return genIgnoreAssetReplaceSymbol('js file');
                }

                if (moduleScriptIgnore) {
                    // <script nomodule></script> 或者 <script type = "module"></script> 替换为
                    // <!-- nomodule script ${scriptSrc} ignored by import-html-entry --> 或 
                    // <!-- module script ${scriptSrc} ignored by import-html-entry -->
                    return genModuleScriptReplaceSymbol('js file', moduleSupport);
                }

                // if it is an inline script,<script>xx</script>,得到标签之间的代码 => xx
                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 数组
                    scripts.push(match);
                }

                // <script>xx</script> 替换为 <!-- inline scripts replaced by import-html-entry -->
                return inlineScriptReplaceSymbol;
            }
        });

    // filter empty script
    scripts = scripts.filter(function (script) {
        return !!script;
    });

    return {
        template,
        scripts,
        styles,
        // set the last script as entry if have not set
        entry: entry || scripts[scripts.length - 1],
    };
}

7.4.3.4 getEmbedHTML
/**
 * convert external css link to inline style for performance optimization,外部样式转换成内联样式
 * @param template,html 模版
 * @param styles link 样式链接
 * @param opts = { fetch }
 * @return embedHTML 处理过后的 html 模版
 */
function getEmbedHTML(template, styles, opts = {}) {
    const { fetch = defaultFetch } = opts;
    let embedHTML = template;

    return getExternalStyleSheets(styles, fetch)
        .then(styleSheets => {
            // 通过循环,将之前设置的 link 注释标签替换为 style 标签,即 <style>/* href地址 */ xx </style>
            embedHTML = styles.reduce((html, styleSrc, i) => {
                html = html.replace(genLinkReplaceSymbol(styleSrc), `<style>/* ${styleSrc} */${styleSheets[i]}</style>`);
                return html;
            }, embedHTML);
            return embedHTML;
        });
}

7.4.3.5 getExternalScript
/**
 * 加载脚本,最终返回脚本的内容,Promise<Array>,每个元素都是一段 JS 代码
 * @param {*} scripts = [脚本http地址 or 内联脚本的脚本内容 or { async: true, src: xx }]
 * @param {*} fetch 
 * @param {*} errorCallback 
 */
export function getExternalScripts(scripts, fetch = defaultFetch, errorCallback = () => {
}) {

    // 定义一个可以加载远程指定 url 脚本的方法,当然里面也做了缓存,如果命中缓存直接从缓存中获取
    const fetchScript = scriptUrl => scriptCache[scriptUrl] ||
        (scriptCache[scriptUrl] = fetch(scriptUrl).then(response => {
            // usually browser treats 4xx and 5xx response of script loading as an error and will fire a script error event
            // https://stackoverflow.com/questions/5625420/what-http-headers-responses-trigger-the-onerror-handler-on-a-script-tag/5625603
            if (response.status >= 400) {
                errorCallback();
                throw new Error(`${scriptUrl} load failed with status ${response.status}`);
            }

            return response.text();
        }));

    return Promise.all(scripts.map(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
                // 异步脚本,通过 requestIdleCallback 方法加载
                const { src, async } = script;
                if (async) {
                    return {
                        src,
                        async: true,
                        content: new Promise((resolve, reject) => requestIdleCallback(() => fetchScript(src).then(resolve, reject))),
                    };
                }

                return fetchScript(src);
            }
        },
    ));
}

7.4.3.6 getExternalStyleSheets
/**
 * 通过 fetch 方法加载指定地址的样式文件
 * @param {*} styles = [ href ]
 * @param {*} fetch 
 * return Promise<Array>,每个元素都是一堆样式内容
 */
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()));
            }

        },
    ));
}
7.4.3.7 execScripts 脚本执行器,让指定的脚本在规定的上下文环境中执行
/**
 * FIXME to consistent with browser behavior, we should only provide callback way to invoke success and error event
 * 脚本执行器,让指定的脚本(scripts)在规定的上下文环境中执行
 * @param entry 入口地址
 * @param scripts = [脚本http地址 or 内联脚本的脚本内容 or { async: true, src: xx }] 
 * @param proxy 脚本执行上下文,全局对象,qiankun JS 沙箱生成 windowProxy 就是传递到了这个参数
 * @param opts
 * @returns {Promise<unknown>}
 */
export function execScripts(entry, scripts, proxy = window, opts = {}) {
    const {
        fetch = defaultFetch, strictGlobal = false, success, error = () => {
        }, beforeExec = () => {
        },
    } = opts;

    // 获取指定的所有外部脚本的内容,并设置每个脚本的执行上下文,然后通过 eval 函数运行
    return getExternalScripts(scripts, fetch, error)
        .then(scriptsText => {
            // scriptsText 为脚本内容数组 => 每个元素是一段 JS 代码
            const geval = (code) => {
                beforeExec();
                (0, eval)(code);
            };

            /**
             * 
             * @param {*} scriptSrc 脚本地址
             * @param {*} inlineScript 脚本内容
             * @param {*} resolve 
             */
            function exec(scriptSrc, inlineScript, resolve) {

                // 性能度量
                const markName = `Evaluating script ${scriptSrc}`;
                const measureName = `Evaluating Time Consuming: ${scriptSrc}`;

                if (process.env.NODE_ENV === 'development' && supportsUserTiming) {
                    performance.mark(markName);
                }

                if (scriptSrc === entry) {
                    // 入口
                    noteGlobalProps(strictGlobal ? proxy : window);

                    try {
                        // bind window.proxy to change `this` reference in script
                        geval(getExecutableScript(scriptSrc, inlineScript, proxy, strictGlobal));
                        const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
                        resolve(exports);
                    } catch (e) {
                        // entry error must be thrown to make the promise settled
                        console.error(`[import-html-entry]: error occurs while executing entry script ${scriptSrc}`);
                        throw e;
                    }
                } else {
                    if (typeof inlineScript === 'string') {
                        try {
                            // bind window.proxy to change `this` reference in script,就是设置 JS 代码的执行上下文,然后通过 eval 函数运行运行代码
                            geval(getExecutableScript(scriptSrc, inlineScript, proxy, strictGlobal));
                        } catch (e) {
                            // consistent with browser behavior, any independent script evaluation error should not block the others
                            throwNonBlockingError(e, `[import-html-entry]: error occurs while executing normal script ${scriptSrc}`);
                        }
                    } else {
                        // external script marked with async,异步加载的代码,下载完以后运行
                        inlineScript.async && inlineScript?.content
                            .then(downloadedScriptText => geval(getExecutableScript(inlineScript.src, downloadedScriptText, proxy, strictGlobal)))
                            .catch(e => {
                                throwNonBlockingError(e, `[import-html-entry]: error occurs while executing async script ${inlineScript.src}`);
                            });
                    }
                }

                // 性能度量
                if (process.env.NODE_ENV === 'development' && supportsUserTiming) {
                    performance.measure(measureName, markName);
                    performance.clearMarks(markName);
                    performance.clearMeasures(measureName);
                }
            }

            /**
             * 递归
             * @param {*} i 表示第几个脚本
             * @param {*} resolvePromise 成功回调 
             */
            function schedule(i, resolvePromise) {

                if (i < scripts.length) {
                    // 第 i 个脚本的地址
                    const scriptSrc = scripts[i];
                    // 第 i 个脚本的内容
                    const inlineScript = scriptsText[i];

                    exec(scriptSrc, inlineScript, resolvePromise);
                    if (!entry && i === scripts.length - 1) {
                        // resolve the promise while the last script executed and entry not provided
                        resolvePromise();
                    } else {
                        // 递归调用下一个脚本
                        schedule(i + 1, resolvePromise);
                    }
                }
            }

            // 从第 0 个脚本开始调度
            return new Promise(resolve => schedule(0, success || resolve));
        });
}

8、使用qiankun从0-1搭建应用项目

采用 React 作为主应用基座,接入Vue 技术栈的微应用。
我们先使用 create-react-app 生成一个 React 的项目,初始化主应用。
create-react-app 是 React 官方提供的脚手架工具,用于快速搭建一个 React 项目。
将普通的项目改造成 qiankun 主应用基座,需要进行三步操作:

  1. 创建微应用容器 - 用于承载微应用,渲染显示微应用:
  2. 注册微应用 - 设置微应用激活条件,微应用地址等等
  3. 启动 qiankun;

8.1 主应用依赖包

npm i react-router-dom -S
npm i antd -S
// index.js
import 'antd/dist/reset.css';

// App.js

import { BrowserRouter as Router, Link } from 'react-router-dom'
import { Menu } from 'antd'
import './App.css'

const menus = [
  {
    key: 'Home',
    label: <Link to="/">主页</Link>,
  },
  {
    key: 'app-vue1',
    label: <Link to="/app-vue1">vue微应用1</Link>,
  },
  {
    key: 'app-vue2',
    label: <Link to="/app-vue2">vue微应用2</Link>,
  },
];

function App() {
  let style = {
    width: '100vw',
    height: '100vh',
  }

  return (
    <Router>
      {/* <h1>主应用启动成功</h1> */}

      <div className="App">
        <Menu
          style={{
            width: 256,
          }}
          theme="dark"
          mode="inline"
          items={menus}
        ></Menu>
        <div id="micro-container" style={style}></div>
      </div>
    </Router>
  )
}

export default App

8.2 创建微应用容器

我们先在主应用中创建微应用的承载容器,这个容器规定了微应用的显示区域,微应用将在该容器内渲染并显示。
我们先设置路由,路由文件规定了主应用自身的路由匹配规则,代码实现如下:

import { BrowserRouter as Router, Link } from 'react-router-dom'
import { Menu } from 'antd'
import './App.css'

const menus = [
  {
    key: 'Home',
    label: <Link to="/">主页</Link>,
  },
  {
    key: 'app-vue1',
    label: <Link to="/app-vue1">vue微应用1</Link>,
  },
  {
    key: 'app-vue2',
    label: <Link to="/app-vue2">vue微应用2</Link>,
  },
];

function App() {
  let style = {
    width: '100vw',
    height: '100vh',
  }

  return (
    <Router>
      {/* <h1>主应用启动成功</h1> */}

      <div className="App">
        <Menu
          style={{
            width: 256,
          }}
          theme="dark"
          mode="inline"
          items={menus}
        ></Menu>
        <div id="micro-container" style={style}></div>
      </div>
    </Router>
  )
}

8.3 注册微应用

在构建好了主框架后,我们需要使用 qiankunregisterMicroApps 方法注册微应用,代码实现:

// micro-app.js
export const MicroApps = [
  {
    name: "vue1App",
    entry: "//localhost:3001",
    container: "#micro-container",
    activeRule: "/app-vue1",
  },
  {
    name: "vue2App",
    entry: "//localhost:3002",
    container: "#micro-container",
    activeRule: "/app-vue2",
  },
];

我们在注册好了微应用,通过 start 函数后,我们需要在合适的地方调用 start 启动主应用。

// index.js
import React from "react";
import ReactDOM from "react-dom/client";
import { registerMicroApps, start } from "qiankun";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { MicroApps } from "./micro-app";

registerMicroApps(MicroApps);
start();

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

8.4 接入微应用

我们现在的主应用基座只有一个主页,现在我们需要接入微应用。
qiankun 内部通过 import-entry-html 加载微应用,要求微应用需要导出生命周期钩子函数 (见下方代码)

/** 校验子应用导出的 生命周期 对象是否正确 */
export function validateExportLifecycle(exports: any){
	const [ bootstrap, mount, unmount ] = exports ?? {};
	return isFunction(bootstrap) && isFunction(mount) && isFunction(unmount);
}

qiankun 内部会校验微应用的生命周期钩子函数,如果微应用没有导出这三个生命周期钩子函数,则微应用会加载失败。

如果我们使用了脚手架搭建微应用的话,我们可以通过 webpack 配置在入口文件处导出这-个生命周期钩子函数。如果没有使用脚手架的话,也可以直接在微应用的 window 上挂载这三个生命周期钩子函数。

8.5 接入 Vue 微应用

使用 vue-cli 先创建一个 Vue 项目,在命令行运行:

vue create micro-sub-app-vue

8.6 配置微应用

在主应用注册好了微应用后,我们还需要对微应用进行一系列的配置。
首先我们在 Vue 的入口文件 main.js 中,导出 qiankun 主应用所需要的三个生命周期钩子函数:

import './public-path';
import Vue from 'vue';
import App from './App.vue';

Vue.config.productionTip = false;

let instance = null;

function render() {
  instance = new Vue({
    render: (h) => h(App),
  }).$mount('#app');
}

render();

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log('[vue1] vue1 app bootstraped');
}
export async function mount(props) {
  console.log('[vue1] props from main framework mount', props);
  render(props);
}
export async function unmount() {
  instance.$destroy();
  instance = null;
}

在配置好了入口文件 main.js 后,我们还需要配置 webpack ,使 main.js 导出的生命周期钩子函数可以被 giankun 识别获取。

我们直接配置 vue.config.js 即可::

const { name } = require("./package");
module.exports = {
  devServer: {
    // 配置下面内容 否则主应用访问会报跨域
    headers: {
      // 配置跨域请求头,解决开发环境的跨域问题
      "Access-Control-Allow-Origin": "*",
    },
    port: "3001",
  },
  configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: "umd", // 把微应用打包成 umd 库格式
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
};

我们需要重点关注一下 output 选项,当我们把 libraryTarget 设置为 umd 后,我们的 library 就暴露为所有的模块定义下都可运行的方式了,主应用就可以获取到微应用的生命周期钩子函数了。

vue.config.js 修改完成后,我们重新启动 Vue 微应用,然后打开主应用基座 http://localhost:3000 。我们点击左侧菜单切换到微应用,此时我们的 Vue 微应用被正确加载。

到这里,Vue 微应用就接入成功了!

参考链接

;