从0到1实现一个微前端框架

为什么需要微前端

  • 巨石应用(Monolith Application)的产生

    我们平常在开发前端应用时,通常会使用SPA这样的架构(如下图),通过路由分发进入不同的页面(组件),而每个页面又由各种组件的构成,随着业务的不断推进,新的功能不断增加,应用内的模块不断增多,应用代码量不断增加,我们部署和维护的难度也在不断增加,应用逐渐变成一个巨石应用
    image.png

    • 巨石应用带来的问题(Disadvantages of Monolith) 1. Difficult to deploy and maintain(难以部署和维护) 2. Obstacle to frequent deployments(会有频繁部署的问题) 3. Dependency between unrelated features(会出现不相关的依赖特征) 4. Makes it diffcult to try out new technologies/framework(难以尝试新的技术和框架)
      为了解决这个问题,提出了微前端的概念。
  • 微前端(Micro Frontend)

    • 概念(concept)

      微前端是一种类似于微服务的架构,是一种由独立交付的多个前端应用组成整体的架构风格,将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的应用,而在用户看来仍然是内聚的单个产品。[1]

    • 架构
      微前端的架构如下图所示,我们将一个总的应用通过某种方式分解成一些更小的应用,每个小的应用都是独立开发,部署的,最后通过父应用路由组合起来,形成一个前端应用。
      image.png
    • 优缺点
      • Advantage
        • 应用自治。只需要遵循统一的接口规范或者框架,以便于系统集成到一起,相互之间是不存在依赖关系的。
        • 单一职责。每个前端应用可以只关注于自己所需要完成的功能。
        • 技术栈无关。可以使用 原生 JS 的同时,又可以使用 React 和 Vue。
      • Disadvantage
        • 维护问题
        • 架构复杂
    • 应用场景(scenes)
      • 中台/后台
      • 大型的 Web 应用

微前端实现方式与原理分析(principle)

微前端实现方式

  • iframe
    • why not iframe[2]
      • 性能问题,iframe 每次进入子应用时都会重新渲染,资源也会重新加载
      • 全局上下文完全隔离,内存变量不共享。cookie 不共享,需要搭建特定的 channel 进行通信
      • DOM 结构不共享,无法使用全局弹窗
      • url 不同步,刷新页面,无法使用前进/后退
  • 基于系统基座实现
    • 主应用搭建一个基座,在基座中渲染子应用
    • qiankun,singleSPA

微前端框架流程图(基于系统基座)

image.png
如上图所示,运行一个微应用需要经过注册初始化运行三个步骤。

  • 注册是将微应用的信息保存,并且创建全局状态管理;
  • 初始化会获取注册的应用所在的地址的HTML文件,并且提取静态 js,css 文件;
  • 运行或路由发生变化时,会运行静态的 js,css 文件,调用生命周期渲染微应用;
  • 需要实现的功能
    1. 加载 HTML
    2. 加载 JS 文件
    3. 定义生命周期
    4. 使用沙箱隔绝执行作用域
    5. 创建应用间通信 channel
    6. 路由监听
    7. 样式隔离

实现流程

