关于前端:从零到一实现企业级微前端框架保姆级教学

4次阅读

共计 17453 个字符,预计需要花费 44 分钟才能阅读完成。

前言

这篇文章笔者足足肝了一周多,屡次斟酌批改内容,力求最大水平帮忙读者造出一个微前端框架,搞懂原理。感觉内容不错的读者点个赞反对下。

微前端是目前比拟热门的一种技术架构,挺多读者私底下问我其中的原理。为了讲清楚原理,我会带着大家从零开始实现一个微前端框架,其中蕴含了以下性能:

  • 如何进行路由劫持
  • 如何渲染子利用
  • 如何实现 JS 沙箱及款式隔离
  • 晋升体验性的性能

另外在实现的过程中,笔者还会聊聊目前有哪些技术计划能够去实现微前端以及做以上性能的时候有哪些实现形式。

这里是本次文章的最终产出物仓库地址:toy-micro。

微前端实现计划

微前端的实现计划有挺多,比如说:

  1. qiankun,本人实现 JS 及款式隔离
  2. icestark,iframe 计划,浏览器原生隔离,但存在一些问题
  3. emp,Webpack 5 Module Federation(联邦模块)计划
  4. WebComponent 等计划

然而这么多实现计划解决的场景问题还是分为两类:

  • 单实例:以后页面只存在一个子利用,个别应用 qiankun 就行
  • 多实例:以后页面存在多个子利用,能够应用浏览器原生隔离计划,比方 iframe 或者 WebComponent 这些

当然了,并不是说单实例只能用 qiankun,浏览器原生隔离计划也是可行的,只有你承受它们带来的有余就行:

iframe 最大的个性就是提供了浏览器原生的硬隔离计划,不论是款式隔离、js 隔离这类问题通通都能被完满解决。但他的最大问题也在于他的隔离性无奈被冲破,导致利用间上下文无奈被共享,随之带来的开发体验、产品体验的问题。

上述内容摘自 Why Not Iframe。

本文的实现计划和 qiankun 统一,然而其中波及到的性能及原理方面的货色都是通用的,你换个实现计划也须要这些。

前置工作

在正式开始之前,咱们须要搭建一下开发环境,这边大家能够任意抉择主 / 子利用的技术栈,比如说主利用用 React,子利用用 Vue,自行抉择即可。每个利用用对应的脚手架工具初始化我的项目就行,这边就不带着大家初始化我的项目了。记得如果是 React 我的项目的话,须要另外再执行一次 yarn eject

举荐大家间接应用笔者仓库里的 example 文件夹,该配置的都配置好了,大家只须要安心跟着笔者一步步做微前端就行。 例子中主利用为 React,子利用为 Vue,最终咱们生成的目录构造大抵如下:

注释

在浏览注释前,我假设各位读者曾经应用过微前端框架并理解其中的概念,比如说通晓主利用是负责整体布局以及子利用的配置及注册这类内容。如果还未应用过,举荐各位简略浏览下任一微前端框架应用文档。

利用注册

在有了主利用之后,咱们须要先在主利用中注册子利用的信息,内容蕴含以下几块:

  • name:子利用名词
  • entry:子利用的资源入口
  • container:主利用渲染子利用的节点
  • activeRule:在哪些路由下渲染该子利用

其实这些信息和咱们在我的项目中注册路由很像,entry 能够看做须要渲染的组件,container 能够看做路由渲染的节点,activeRule 能够看做如何匹配路由的规定。

接下来咱们先来实现这个注册子利用的函数:

// src/types.ts
export interface IAppInfo {
  name: string;
  entry: string;
  container: string;
  activeRule: string;
}

// src/start.ts
export const registerMicroApps = (appList: IAppInfo[]) => {setAppList(appList);
};

// src/appList/index.ts
let appList: IAppInfo[] = [];

export const setAppList = (list: IAppInfo[]) => {appList = list;};

export const getAppList = () => {return appList;};

上述实现很简略,就只须要将用户传入的 appList 保存起来即可。

路由劫持

