19

Preface

The author of this article has worked hard for more than a week, and has considered and revised the content many times, trying to help readers create a micro front-end framework to the greatest extent possible and understand the principles. Readers who think the content is good give a thumbs up and support.

Micro front-end is currently a popular technical architecture, and many readers ask me the principle in private. In order to clarify the principle, I will lead you to implement a micro front-end framework from scratch, which contains the following functions:

  • How to carry out route hijacking
  • How to render sub-applications
  • How to achieve JS sandbox and style isolation
  • Features to enhance experience

In addition, in the process of implementation, the author will also talk about the current technical solutions to achieve the micro front end and the implementation methods when doing the above functions.

Here is the address of the final output of this article: toy-micro .

Micro front end implementation scheme

There are many implementation schemes for the micro front end, for example:

  1. qiankun , implement JS and style isolation by yourself
  2. icestark , iframe solution, browser native isolation, but there are some problems
  3. emp , Webpack 5 Module Federation (Federal Module) program
  4. WebComponent and other solutions

But the scene problems solved by so many implementation solutions are still divided into two categories:

  • Single instance: There is only one sub-application on the current page, generally use qiankun
  • Multi-instance: There are multiple sub-applications on the current page, you can use the browser's native isolation scheme, such as iframe or WebComponent

Of course, it does not mean that single instance can only be used with qiankun. The browser native isolation solution is also feasible, as long as you accept the shortcomings they bring:

The biggest feature of iframe is to provide a browser native hard isolation solution, whether it is style isolation, js isolation and other issues can be perfectly solved. But his biggest problem is also that his isolation cannot be broken, resulting in the inability to share the context between applications, and the resulting development experience and product experience problems.

The above content is from 16135ca9522cef Why Not Iframe .

The implementation scheme of this article is the same as qiankun, but the functions and principles involved in it are all common, and you also need these when you change the implementation scheme.

Pre-work

Before the official start, we need to set up a development environment, where you can choose the technology stack of the main/sub application at will. For example, use React for the main application and Vue for the sub-application. You can choose by yourself. Each application uses the corresponding scaffolding tool to initialize the project, and I won’t take you to initialize the project here. Remember that if it is a React project, you need to execute yarn eject .

recommends that you directly use warehouse of the author, the configuration is configured, you only need to worry about following the author to example, the main application is React and the sub-application is Vue. The final directory structure we generate is roughly as follows:

截屏2021-08-30下午10.15.01

text

Before reading the main text, I assume that readers have used the micro front-end framework and understand its concepts, such as knowing that the main application is responsible for the overall layout and configuration and registration of sub-applications. If you have not used it before, I recommend that you briefly read any of the following micro-front-end framework usage documents.

Application registration

After we have the main application, we need to register the information of the sub-application in the main application. The content includes the following pieces:

  • name: sub-application noun
  • entry: the resource entry of the sub-application
  • container: the node where the main application renders the sub-application
  • activeRule: Under which routes the sub-application is rendered

In fact, this information is very similar to how we register the route in the project. entry can be regarded as the component that needs to be rendered, container can be regarded as the node for route rendering, and activeRule can be regarded as the rule of how to match the route.

Next, let's first implement the function of registering the sub-application:

// 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;
};

The above implementation is very simple, just save the appList passed in by the user.

Route hijacking

After we have the sub-application list, we need to start the micro front end to render the corresponding sub-application, that is, we need to judge the route to render the corresponding application. But before proceeding to the next step, we need to consider a question: monitor the routing changes to determine which sub-application to render?

For projects with non-SPA (single page application) architecture, this is not a problem at all, because we only need to judge the current URL and render the application when starting the micro front end; But under the SPA architecture, the routing change is It won't cause page refresh, so we need a way to know the route change, so as to determine whether we need to switch sub-applications or do nothing.

If you understand the principle of the Router library, you should be able to think of a solution right away. If you don’t understand, you can read the author’s article .

In order to take care of readers who don't understand, I will briefly talk about routing principles here.

There are currently two ways for single-page applications to use routing:

  1. Hash mode, that is, #
  2. histroy mode, which is a common URL format