1.获取应用

  • 获取 HTML 文件
    image.png
    在创建构造函数之前,我们需要先实现一个获取 HTML 字符串,并且截取静态 js,css 文件的方法。在这里我做了一种兼容vite的方式

  • loadHtml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    async function loadHtml(
    entry: string,
    type: LoadScriptType
    ): Promise<LoadHtmlResult> {
    const data = await fetch(entry, {
    method: "GET",
    });
    let text = await data.text();
    const scriptArr = text
    .match(scriptReg)
    ?.filter((val) => val)
    .map((val) => (isHttp.test(val) ? val : `${entry}${val}`));
    const styleArr = text
    .match(styleReg)
    ?.filter((val) => val)
    .map((val) => (isHttp.test(val) ? val : `${entry}${val}`));
    text = text.replace(/(<script.*><\/script>)/g, "");
    console.log(scriptArr);

    const scriptText: string[] = [];
    if (type === "webpack" && scriptArr) {
    for (const item of scriptArr) {
    let scriptFetch = await fetch(item, { method: "GET" });
    scriptText.push(await scriptFetch.text());
    }
    }

    return {
    entry,
    html: text,
    scriptSrc: type === "webpack" ? scriptText : scriptArr || [],
    styleSrc: styleArr || [],
    };
    }
  • 运行 JS(runScript),定义生命周期
    image.png
    获取到 HTML 文件以后,需要定义一个执行微应用 js 文件,并且调用生命周期的方法

  • 生命周期

    • beforeMount 在渲染应用之前调用的函数
    • mount 挂载微应用时调用渲染函数
    • unmount 卸载微应用时调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
/** 生命周期函数 */
export type LoadFunctionResult = {
beforeMount: () => void;
mount: (props: LoadFunctionMountParam) => void;
unmount: (props: UnloadFunctionParam) => void;
};
export type LoadScriptType = "esbuild" | "webpack";

/** 注入环境变量 */
export function injectEnvironmentStr(context: ProxyParam) {
context[PRODUCT_BY_MICRO_FRONTEND] = true;
context.__vite_plugin_react_preamble_installed__ = true;
return true;
}

/** 使用import加载script */
export async function loadScriptByImport(scripts: string[]) {
injectEnvironmentStr(window);
let scriptStr = `
return Promise.all([`;
scripts.forEach((val) => {
scriptStr += `import("${val}"),`;
});
scriptStr = scriptStr.substring(0, scriptStr.length - 1);
scriptStr += `]);
`;
return await new Function(scriptStr)();
}

/** 执行js字符串 */
export async function loadScriptByString(
scripts: string[],
context: ProxyParam
) {
const scriptArr: Promise<Record<string, any>>[] = [];
injectEnvironmentStr(context);
scripts.forEach(async (val) => {
scriptArr.push(
await new Function(`
return (window => {
${val}
return window.middleVue;
})(this)
`).call(context)
);
});
return scriptArr;
}

/** 加载JS文件 */
export async function loadFunction<T extends LoadFunctionResult>(
context: Window,
scripts: string[] = [],
type: LoadScriptType = "esbuild"
): Promise<T> {
let result = {};
if (type === "esbuild") {
result = await loadScriptByImport(scripts);
} else {
result = await loadScriptByString(scripts, context);
}

let obj: LoadFunctionResult = {
beforeMount: () => {},
mount: () => {},
unmount: () => {},
};
(<Record<string, any>[]>result).forEach((val) => {
Object.assign(obj, val);
});
return <T>obj;
}

2. 定义构造函数(MicroFront)

image.png

上面我们已经,实现了获取 HTML 和运行 JS 文件的方法。接下来需要定义一个构造函数去调用它们。
注册应用时,我们需要传入以下参数的对象数组。
image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
interface MicroFrountendMethod {
init: () => void;
setCurrentRoute: (routeName: string) => void;
start: () => void;
}