在有了子利用列表当前,咱们须要启动微前端以便渲染相应的子利用,也就是须要判断路由来渲染相应的利用。然而在进行下一步前,咱们须要先思考一个问题:如何监听路由的变动来判断渲染哪个子利用?

对于非 SPA(单页利用)架构的我的项目来说,这个齐全不是什么问题,因为咱们只须要在启动微前端的时候判断下以后 URL 并渲染利用即可;然而在 SPA 架构下,路由变动是不会引发页面刷新的,因而咱们须要一个形式通晓路由的变动,从而判断是否须要切换子利用或者什么事都不干。

如果你理解过 Router 库原理的话,应该马上能想到解决方案。如果你并不理解的话,能够先自行浏览笔者之前的文章。

为了关照不理解的读者,笔者这里先简略的聊一下路由原理。

目前单页利用应用路由的形式分为两种:

  1. hash 模式,也就是 URL 中携带 #
  2. histroy 模式,也就是常见的 URL 格局了

以下笔者会用两张图例展现这两种模式别离会波及到哪些事件及 API:

从上述图中咱们能够发现,路由变动会波及到两个事件:

  • popstate
  • hashchange

因而这两个事件咱们必定是须要去监听的。除此之外,调用 pushState 以及 replaceState 也会造成路由变动,但不会触发事件,因而咱们还须要去重写这两个函数。

晓得了该监听什么事件以及重写什么函数之后,接下来咱们就来实现代码:

// src/route/index.ts

// 保留原有办法
const originalPush = window.history.pushState;
const originalReplace = window.history.replaceState;

export const hijackRoute = () => {
  // 重写办法
  window.history.pushState = (...args) => {
    // 调用原有办法
    originalPush.apply(window.history, args);
    // URL 扭转逻辑,理论就是如何解决子利用
    // ...
  };
  window.history.replaceState = (...args) => {originalReplace.apply(window.history, args);
    // URL 扭转逻辑
    // ...
  };

  // 监听事件,触发 URL 扭转逻辑
  window.addEventListener("hashchange", () => {});
  window.addEventListener("popstate", () => {});

  // 重写
  window.addEventListener = hijackEventListener(window.addEventListener);
  window.removeEventListener = hijackEventListener(window.removeEventListener);
};

const capturedListeners: Record<EventType, Function[]> = {hashchange: [],
  popstate: [],};
const hasListeners = (name: EventType, fn: Function) => {return capturedListeners[name].filter((listener) => listener === fn).length;
};
const hijackEventListener = (func: Function): any => {return function (name: string, fn: Function) {
    // 如果是以下事件,保留回调函数
    if (name === "hashchange" || name === "popstate") {if (!hasListeners(name, fn)) {capturedListeners[name].push(fn);
        return;
      } else {capturedListeners[name] = capturedListeners[name].filter((listener) => listener !== fn
        );
      }
    }
    return func.apply(window, arguments);
  };
};
// 后续渲染子利用后应用,用于执行之前保留的回调函数
export function callCapturedListeners() {if (historyEvent) {Object.keys(capturedListeners).forEach((eventName) => {const listeners = capturedListeners[eventName as EventType]
      if (listeners.length) {listeners.forEach((listener) => {
          // @ts-ignore
          listener.call(this, historyEvent)
        })
      }
    })
    historyEvent = null
  }
}

以上代码看着很多行,理论做的事件很简略,总体分为以下几步:

  1. 重写 pushState 以及 replaceState 办法,在办法中调用原有办法后执行如何解决子利用的逻辑
  2. 监听 hashchangepopstate 事件,事件触发后执行如何解决子利用的逻辑
  3. 重写监听 / 移除事件函数,如果利用监听了 hashchangepopstate 事件就将回调函数保存起来以备后用

利用生命周期

在实现路由劫持后,咱们当初须要来思考如果实现解决子利用的逻辑了,也就是如何解决子利用加载资源以及挂载和卸载子利用。看到这里,大家是不是感觉这和组件很相似。组件也同样须要解决这些事件,并且会裸露相应的生命周期给用户去干想干的事。

因而对于一个子利用来说,咱们也须要去实现一套生命周期,既然子利用有生命周期,主利用必定也有,而且也必然是绝对应子利用生命周期的。

