阅读 16399

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

前言

这篇文章笔者足足肝了一周多,多次斟酌修改内容,力求最大程度帮助读者造出一个微前端框架,搞懂原理。觉得内容不错的读者点个赞支持下。

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

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

另外在实现的过程中,笔者还会聊聊目前有哪些技术方案可以去实现微前端以及做以上功能的时候有哪些实现方式。

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

微前端实现方案

微前端的实现方案有挺多,比如说:

  1. qiankunicestark 自己实现 JS 及样式隔离
  2. emp,Webpack 5 Module Federation(联邦模块)方案
  3. iframe 、WebComponent 等方案,浏览器原生隔离,但存在一些问题

更新:这里之前有一个错误,笔者错把 icestark 的技术方案说成了 iframe 的。

但是这么多实现方案解决的场景问题还是分为两类:

  • 单实例:当前页面只存在一个子应用,一般使用 qiankun 就行
  • 多实例:当前页面存在多个子应用,可以使用浏览器原生隔离方案,比如 iframe 或者 WebComponent 这些

当然了,并不是说单实例只能用 qiankun,浏览器原生隔离方案也是可行的,只要你接受它们带来的不足就行:

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

上述内容摘自Why Not Iframe

本文的实现方案和 qiankun 一致,但是其中涉及到的功能及原理方面的东西都是通用的,你换个实现方案也需要这些。

前置工作

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

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

截屏2021-08-30下午10.15.01

正文

在阅读正文前,我假定各位读者已经使用过微前端框架并了解其中的概念,比如说知晓主应用是负责整体布局以及子应用的配置及注册这类内容。如果还未使用过,推荐各位简略阅读下任一微前端框架使用文档。

应用注册

在有了主应用之后,我们需要先在主应用中注册子应用的信息,内容包含以下几块:

  • 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:

img

img

从上述图中我们可以发现,路由变化会涉及到两个事件:

  • 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 的使用就行。

截屏2021-09-02下午10.31.03

有了解决方案以后,我们就快速实现下路由匹配的函数:

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 出来的内容了:

截屏2021-09-05上午11.23.26

大家可以在上图中看到导出的这些函数都是子应用的生命周期,我们需要拿到这些函数去调用。

最后我们在 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
}
复制代码

完成以上步骤后,我们就能看到子应用被正常渲染出来了!

截屏2021-09-05下午12.30.51

但是到这一步其实还不算完,我们考虑这样一个问题:子应用改变全局变量怎么办? 我们目前所有应用都可以获取及改变 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

公众号:前端真好玩

特别声明:原创不易,未经授权不得转载或抄袭,如需转载可联系笔者授权

文章分类
前端