export default class MicroFrountend implements MicroFrountendMethod {
/** 微应用列表 */
private servers: RegisterData[];
/** 请求后的应用列表 */
private serverLoadData: Record<string, LoadHtmlResult>;
/** 当前路由 */
public currentRoute: string;
/** 当前开启的微应用容器 */
public currentActiveApp: string[];
/** 全局store */
public store: Record<string, any>;

constructor(servers: RegisterData[]) {
this.servers = servers;
this.serverLoadData = {};
this.currentRoute = '';
this.currentActiveApp = [];
this.store = createStore();
}

/** 初始化 */
public async init() {
for (let item of this.servers) {
const serverData = await loadHtml(item.entry, item.type);
addNewListener(item.appName);
this.serverLoadData[item.appName] = serverData;
}

return true;
}

/** 设置路由 */
public setCurrentRoute(routeName: string) {
const appIndex = this.servers.findIndex(
(val) => val.activeRoute === routeName
);
if (appIndex === -1) return false;
const appName = this.servers[appIndex].appName;
const isInclude = Object.keys(this.serverLoadData).includes(appName);
if (!isInclude) {
return false;
}

this.currentRoute = routeName;
return true;
}

/** 开启加载微前端应用 */
public async start() {
const currentRoute = this.currentRoute || window.location.pathname;
const appList = this.servers.filter(
(val) => val.activeRoute === currentRoute
);
for (let val of appList) {
const appName = val.appName;
const htmlData = this.serverLoadData[appName];
const scriptResult = await runScript(val, htmlData, this.store);
this.serverLoadData[appName].lifeCycle = scriptResult.lifeCycle;
this.serverLoadData[appName].sandbox = scriptResult.sandBox;
}
}

3. JS 沙箱

沙箱的介绍已经有很多文件在介绍了,这里也就不多介绍,具体可以看这里

  • 沙箱种类
    iframe

    1
    <iframe></iframe>

    SnapshopSandbox(快照沙箱)
    image.png
    LegacySandbox(单例沙箱)
    image.png
    ProxySandbox(多例沙箱)
    image.png

  • 沙箱实现(多例沙箱)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    interface SandBoxImplement {
    active: () => void;
    inActive: () => void;
    }

    type ProxyParam = Record<string, any> & Window;

    /** 沙箱操作 */
    class SandBox implements SandBoxImplement {
    public proxy: ProxyParam;
    private isSandboxActive: boolean;
    public name: string;

    /** 激活沙箱 */
    active() {
    this.isSandboxActive = true;
    }

    /** 关闭沙箱 */
    inActive() {
    this.isSandboxActive = false;
    }

    constructor(appName: string, context: Window & Record<string, any>) {
    this.name = appName;
    this.isSandboxActive = false;
    const fateWindow = {};
    this.proxy = new Proxy(<ProxyParam>fateWindow, {
    set: (target, key, value) => {
    if (this.isSandboxActive) {
    target[<string>key] = value;
    }
    return true;
    },
    get: (target, key) => {
    if (target[<string>key]) {
    return target[<string>key];
    } else if (Object.keys(context).includes(<string>key)) {
    return context[<string>key];
    }

    return undefined;
    },
    });
    }
    }

    export default SandBox;
  • 在沙箱中运行微应用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    /** 注入环境变量 */
    function injectEnvironmentStr(context: ProxyParam) {
    context[PRODUCT_BY_MICRO_FRONTEND] = true;
    context.__vite_plugin_react_preamble_installed__ = true;
    return true;
    }

    /** 执行js字符串 */
    async function loadScriptByString(scripts: string[], context: ProxyParam) {
    const scriptArr: Promise<Record<string, any>>[] = [];
    injectEnvironmentStr(context);
    scripts.forEach(async (val) => {
    scriptArr.push(
    await new Function(`
    return (window => {
    ${val}
    return window.middleVue;
    })(this)
    `).call(context)
    );
    });
    return scriptArr;
    }

4. 全局通信状态管理

到这里为止,我们已经可以在父应用中运行子应用了。但是很多时候需要再父应用和子应用之间进行数据通信,或者子应用之间的数据通信
所以我们还需要实现一个全局的状态管理,在调用生命周期时,将状态管理方法传入微应用中。通信流程如下图所示,利用发布——订阅的方法通知订阅了参数改变事件触发对应的业务函数。
image.png