那么到这里咱们大抵能够整理出来主 / 子利用的生命周期。

对于主利用来说,分为以下三个生命周期:

  1. beforeLoad:挂载子利用前
  2. mounted:挂载子利用后
  3. unmounted:卸载子利用

当然如果你想减少生命周期也是齐全没问题的,笔者这里为了简便就只实现了三种。

对于子利用来说,通用也分为以下三个生命周期:

  1. bootstrap:首次利用加载触发,罕用于配置子利用全局信息
  2. mount:利用挂载时触发,罕用于渲染子利用
  3. unmount:利用卸载时触发,罕用于销毁子利用

接下来咱们就来实现注册主利用生命周期函数:

// src/types.ts
export interface ILifeCycle {beforeLoad?: LifeCycle | LifeCycle[];
  mounted?: LifeCycle | LifeCycle[];
  unmounted?: LifeCycle | LifeCycle[];}

// src/start.ts
// 改写下之前的
export const registerMicroApps = (appList: IAppInfo[],
  lifeCycle?: ILifeCycle
) => {setAppList(appList);
  lifeCycle && setLifeCycle(lifeCycle);
};

// src/lifeCycle/index.ts
let lifeCycle: ILifeCycle = {};

export const setLifeCycle = (list: ILifeCycle) => {lifeCycle = list;};

因为是主利用的生命周期,所以咱们在注册子利用的时候就顺带注册上了。

而后子利用的生命周期:

// src/enums.ts
// 设置子利用状态
export enum AppStatus {
  NOT_LOADED = "NOT_LOADED",
  LOADING = "LOADING",
  LOADED = "LOADED",
  BOOTSTRAPPING = "BOOTSTRAPPING",
  NOT_MOUNTED = "NOT_MOUNTED",
  MOUNTING = "MOUNTING",
  MOUNTED = "MOUNTED",
  UNMOUNTING = "UNMOUNTING",
}
// src/lifeCycle/index.ts
export const runBeforeLoad = async (app: IInternalAppInfo) => {
  app.status = AppStatus.LOADING;
  await runLifeCycle("beforeLoad", app);

  app = await 加载子利用资源;
  app.status = AppStatus.LOADED;
};

export const runBoostrap = async (app: IInternalAppInfo) => {if (app.status !== AppStatus.LOADED) {return app;}
  app.status = AppStatus.BOOTSTRAPPING;
  await app.bootstrap?.(app);
  app.status = AppStatus.NOT_MOUNTED;
};

export const runMounted = async (app: IInternalAppInfo) => {
  app.status = AppStatus.MOUNTING;
  await app.mount?.(app);
  app.status = AppStatus.MOUNTED;
  await runLifeCycle("mounted", app);
};

export const runUnmounted = async (app: IInternalAppInfo) => {
  app.status = AppStatus.UNMOUNTING;
  await app.unmount?.(app);
  app.status = AppStatus.NOT_MOUNTED;
  await runLifeCycle("unmounted", app);
};

const runLifeCycle = async (name: keyof ILifeCycle, app: IAppInfo) => {const fn = lifeCycle[name];
  if (fn instanceof Array) {await Promise.all(fn.map((item) => item(app)));
  } else {await fn?.(app);
  }
};

以上代码看着很多,理论实现也很简略,总结一下就是:

  • 设置子利用状态,用于逻辑判断以及优化。比如说当一个利用状态为非 NOT_LOADED 时(每个利用初始都为 NOT_LOADED 状态),下次渲染该利用时就无需反复加载资源了
  • 如须要解决逻辑,比如说 beforeLoad 咱们须要加载子利用资源
  • 执行主 / 子利用生命周期,这里须要留神下执行程序,能够参考父子组件的生命周期执行程序

欠缺路由劫持

实现利用生命周期当前,咱们当初就能来欠缺先前路由劫持中没有做的「如何解决子利用」的这块逻辑。