The following author will use two illustrations to show which events and APIs are involved in these two modes:

img

img

From the above figure, we can find that routing changes will involve two events:

  • popstate
  • hashchange

Therefore, we definitely need to monitor these two events. In addition, calling pushState and replaceState will also cause routing changes, but will not trigger events, so we also need to rewrite these two functions.

After knowing what events to listen for and what functions to rewrite, let's implement the code:

// 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
  }
}

The above code looks at many lines, what it actually does is very simple, and is generally divided into the following steps:

  1. Rewrite the pushState and replaceState methods, and execute the logic of how to process the sub-application after calling the original method in the method
  2. Monitor the hashchange and popstate events, and execute the logic of how to process the sub-application after the event is triggered
  3. Rewrite the monitor/remove event function, if the application monitors the hashchange and popstate events, save the callback function for later use

Application life cycle

After implementing route hijacking, we now need to consider how to implement the logic of processing sub-applications, that is, how to handle sub-application loading resources and mounting and unloading sub-applications. Seeing this, do you think this is very similar to components. Components also need to deal with these things, and will expose the corresponding life cycle to users to do what they want.

Therefore, for a sub-application, we also need to implement a set of life cycles. Since the sub-application has a life cycle, the main application must have it, and it must correspond to the life cycle of the sub-application.

So here we can roughly sort out the life cycle of the main/sub application.

For the main application, it is divided into the following three life cycles:

  1. beforeLoad : Before mounting the sub-application
  2. mounted : After mounting the sub-application
  3. unmounted : Uninstall sub-applications

Of course, if you want to increase the life cycle, there is no problem at all. The author here only implements three for simplicity.

For sub-applications, general is also divided into the following three life cycles:

  1. bootstrap : Triggered when the first application loads, often used to configure the global information of sub-applications
  2. mount : Triggered when the application is mounted, often used to render sub-applications
  3. unmount : Triggered when the application is uninstalled, often used to destroy sub-applications

Next, we will implement the registration main application life cycle function:

// 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;
};

Because it is the life cycle of the main application, we register it when we register the sub-application.

Then the life cycle of the sub-application:

// 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);
  }
};

The above code looks a lot, and the actual implementation is also very simple. In summary:

  • Set the status of sub-applications for logical judgment and optimization. For example, when an application state is not NOT_LOADED (each application is initially in NOT_LOADED state), there is no need to reload resources the next time the application is rendered
  • If you need to process logic, such as beforeLoad we need to load sub-application resources
  • Execute the main/child application life cycle, here you need to pay attention to the execution order, you can refer to the life cycle execution order of the parent and child components

Perfect routing hijacking

After implementing the application life cycle, we can now complete the logic how to deal with the sub-application

This piece of logic is actually very simple after we finish the life cycle, which can be divided into the following steps:

  1. Determine whether the current URL is consistent with the previous URL, if they are consistent, continue
  2. Use the URL of course to match the corresponding sub-application. At this time, there are several situations:

    • When the micro front end is started for the first time, only the successfully matched sub-applications need to be rendered at this time
    • Sub-applications are not switched, no need to deal with sub-applications at this time
    • Switch sub-applications. At this time, you need to find out the previously rendered sub-applications for uninstall processing, and then render the matched sub-applications
  3. Save the current URL for the next first step judgment

After clarifying the steps, let's implement it:

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
}

The main body of the above code is executing the life cycle functions in order, but the function of matching routing is not implemented, because we need to consider some issues first.

You must have used routing in project development. You should know that the principle of routing matching is mainly composed of two parts:

  • Nested relationship
  • Path syntax

The nesting relationship refers to: if my current route is set to /vue , then /vue like 06135ca95236eb or /vue/xxx can match this route, unless we set excart which is an exact match.

The author of the path grammar will directly take an example from the document to present it:

<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

It seems that routing matching is still very troublesome to implement, so do we have an easy way to implement this function? The answer is definitely yes. As long as we read the source code of the Route library, we can find that they all use the path-to-regexp library. Interested readers can read the documentation of this library by themselves. I have brought it here. We just look at the use of one of the APIs.

截屏2021-09-02下午10.31.03