  • 创建全局状态

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    /** 创建全局store */
    export function createStore() {
    const globalStore = new Proxy(<Record<string, any>>{}, {
    get(target, key: string) {
    return target[key];
    },
    set(target, key: string, value) {
    const oldVal = target[key];
    target[key] = value;

    // 触发监听事件
    triggerEvent({ key, value, oldValue: oldVal });
    return true;
    },
    });
    return globalStore;
    }
  • 新增监听器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    export type triggerEventParam<T> = {
    key: string;
    value: T;
    oldValue: T;
    };
    /** 监听对象 */
    const listener: Map<
    string,
    Record<string, (data: triggerEventParam<any>) => void>
    > = new Map();
    /** 新增store监听器 */
    export function addNewListener(appName: string) {
    if (listener.has(appName)) return;
    listener.set(appName, {});
    }
  • 订阅事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    /** 设置监听事件 */
    export function setEventTrigger<T extends any>(
    appName: string,
    key: string,
    callback: (data: triggerEventParam<T>) => void
    ) {
    if (listener.has(appName)) {
    const obj = listener.get(appName);
    if (obj) {
    obj[key] = callback;
    }
    }
    }
  • 触发事件

    1
    2
    3
    4
    5
    6
    7
    8
    /** 改变字段值触发事件 */
    export function triggerEvent<T extends any>(data: triggerEventParam<T>) {
    listener.forEach((val) => {
    if (val[data.key] && typeof val[data.key] === "function") {
    val[data.key](data);
    }
    });
    }

5. 路由监听

image.png

路由监听是微前端中非常重要且复杂的一环,我们需要通过监听路由变化去挂载或者卸载应用。我们都知道前端路由分为两种:hashhistory路由。
监听 hash 很简单,只需要在监听器中定义hashchange的方法即可。
监听 history 路由时,需要分成两种情况,触发浏览器前进,后退,我们可以使用popstate方法监听到,但是 history 中的pushStatereplaceState无法使用popstate监听,我们需要重写这两个方法,将监听器放到这两个方法中。

  • 监听 hash 路由变化回调

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /** 监听hash路由变化 */
    function listenHash(callback: listenCallback) {
    window.addEventListener("hashchange", (ev) => {
    callback(getHashPathName(ev.oldURL), getHashPathName(ev.newURL), {});
    });
    }

    function getHashPathName(url: string) {
    const pathArr = url.split("#");
    return pathArr[1] ? `/${pathArr[1]}` : "/";
    }
  • 监听 history 路由变化回调

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    export type listenCallback = (
    oldPathName: string,
    pathName: string,
    param: any
    ) => void;
    // 保存原生方法
    const globalPushState = window.history.pushState;
    const globalReplaceState = window.history.replaceState;
    /** 监听history路由变化 */
    function listenHistory(callback: listenCallback, currentRoute: string) {
    window.history.pushState = historyControlRewrite("pushState", callback);
    window.history.replaceState = historyControlRewrite(
    "replaceState",
    callback
    );
    window.addEventListener("popstate", (ev) => {
    callback(currentRoute, window.location.pathname, ev.state);
    });
    }

    // 重写pushState,replaceState方法
    const historyControlRewrite = function (
    name: "pushState" | "replaceState",
    callback: listenCallback
    ) {
    const method = history[name];
    return function (data: any, unused: string, url: string) {
    const oldPathName = window.location.pathname;
    if (oldPathName === url) return;
    method.apply(history, [data, unused, url]);
    callback(oldPathName, url || "", data);
    };
    };

6. 样式隔离

  • 样式隔离解决方案
    1. 在微前端框架中获取 style 样式并添加唯一前缀
    2. 微应用中约定通过 postcss 处理
  • 利用 postcss 处理样式隔离

演示

父应用我使用的是原生JS,分别定义了一个vue框架和react框架的项目
image.png

Vue 项目

image.png

React 项目

image.png

运行

image.png

小结

image.png

源码地址: https://github.com/JeremyYu-cn/micro_frontend

参考

  1. 字节跳动是如何落地微前端的
  2. Why Not Iframe
  3. qiankun
  4. singleSpa
  5. 从零到一实现企业级微前端框架,保姆级教学
  6. 解密微前端:从 qiankun 看沙箱隔离