这块逻辑在咱们做完生命周期之后其实很简略,能够分为以下几步:

  1. 判断以后 URL 与之前的 URL 是否统一,如果统一则持续
  2. 利用当然 URL 去匹配相应的子利用,此时分为几种状况:

    • 首次启动微前端,此时只需渲染匹配胜利的子利用
    • 未切换子利用,此时无需解决子利用
    • 切换子利用,此时须要找出之前渲染过的子利用做卸载解决,而后渲染匹配胜利的子利用
  3. 保留以后 URL,用于下一次第一步判断

理分明步骤之后,咱们就来实现它:

let lastUrl: string | null = null
export const reroute = (url: string) => {if (url !== lastUrl) {const { actives, unmounts} = 匹配路由,寻找符合条件的子利用
    // 执行生命周期
    Promise.all(
      unmounts
        .map(async (app) => {await runUnmounted(app)
        })
        .concat(actives.map(async (app) => {await runBeforeLoad(app)
            await runBoostrap(app)
            await runMounted(app)
          })
        )
    ).then(() => {
      // 执行路由劫持大节未应用的函数
      callCapturedListeners()})
  }
  lastUrl = url || location.href
}

以上代码主体就是在按程序执行生命周期函数,然而其中匹配路由的函数并未实现,因为咱们须要先来思考一些问题。

大家平时我的项目开发中必定是用过路由的,那应该晓得路由匹配的准则次要由两块组成:

  • 嵌套关系
  • 门路语法

嵌套关系指的是:如果我以后的路由设置的是 /vue,那么相似 /vue 或者 /vue/xxx 都能匹配上这个路由,除非咱们设置 excart 也就是准确匹配。

门路语法笔者这里就间接拿个文档里的例子出现了:

<Route path="/hello/:name">         // 匹配 /hello/michael 和 /hello/ryan
<Route path="/hello(/:name)">       // 匹配 /hello, /hello/michael 和 /hello/ryan
<Route path="/files/*.*">           // 匹配 /files/hello.jpg 和 /files/path/to/hello.jpg

这样看来路由匹配实现起来还是挺麻烦的,那么咱们是否有简便的方法来实现该性能呢?答案必定是有的,咱们只有浏览 Route 库源码就能发现它们外部都应用了 path-to-regexp 这个库,有趣味的读者能够自行浏览下这个库的文档,笔者这里就带过了,咱们只看其中一个 API 的应用就行。

有了解决方案当前,咱们就疾速实现下路由匹配的函数:

export const getAppListStatus = () => {
  // 须要渲染的利用列表
  const actives: IInternalAppInfo[] = []
  // 须要卸载的利用列表
  const unmounts: IInternalAppInfo[] = []
  // 获取注册的子利用列表
  const list = getAppList() as IInternalAppInfo[]
  list.forEach((app) => {
    // 匹配路由
    const isActive = match(app.activeRule, { end: false})(location.pathname)
    // 判断利用状态
    switch (app.status) {
      case AppStatus.NOT_LOADED:
      case AppStatus.LOADING:
      case AppStatus.LOADED:
      case AppStatus.BOOTSTRAPPING:
      case AppStatus.NOT_MOUNTED:
        isActive && actives.push(app)
        break
      case AppStatus.MOUNTED:
        !isActive && unmounts.push(app)
        break
    }
  })

  return {actives, unmounts}
}

实现以上函数之后,大家别忘了在 reroute 函数中调用一下,至此路由劫持性能彻底实现了,残缺代码可浏览此处。

欠缺生命周期

之前在实现生命周期过程中,咱们还有很重要的一步「加载子利用资源」未实现,这一大节咱们就把这块内容搞定。

既然要加载资源,那么咱们必定就先须要一个资源入口,就和咱们应用的 npm 包一样,每个包肯定会有一个入口文件。回到 registerMicroApps 函数,咱们最开始就给这个函数传入了 entry 参数,这就是子利用的资源入口。

资源入口其实分为两种计划:

  1. JS Entry
  2. HTML Entry

这两个计划都是字面意思,前者是通过 JS 加载所有动态资源,后者则通过 HTML 加载所有动态资源。

JS Entry 是 single-spa 中应用的一个形式。然而它限度有点多,须要用户将所有文件打包在一起,除非你的我的项目对性能无感,否则根本能够 pass 这个计划。