With the solution in place, we will quickly implement the function of route matching:

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 }
}

After completing the above function, reroute don't forget to call it in the 06135ca9523792 function. So far, the route hijacking function is completely completed. The complete code can be read here .

Perfect life cycle

In the process of implementing the life cycle before, we still have a very important step " loading sub-application resources " that has not been completed. In this section, we will finish this piece of content.

Since resources are to be loaded, we must first need a resource entry, just like the npm package we use, each package must have an entry file. Back to the registerMicroApps entry parameter to this function at the very beginning, which is the resource entry of the sub-application.

The resource entrance is actually divided into two schemes:

  1. JS Entry
  2. HTML Entry

These two schemes are literal. The former loads all static resources through JS, and the latter loads all static resources through HTML.

JS Entry is a method used in single-spa But it is a bit more restrictive and requires users to package all files together. Unless your project has no sense of performance, you can basically pass this solution.

HTML Entry is much better, after all, all websites use HTML as the entry file. In this solution, we basically do not need to change the packaging method, and it is almost non-invasive to user development. We only need to find out the static resources in the HTML, load and run to render the sub-application, so we chose this solution.

Next we begin to realize the content of this part.

Load resources

First of all, we need to get the HTML content, here we only need to call the original fetch to get things.

// 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
}

In the author's warehouse example, after we switch the route to /vue , we can print out the content of the loaded HTML file.

<!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>

We can see a lot of static resource URL in this file, and then we need to load these resources. But one thing we need to pay attention to is that these resources can only be loaded correctly under their own BaseURL. If it is under the BaseURL of the main application, a 404 error must be reported.

Then we need to pay attention to one more point: because we are loading the resources of the sub-application under the URL of the main application, this is likely to trigger cross-domain restrictions. Therefore, in the development and production environment, everyone must pay attention to cross-domain processing.

For example, if the sub-application in the development environment is Vue, how to handle cross-domain:

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

Next, we need to process the paths of these resources first, concatenate the relative paths into the correct absolute paths, and then go to 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
}

The function of the above code will not be repeated. The comments are already very detailed. Next, we need to find the resources in the HTML file and go to fetch .

Since it is to find the resource, then we have to parse the HTML content:

// 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 {  }
}

Analyzing the content is still simple. We recursively search for elements, link , script , and img and do the corresponding processing.

First look at how we deal with 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)!)
  }
}

When processing the link tag, we only need to process the CSS resources, and other preload/prefetch resources can directly replace 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 }
}

When processing the script tag, we need to distinguish whether it is a JS file or an inline code. The former also requires fecth to get the content once.

Then we will parseHTML return all parsed out in scripts , links , inlineScript .

Next, we load the CSS first and then load the JS file in order:

// 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
}

Above we have realized from loading HTML files to parse files to find out all the static resources to the final loading of CSS and JS files. But in fact, our implementation is still a bit rough. Although the core content is implemented, there are still some details that have not been considered.

Therefore, we can also consider directly using the third party library to implement the process of loading and parsing files. Here we choose the import-html-entry library. The internal things are the same as our core, but a lot of details are processed.

If you want to use this library directly, you can loadHTML into this:

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
}

Run JS

When we get all the JS content, it's time to run the JS. After this step is completed, we can see the sub-application rendered on the page.

The content of this section can be written in a few lines of code if it is simple. If it is complex, it will need to consider a lot of details. Let's first implement the simple part, that is, how to run JS.

For a JS string, there are roughly two ways to execute it:

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

Here we choose the second way to achieve:

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

I don't know if you still remember that we set a name property for each sub-application when registering the sub-application. This property is actually very important, and we will also use it in future scenarios. In addition, when you set name for the sub-application, don't forget that you need to slightly change the package configuration, and set one of the options to the same content.

name: vue for one of the technology stacks as a sub-application of Vue, then we also need to make the following settings in the packaging configuration:

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

After this configuration, we can access the content of the application's JS entry file export window.vue

截屏2021-09-05上午11.23.26

You can see in the figure above that these exported functions are the life cycle of the sub-application, and we need to get these functions to call.