HTML Entry 则要好得多,毕竟所有网站都是以 HTML 作为入口文件的。在这种计划里,咱们根本无需改变打包形式,对用户开发简直没侵入性,只须要寻找出 HTML 中的动态资源加载并运行即可渲染子利用了,因而咱们抉择了这个计划。

接下来咱们开始来实现这部分的内容。

加载资源

首先咱们须要获取 HTML 的内容,这里咱们只需调用原生 fetch 就能拿到货色了。

// src/utils
export const fetchResource = async (url: string) => {return await fetch(url).then(async (res) => await res.text())
}
// src/loader/index.ts
export const loadHTML = async (app: IInternalAppInfo) => {const { container, entry} = app

  const htmlFile = await fetchResource(entry)

  return app
}

在笔者的仓库 example 中,咱们切换路由至 /vue 之后,咱们能够打印出加载到的 HTML 文件内容。

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="/favicon.ico">
    <title>sub</title>
  <link href="/js/app.js" rel="preload" as="script"><link href="/js/chunk-vendors.js" rel="preload" as="script"></head>
  <body>
    <noscript>
      <strong>We're sorry but sub doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  <script type="text/javascript" src="/js/chunk-vendors.js"></script>
  <script type="text/javascript" src="/js/app.js"></script></body>
</html>

咱们能够在该文件中看到好些 相对路径的动态资源 URL,接下来咱们就须要去加载这些资源了。然而咱们须要留神一点的是,这些资源只有在本人的 BaseURL 下能力被正确加载到,如果是在主利用的 BaseURL 下必定报 404 谬误了。

而后咱们还须要留神一点:因为咱们是在主利用的 URL 下加载子利用的资源,这很有可能会触发跨域的限度。因而在开发及生产环境大家务必留神跨域的解决。

举个开发环境下子利用是 Vue 的话,解决跨域的形式:

// vue.config.js
module.exports = {
  devServer: {
    headers: {'Access-Control-Allow-Origin': '*',},
  },
}

接下来咱们须要后行解决这些资源的门路,将相对路径拼接成正确的绝对路径,而后再去 fetch

// src/utils
export function getCompletionURL(src: string | null, baseURI: string) {if (!src) return src
  // 如果 URL 曾经是协定结尾就间接返回
  if (/^(https|http)/.test(src)) return src
    // 通过原生办法拼接 URL
  return new URL(src, getCompletionBaseURL(baseURI)).toString()}
// 获取残缺的 BaseURL
// 因为用户在注册利用的 entry 外面可能填入 //xxx 或者 https://xxx 这种格局的 URL
export function getCompletionBaseURL(url: string) {return url.startsWith('//') ? `${location.protocol}${url}` : url
}

以上代码的性能就不再赘述了,正文曾经很具体了,接下来咱们须要找到 HTML 文件中的资源而后去 fetch

既然是找出资源,那么咱们就得解析 HTML 内容了:

// src/loader/parse.ts
export const parseHTML = (parent: HTMLElement, app: IInternalAppInfo) => {const children = Array.from(parent.children) as HTMLElement[]
  children.length && children.forEach((item) => parseHTML(item, app))

  for (const dom of children) {if (/^(link)$/i.test(dom.tagName)) {// 解决 link} else if (/^(script)$/i.test(dom.tagName)) {// 解决 script} else if (/^(img)$/i.test(dom.tagName) && dom.hasAttribute('src')) {
      // 解决图片,毕竟图片资源用相对路径必定也 404 了
      dom.setAttribute(
        'src',
        getCompletionURL(dom.getAttribute('src')!, app.entry)!
      )
    }
  }

  return {}}

解析内容这块还是简略的,咱们递归寻找元素,将 linkscriptimg 元素找进去并做对应的解决即可。

首先来看咱们如何解决 link

// src/loader/parse.ts
// 补全 parseHTML 逻辑
if (/^(link)$/i.test(dom.tagName)) {const data = parseLink(dom, parent, app)
  data && links.push(data)
}
const parseLink = (
  link: HTMLElement,
  parent: HTMLElement,
  app: IInternalAppInfo
) => {const rel = link.getAttribute('rel')
  const href = link.getAttribute('href')
  let comment: Comment | null
  // 判断是不是获取 CSS 资源
  if (rel === 'stylesheet' && href) {comment = document.createComment(`link replaced by micro`)
    // @ts-ignore
    comment && parent.replaceChild(comment, script)
    return getCompletionURL(href, app.entry)
  } else if (href) {link.setAttribute('href', getCompletionURL(href, app.entry)!)
  }
}

解决 link 标签时,咱们只须要解决 CSS 资源,其它 preload / prefetch 的这些资源间接替换 href 就行。

// src/loader/parse.ts
// 补全 parseHTML 逻辑
if (/^(link)$/i.test(dom.tagName)) {const data = parseScript(dom, parent, app)
  data.text && inlineScript.push(data.text)
  data.url && scripts.push(data.url)
}
const parseScript = (
  script: HTMLElement,
  parent: HTMLElement,
  app: IInternalAppInfo
) => {
  let comment: Comment | null
  const src = script.getAttribute('src')
  // 有 src 阐明是 JS 文件,没 src 阐明是 inline script,也就是 JS 代码间接写标签里了
  if (src) {comment = document.createComment('script replaced by micro')
  } else if (script.innerHTML) {comment = document.createComment('inline script replaced by micro')
  }
  // @ts-ignore
  comment && parent.replaceChild(comment, script)
  return {url: getCompletionURL(src, app.entry), text: script.innerHTML }
}

解决 script 标签时,咱们须要区别是 JS 文件还是行内代码,前者还须要 fecth 一次获取内容。

而后咱们会在 parseHTML 中返回所有解析进去的 scripts, links, inlineScript

接下来咱们依照程序先加载 CSS 再加载 JS 文件:

// src/loader/index.ts
export const loadHTML = async (app: IInternalAppInfo) => {const { container, entry} = app

  const fakeContainer = document.createElement('div')
  fakeContainer.innerHTML = htmlFile
  const {scripts, links, inlineScript} = parseHTML(fakeContainer, app)

  await Promise.all(links.map((link) => fetchResource(link)))

  const jsCode = (await Promise.all(scripts.map((script) => fetchResource(script)))
  ).concat(inlineScript)

  return app
}

以上咱们就实现了从加载 HTML 文件到解析文件找出所有动态资源到最初的加载 CSS 及 JS 文件。然而实际上咱们这个实现还是有些毛糙的,尽管把核心内容实现了,然而还是有一些细节没有思考到的。

因而咱们也能够思考间接应用三方库来实现加载及解析文件的过程,这里咱们选用了 import-html-entry 这个库,外部做的事件和咱们外围是统一的,只是多解决了很多细节。

如果你想间接应用这个库的话,能够把 loadHTML 革新成这样:

export const loadHTML = async (app: IInternalAppInfo) => {const { container, entry} = app

  // template:解决好的 HTML 内容
  // getExternalStyleSheets:fetch CSS 文件
  // getExternalScripts:fetch JS 文件
  const {template, getExternalScripts, getExternalStyleSheets} =
    await importEntry(entry)
  const dom = document.querySelector(container)

  if (!dom) {throw new Error('容器不存在')
  }
  // 挂载 HTML 到微前端容器上
  dom.innerHTML = template
  // 加载文件
  await getExternalStyleSheets()
  const jsCode = await getExternalScripts()

  return app
}

运行 JS

当咱们拿到所有 JS 内容当前就该运行 JS 了,这步实现当前咱们就能在页面上看到子利用被渲染进去了。

这一大节的内容说简略的话能够没几行代码就写完,说简单的话实现起来会须要思考很多细节,咱们先来实现简略的局部,也就是如何运行 JS。

对于一段 JS 字符串来说,咱们想执行的话大抵上有两种形式:

  1. eval(js string)
  2. new Function(js string)()

这边咱们选用第二种形式来实现:

const runJS = (value: string, app: IInternalAppInfo) => {
  const code = `
    ${value}
    return window['${app.name}']
  `
  return new Function(code).call(window, window)
}