Finally, we call loadHTML runJS we are done:

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
}

After completing the above steps, we can see that the sub-application is rendered normally!

截屏2021-09-05下午12.30.51

But this step is not finished yet. Let's consider this question: sub-application changes global variables? All of our current applications can access and change window , so once there is a global variable conflict between applications, it will cause problems, so we need to solve this next.

JS sandbox

We need to prevent the sub-application from directly modifying window and to be able to access window , then we can only make a fake window for the sub-application, which is to implement a JS sandbox.

There are also many schemes for implementing sandboxes, such as:

  1. Snapshot
  2. Proxy

Let’s talk about the snapshot scheme first. In fact, this scheme is very simple to implement. To put it window , it is to record all the current content on 06135ca9523d6f before mounting the sub-application, and then let the sub-application to play at will, until the sub-application is uninstalled. Time to restore the window before mounting. This program is easy to implement, the only drawback is slower performance reader, are interested can look directly qiankun implementation , there is not paste code.

Let's talk about Proxy, which is also the solution we chose. Many readers have already understood how to use it. After all, the responsive principle of Vue3 has been said to be bad. If you don't know it yet, you can read the MDN document .

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
  }
}

The above code is just a first version of the sandbox. The core idea is to create a fake window . If the user sets the value, set it on fakeWindow , so that it will not affect the global variables. If the user takes a value, it is judged whether the attribute exists on fakeWindow or window .

Of course, in actual use, we still need to improve this sandbox, and we need to deal with some details. It is recommended that you read qiankun's source code . The amount of code is not much, it is nothing more than dealing with a lot of boundary conditions.

Another thing to note is that both general snapshots and Proxy sandboxes are required. The former is nothing but the latter's downgrade scheme. After all, not all browsers support Proxy.

Finally, we need to modify runJS to use the sandbox:

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)()
}

So far, we have actually completed the core functions of the entire micro front end. Because the text expression is difficult to coherent all the function improvement steps of the context, so if you have when reading the article, it is recommended to look at the source code author's 16135ca9523e6a warehouse.

Next we will do some improved functions.

Improved function

prefetch

Our current approach is to load the sub-application after matching a sub-application successfully. This method is actually not efficient enough. We also hope that the user can load other sub-application resources when browsing the current sub-application, so that the user does not need to wait when switching applications.

There is not much code to implement, and we can do it right away 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)
  })
}

There is nothing else to say about the above code, requestIdleCallback talk about the function 06135ca9523f09.

window.requestIdleCallback() method queues the functions called during the browser's idle time. This enables developers to perform background and low-priority work on the main event loop without affecting delayed key events such as animation and input response.

We use this function to implement prefetch in the free time of the browser. In fact, this function is also useful in React. It is nothing more than an internal implementation of a polyfill version. Because this API has some problems (the fastest 50ms response time) has not been solved, but there will be no problems in our scenario, so it can be used directly.

Resource caching mechanism

After we load the resource once, the user definitely does not want to load the resource again when entering the application next time, so we need to implement a resource caching mechanism.

In the previous section, because we used import-html-entry , the internal caching mechanism is built in. If you want to implement your own, you can refer internal implementation .

To put it simply, it is to create an object cache for the content of the file that is requested each time, the next time the request is made, it is first judged whether there is a value in the object, and if it exists, it can be used directly.

Global communication and status

This part of the content is not implemented in the author's code, if you are interested in doing it yourself, the author can provide some ideas.

Global communication and status can actually be regarded as an implementation of the publish and subscribe model. As long as you have written Event by yourself, it should not be a problem to achieve this.

In addition, you can also read qiankun's global state implementation , a total of 100 lines of code.

finally

This is the end of the article. The entire article is nearly 10,000 words. Many readers may still have some doubts reading. source code 16135ca952402d.

In addition, you can also ask questions in the communication area, and the author will answer questions in free time.

Author: yck

Warehouse: Github

Public number: front end is really fun

Special statement: originality is not easy, no reprinting or plagiarism is allowed without authorization, if you need to reprint, please contact the author for authorization


y_ck
845 声望2.7k 粉丝

Hi there,I'm yck 👋