不晓得大家是否还记得咱们在注册子利用的时候给每个子利用都设置了一个 name 属性,这个属性其实很重要,咱们在之后的场景中也会用到。另外大家给子利用设置 name 的时候别忘了还须要稍微改变下打包的配置,将其中一个选项也设置为同样内容。

举个例子,咱们如果给其中一个技术栈为 Vue 的子利用设置了 name: vue,那么咱们还须要在打包配置中进行如下设置:

// vue.config.js
module.exports = {
  configureWebpack: {
    output: {
      // 和 name 一样
      library: `vue`
    },
  },
}

这样配置后,咱们就能通过 window.vue 拜访到利用的 JS 入口文件 export 进去的内容了:

大家能够在上图中看到导出的这些函数都是子利用的生命周期,咱们须要拿到这些函数去调用。

最初咱们在 loadHTML 中调用一下 runJS 就完事了:

export const loadHTML = async (app: IInternalAppInfo) => {const { container, entry} = app

  const {template, getExternalScripts, getExternalStyleSheets} =
    await importEntry(entry)
  const dom = document.querySelector(container)

  if (!dom) {throw new Error('容器不存在')
  }

  dom.innerHTML = template

  await getExternalStyleSheets()
  const jsCode = await getExternalScripts()

  jsCode.forEach((script) => {const lifeCycle = runJS(script, app)
    if (lifeCycle) {
      app.bootstrap = lifeCycle.bootstrap
      app.mount = lifeCycle.mount
      app.unmount = lifeCycle.unmount
    }
  })

  return app
}

实现以上步骤后,咱们就能看到子利用被失常渲染进去了!

然而到这一步其实还不算完,咱们思考这样一个问题:子利用扭转全局变量怎么办?咱们目前所有利用都能够获取及扭转 window 上的内容,那么一旦利用之间呈现全局变量抵触就会引发问题,因而咱们接下来须要来解决这个事儿。

JS 沙箱

咱们即要避免子利用间接批改 window 上的属性又要能拜访 window 上的内容,那么就只能做个假的 window 给子利用了,也就是实现一个 JS 沙箱。

实现沙箱的计划也有很多种,比如说:

  1. 快照
  2. Proxy

先来说说快照的计划,其实这个计划实现起来特地简略,说白了就是在挂载子利用前记录下以后 window 上的所有内容,而后接下来就轻易让子利用去玩了,直到卸载子利用时复原挂载前的 window 即可。这种计划实现容易,惟一毛病就是性能慢点,有趣味的读者能够间接看看 qiankun 的实现,这里就不再贴代码了。

再来说说 Proxy,也是咱们选用的计划,这个应该挺多读者都曾经理解过它的应用形式了,毕竟 Vue3 响应式原理都被说烂了。如果你还不理解它的话,能够先自行浏览 MDN 文档。

export class ProxySandbox {
  proxy: any
  running = false
  constructor() {
    // 创立个假的 window
    const fakeWindow = Object.create(null)
    const proxy = new Proxy(fakeWindow, {set: (target: any, p: string, value: any) => {
        // 如果以后沙箱在运行,就间接把值设置到 fakeWindow 上
        if (this.running) {target[p] = value
        }
        return true
      },
      get(target: any, p: string): any {
        // 避免用户逃课
        switch (p) {
          case 'window':
          case 'self':
          case 'globalThis':
            return proxy
        }
        // 如果属性不存在 fakeWindow 上,然而存在于 window 上
        // 从 window 上取值
        if (!window.hasOwnProperty.call(target, p) &&
          window.hasOwnProperty(p)
        ) {
          // @ts-ignore
          const value = window[p]
          if (typeof value === 'function') return value.bind(window)
          return value
        }
        return target[p]
      },
      has() {return true},
    })
    this.proxy = proxy
  }
  // 激活沙箱
  active() {this.running = true}
  // 失活沙箱
  inactive() {this.running = false}
}

以上代码只是一个初版的沙箱,外围思路就是创立一个假的 window 进去,如果用户设置值的话就设置在 fakeWindow 上,这样就不会影响全局变量了。如果用户取值的话,就判断属性是存在于 fakeWindow 上还是 window 上。

当然理论应用的时候咱们还是须要欠缺一下这个沙箱的,还须要解决一些细节,这里举荐大家间接浏览 qiankun 的源码,代码量不多,无非多解决了不少边界状况。

另外须要留神的是:个别快照和 Proxy 沙箱都是须要的,无非前者是后者的降级计划,毕竟不是所有浏览器都反对 Proxy 的。

最初咱们须要革新下 runJS 里的代码以便应用沙箱:

const runJS = (value: string, app: IInternalAppInfo) => {if (!app.proxy) {app.proxy = new ProxySandbox()
    // 将沙箱挂在全局属性上
    // @ts-ignore
    window.__CURRENT_PROXY__ = app.proxy.proxy
  }
  // 激活沙箱
  app.proxy.active()
  // 用沙箱代替全局环境调用 JS 
  const code = `
    return (window => {${value}
      return window['${app.name}']
    })(window.__CURRENT_PROXY__)
  `
  return new Function(code)()}

至此,咱们其实曾经实现了整个微前端的外围性能。因为文字表白很难连贯上下文所有的函数欠缺步骤,所以如果大家在阅读文章时有对不上的,还是举荐看下笔者仓库的源码。

接下来咱们会来做一些改善型性能。

改善型性能

prefetch

咱们目前的做法是匹配一个子利用胜利后才去加载子利用,这种形式其实不够高效。咱们更心愿用户在浏览以后子利用的时候就能把别的子利用资源也加载结束,这样用户切换利用的时候就无需期待了。

实现起来代码不多,利用咱们之前的 import-html-entry 就能马上做完了:

// src/start.ts
export const start = () => {const list = getAppList()
  if (!list.length) {throw new Error('请先注册利用')
  }

  hijackRoute()
  reroute(window.location.href)

  // 判断状态为 NOT_LOADED 的子利用才须要 prefetch
  list.forEach((app) => {if ((app as IInternalAppInfo).status === AppStatus.NOT_LOADED) {prefetch(app as IInternalAppInfo)
    }
  })
}
// src/utils.ts
export const prefetch = async (app: IInternalAppInfo) => {requestIdleCallback(async () => {const { getExternalScripts, getExternalStyleSheets} = await importEntry(app.entry)
    requestIdleCallback(getExternalStyleSheets)
    requestIdleCallback(getExternalScripts)
  })
}

以上代码别的都没啥好说的,次要来聊下 requestIdleCallback 这个函数。

window.requestIdleCallback()办法将在浏览器的闲暇时段内调用的函数排队。这使开发者可能在主事件循环上执行后盾和低优先级工作,而不会影响提早要害事件,如动画和输出响应。

咱们利用这个函数实现在浏览器闲暇工夫再去进行 prefetch,其实这个函数在 React 中也有用到,无非外部实现了一个 polyfill 版本。因为这个 API 有一些问题(最快 50ms 响应一次)尚未解决,然而在咱们的场景下不会有问题,所以能够间接应用。

资源缓存机制

当咱们加载过一次资源后,用户必定不心愿下次再进入该利用的时候还须要再加载一次资源,因而咱们须要实现资源的缓存机制。

上一大节咱们因为应用到了 import-html-entry,外部自带了缓存机制。如果你想本人实现的话,能够参考外部的实现形式。

简略来说就是搞一个对象缓存下每次申请下来的文件内容,下次申请的时候先判断对象中存不存在值,存在的话间接拿进去用就行。

全局通信及状态

这部分内容在笔者的代码中并未实现,如果你有趣味本人做的话,笔者能够提供一些思路。

全局通信及状态实际上齐全都能够看做是公布订阅模式的一种实现,只有你本人手写过 Event 的话,实现这个应该不是什么难题。

另外你也能够浏览下 qiankun 的全局状态实现,总共也就 100 行代码。

最初

文章到这里就完结了,整篇文章近万字,读下来可能不少读者还会存在一些疑虑,你能够抉择多读几遍或者联合笔者的源码浏览。

另外大家也能够在交换区发问,笔者会在闲暇工夫解答问题。

作者:yck

仓库:Github

公众号:前端真好玩

特地申明:原创不易,未经受权不得转载或剽窃,如需转载可分割笔者受权

正文完
 0