微前端入门到入土

1,784 阅读16分钟

楔子

随着时间的推移,一个系统会越来越复杂,体积越来越庞大,代码之间的牵扯越来越深,构建耗时越来越长,技术也会逐渐的被淘汰或替代,最终该系统会变成一个巨石应用(屎山)。

巨石应用有着以下几个让程序员们深恶痛绝的问题(不限于):

  • 代码复杂度高(可读性差、维护困难)
  • 代码规范不完善(由于各种各样的原因妥协式的没有按照指定的代码规范进行开发)
  • 代码耦合度高,牵一发而动全身
  • 构建时间过长,往往修改一点内容都需要等上好几分钟才能看到结果
  • 技术老旧(随着时间的推移,当前最新的技术在几年后可能已经被淘汰或者更新了多个大版本)
  • 开发效率低下和困难

那么有没有一种方案可以解决 巨石应用 ?

答案是有的,那就是 微前端

系统的生命周期

那么在开始了解 微前端 之前,我们先来了解一下一个系统的生命周期。

生命周期这个词我们可能熟悉的不行了,我们使用的框架,例如:Vue、React、Angular 都有着自己的生命周期钩子,而且也是面试必考的知识点。

系统的生命周期大致可以分为:初生、发展、成熟、衰弱等几个阶段。

早期的系统有着以下的特点(不限于):

  • 代码复杂度低(可读性好、易维护)
  • 代码规范保持完好
  • 代码耦合度低
  • 开发效率高和容易

晚期的系统有着以下的特点(不限于):

  • 代码复杂度高(可读性差、维护困难)
  • 代码规范不完善(由于各种各样的原因妥协式的没有按照指定的代码规范进行开发)
  • 代码耦合度高,牵一发而动全身
  • 构建时间过长,往往修改一点内容都需要等上好几分钟才能看到结果
  • 技术老旧(随着时间的推移,当前最新的技术在几年后可能已经被淘汰或者更新了多个大版本)
  • 开发效率低下和困难

再了解系统的生命周期和它的一些特点后,接下来先回归到 微前端

什么是微前端?

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

通过搜索引擎可以很容易的在网上查到这段比较概念化的解释,当然一般情况下来说对于第一次接触 微前端 的人来说,可能会很难理解这句话的意思。

从开发的层面上来看,微前端 可以将多个 小型应用 整合成一个完整的应用。

从产品的层面上来看,微前端 可以将一个 大型应用 拆解成多个独立灵活的 小型应用,并且每一个 小型应用 都是可以独立开发、独立运行、独立部署的,它们之间不会有任何干扰和影响(这点非常重要)。

有的同学可能就会问,将一个 大型应用 分离成多个 小型应用 有哪些好处?

每个 小型应用 都是 独立开发、独立运行、独立部署 的,这就意味着可以 多团队并行开发 且有无冲突、无阻塞等特点,并且可以让 小型应用 拥有所有 早期系统 的特性。

微前端的特性

其实微前端的特性,在上面基本上都已经展现出来了,这里我们可以总结一下。

  • 技术栈无关 - **主应用** 不限制接入的 **子应用** 使用的技术栈,**子应用** 具备完全自主权;
    
  • 独立开发、独立部署 - **子应用** 仓库独立、开发独立、部署独立,部署之后 **主应用** 自动同步更新;
    
  • 增量升级 - 对于一个系统而言,我们很难去对它做全量的技术栈升级或重构,而微前端则是一种非常好的渐进式重构的手段和策略;
    
  • 独立运行时 - 每个 **子应用** 之间的状态都是隔离的互不影响。
    

常见的微前端实现方式

其实 微前端 的实现并没有很复杂,甚至有些非常的简单,而且很多同学已经接触过 微前端 的实现了,只不过由于没有了解过 微前端 ,所以不知道。

  • 路由分发
    
  • iFreame
    
  • Web Components
    

有兴趣的同学可以通过搜索引擎,去检索更多的 微前端 实现方式。

路由分发

简单来说,其实就是通过 网关/代理路由 进行监听,对不同的路由路径导向不同的前端资源入口。

例如:

使用 Nginx 代理了 localhost:80

然后访问路径为 /app1 的时候,返回 html/app1/index.html 的文件;

访问路径为 /app2 的时候,返回 html/app2/index.html 的文件。

这种方式由于是多页面应用,所以用户体验不好,而且比较依赖于后端,应用之间存在着耦合问题。

iFreame

我想想大家对 iFreame 这个东西熟悉的不能再熟悉了把!

它的作用就是在网页中嵌套另一个网页,那么通过这种特性就可以实现微前端了。

但实际上个大 微前端 框架,就没有一个是用 iFreame 去做的,因为 iFreame 有着很多的问题,其中一些甚至是无法解决的。

Web Components

可能有些同学对 Web Components 不太了解,在 MDN 中是这样对 Web Components 定义的:

Web Components 是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的 web 应用中使用它们。

Web Components 它由三项主要技术组成,它们可以一起使用来创建封装功能的定制元素,可以在你喜欢的任何地方重用,不必担心代码冲突。

  • Custom elements(自定义元素): 一组 JavaScript API,允许您定义 custom elements 及其行为,然后可以在您的用户界面中按照需要使用它们。

  • Shadow DOM(影子 DOM) :一组 JavaScript API,用于将封装的“影子”DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,您可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。

  • HTML templates(HTML 模板):  <template> 和 <slot> 元素使您可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。

综上所述,Web Components 基本上符合 微前端 所有特性,但是 Web Components 属于浏览器的新特性,所以 Web Components 对浏览器是有一定要求的。

从零到一实现微前端框架

文本的主旨是让读者对微前端的核心实现有一定的了解,这一块以 伪代码 为主,并且不会涉及太多 应用 上的内容,如果想真正运行起来,可能还需要各位自行调试一番。

到目前为止,我们已经对 微前端 的概念有了一定程度的了解。

那么接下来,让我们自己实现一个 微前端框架 吧!

架构设计

一个简单的 微前端 应用架构。

我们目前只涉及到 微前端框架 这一块的内容,如果对其他部分的内容比较感兴趣的话,可以自行查阅资料。

hulk.png

技术选型

首先我们要确定一下要使用什么样的方式来实现 微前端

微前端方案

这里我决定使用 路由分发 的形式来实现微前端!

但是并不是我们之前说的那种 路由分发 ,由于前后端分离的架构,加上目前主流的 SPA(单页面应用) ,所以我们前端也拥有控制 路由分发 的能力,这种方案被叫做 组合式应用路由分发,算是 路由分发 的一个变种吧。

子应用注册

首先要考虑的就是如何注册 子应用 ,只有注册了 子应用 ,才能根据路由来匹配和加载对吧?

这里可以定义 registerMicroApplications 的 API 来专门进行 子应用 的注册,注册后的子应用会通过 单例模式 进行保存,并且通过开放的 API 进行对数据的读写。

然后我们需要考虑一下,子应用 需要提供哪些数据,我们才能进行匹配和加载?

TS 数据类型仅用于表示数据结构

子应用注册的数据结构:

interface IApplication {
    name: string; // 应用名称
    container: string; // 加载到的容器
    entry: string; // 入口
    activePath: string; // 激活路径
}

生命周期

声明周期这个东西大家肯定都不陌生,实现上其实就是在代码执行到某个节点的时候,执行生命周期的钩子。

这里设定三个生命周期,分别是:created、mounted、unmanned

生命周期配置的数据结构:

interface ILifecycle {
    created: () => void
    mounted: () => void
    unmount: () => void
}

子应用加载

确定了 微前端 的实现方案,那么还有一个核心的问题,那就是如何加载 子应用

这里我们使用一种叫做 HTML Entry 的方案,通过直接请求 子应用 入口的 index.html 文件,并且对文件进行解析后,将 HTML DOM 添加到指定的容器中,这样可以极大程度上减少 主应用 的接入成本,也可以避免过度对 子应用 进行侵入式的改造。

沙箱机制

想要在一个页面中运行多个 应用 ,就需要避免它们为了使用资源(window)而互相打架(冲突),所以就需要 沙箱机制 进行 环境隔离

这里我们会使用两种方案实现 沙箱机制

  • 快照 - 通过对原始 window 对象创建一个快照来保存原始状态,然后在子应用卸载的时候,使用快照还原 window 对象。
  • proxy - 通过 proxy 来代理 window 对象,子应用对 window 的操作将通过 proxy 的代理来进行处理,以此来保证 window 对象不会被污染

但毕竟都是通过 技术 手段实现的 沙箱机制 所以也不可避免会有一些 冲突 出现,无法真正意义上的完美隔离。

数据通讯

既然是多个 小型应用 组合成的一个 大型应用,那么 应用间的通讯 也是一个不可避免的问题。

那么 应用应用 之间该如何进行通讯呢?

没错,就是它 观察者 + 单例

功能实现

准备工作都就绪了,那么就开始紧张刺激的开发环节吧!

子应用注册

我们的 技术方案 已经明确了 子应用 如何进行 注册 ,以及注册 子应用 需要提供哪些数据,这里我们只需要将它进行实现即可。

这里强调一下子应用数据结构:

interface IApplication {
    name: string; // 应用名称
    container: string; // 加载到的容器
    entry: string; // 入口
    activePath: string; // 激活路径
}

子应用数据存储:

// 文件名:applications.js

let appList = []

export const setAppList = (apps) => { appList = apps }
export const getAppList = () => appList

子应用注册:

// 文件名:registerMicroApplications.js

import { setAppList } from '@/applications.js'

export default function registerMicroApplications (apps) {
    setAppList(apps)
}

路由分发

那么前端要怎么控制路由呢?

相信只要是用过 Vue、React、Angular 的对如何进行路由控制一定不会陌生,而且这也算是面试中很常见的一道面试题了。

常规的路由控制分为两种,一种是 hash 、 一种是 history ,两者都有各自的特点和使用场景。

hash 其实就是通过 # 拼接 path(路径) 的一种形式,总所周知 hash 的改变是不会引起浏览器发起请求的,但是它会触发 onhashchange 事件。

history 接口允许操作浏览器 曾经在标签页或者框架里访问 的会话 历史记录,利用 history 可以做到变更浏览器地址栏的 url 并且不会导致浏览器发起请求,当然由于路径不是真实存在所以需要服务端提供支持,服务端需要在任何情况下都返回 index.html ,否则使用 history 的应用只要一刷新就会出现 404 的错误,history 主要的 APIpushState(添加)replaceState(修改) 以及 onpopstate 事件。

那么,懂的都懂了把?

// 文件名:watchRouter.js

import lifecycle from '@/lifecycle.js'

// 匹配路由
const matchApp = () => {
    const { pathname, hash } = window.location
    const url = pathname + hash
    
    // 判断当前 路由 是否和已命中 子应用 激活路径 前缀相同,如果是则返回
    if (url.startsWith(window.__MICRO_ACTION_PATH__)) return
    
    // 路由变更 切换 环境变量
    window.__MICRO_PREV_ACTION_PATH__ = window.__MICRO_ACTION_PATH__
    
    // 执行生命周期
    lifecycle()
}

// 简单来说就是做了个包装,会额外执行一个 自定义 事件
const wrapper = (defaultFunction, customEventName) => {
    return function (...rest) {
        defaultFunction.apply(this, rest)
        window.dispatchEvent(new Event(customEventName))
    }
}

// 路由监听
const watchRouter = () => {
    window.history.pushState = wrapper(window.history.pushState, 'microPushState')
    window.history.replaceState = wrapper(window.history.replaceState, 'microReplaceState')
    
    // 触发事件后 执行路由匹配机制
    window.addEventListener('popstate', matchApp)
    window.addEventListener('hashchange', matchApp)
    window.addEventListener('microPushState', matchApp)
    window.addEventListener('microReplaceState', matchApp)
}

生命周期

生命周期 可以分为两种,一种是 全局生命周期 、一种是 应用私有生命周期

这里强调一下生命周期配置的数据结构:

interface ILifecycle {
    created: () => void
    mounted: () => void
    unmount: () => void
}

全局生命周期

其实全局生命周期的配置和子应用注册差不太多。

全局生命周期配置存储:

// 文件名:globalLifecycle.js

let globalLifecycle = {}

export const setGlobalLifecycle = (data) => { globalLifecycle = data }
export const getGlobalLifecycle = () => globalLifecycle

全局生命周期配置:

// 文件名:registerMicroLifecycle.js

import { setGlobalLifecycle } from '@/globalLifecycle.js'

export default function registerMicroLifecycle (lifecycle) {
    setGlobalLifecycle(lifecycle)
}

子应用私有生命周期以及生命周期执行时机

子应用的私有生命周期主要通过执行了 应用加载 后,从返回的对象中进行收集,也可以在应用注册的时候直接把生命周期注册进去。

再就是生命周期的执行时机,生命周期的执行时机在 路由分发 中的代码有注释说明,当路由变更的时候出发 路由匹配 机制,当 匹配到了新的子应用 后,就会执行 旧子应用的卸载 ,以及 新的子应用创建和挂载

子应用私有生命周期注册:

// 某个子应用的 main/index (入口)文件
// 这里还需要再 webpack 的打包配置文件导出后的 模块命名 ,需要和子应用名称一致

import { createApp } from 'vue'
import App from './app.vue'

export const create = () => {
    createApp(App).mount('#subApp')
}

export const mounted = () => {
    ...
}

export const unmount = () => {
    ...
}

子应用私有声明周期获取和声明周期执行:

// 文件名:lifecycle.js

import { getGlobalLifecycle } from '@/globalLifecycle.js'
import filterAppList from '@/getCurrentAppInfo.js'

// 运行生命周期钩子
const runLifecycleHooks (type, app) => {
    const globalLifecycle = getGlobalLifecycle()
    app && app[type] && app[type]()
    globalLifecycle[type] && globalLifecycle[type]()
}

// 执行生命周期
export const lifecycle = async () => {
    const prevApp = filterAppList('activePath', window.__MICRO_PREV_ACTION_PATH__)
    const nextApp = filterAppList('activePath', window.__MICRO_ACTION_PATH__)
    
    // 即将加载的应用不存在,保存当前状态,不做任何操作
    if (!nextApp) return 
    
    // nextApp 存在,则先执行 prevApp 的 卸载 钩子
    if (prevApp) await runLifecycleHooks('unmount', prevApp)
    
    // 加载子应用
    await loadApp(nextApp)
    
    // 执行 nextApp 的 创建 钩子
    await runLifecycleHooks('created', nextApp)
    
    // 执行 nextApp 的挂载钩子
    await runLifecycleHooks('mounted', nextApp)
}

应用加载 HTML Entry

在加载 子应用 之前,先要获取到,当前需要加载的 子应用数据 ;

有了 子应用数据 之后,才能进行 子应用加载 的步骤,否则巧妇难为无米之炊不是。

获取当前命中的子应用数据:

// 文件名:getCurrentAppInfo.js

import { getAppList } from '@/applications.js'

// 根据指定的 key 和 value 过滤已注册的子应用列表
export const filterAppList = (key, value) => {
    const appList = getAppList()
    const filter = appList.filter(item => item[key].startsWith(value))
    
    if (filter.length) return filter[0]
    
    return null
}

// 获取当前(匹配中的)子应用信息
export default function getCurrentAppInfo () {
    const appList = getAppList()
    if (!appList.length) throw Error('请注册子应用!')
    
    // 获取当前 路径
    const pathname = window.location.pathname 
    // 从已注册的子应用列表中找出激活路径吻合的应用
    const app = filterAppList('activePath', pathname) 
    
    // 有匹配中的应用
    if (app) {
        const hash = window.location.hash
        window.history.pushState({}, null, pathname + hash) // 添加历史记录
        window.__MICRO_ACTION_PATH__ = app.activePath // 环境变量变更
    }
}

加载 HTML 文件并进行解析:

// 文件名:loader.js

import { ProxySandBox } from '@/sandbox.js'

// 请求方法
const request = (url) => {
    return fetch(url).then(res => res.text())
}

// 递归元素,对命中的元素执行 回调 ,并且执行替换
const deepElement = ($element, tagName, callback) => {
    const $children = $element.children
    const $parent = $element.parent
    
    // 是否命中
    if ($element.nodeName === tagName) {
        callback($element) // 执行回调
        
        // 替换标签
        if ($parent) {
            const $comment = document.createComment(`当前标签${tagName}已被替换!`)
            $parent.replaceChild($comment, $element)
        }
    }
    
    // 递归
    if ($children && $children.length) {
        for (let i = 0, l = $children.length; i < l; i++) {
            deepElement($children[i], tagName, callback)
        }
    }
}

// 解析资源
const parseResources = async (root, entry) => {
    const dom = root.outerHTML
    const jsUrl = new Set()
    const jsCode = new Set()
    
    // 递归 root ,并且收集 script 标签的 src 或 代码块
    deepElement(root, 'SCRIPT', ($script) => {
        const src = $script.getAttribute('src')
        if (src) {
            src.startsWith('http') ? jsUrl.add(src) : jsUrl.add(entry + src)
        } else {
            jsCode.add($script.outerHTML)
        }
    })
    
    // 递归 root ,并且收集 link 标签中的 js 文件
    deepElement(root, 'LINK', ($link) => {
        const href = $link.getAttribute('href')
        if (href.endsWith('.js')) {
            href.startsWith('http') ? jsUrl.add(href) : jsUrl.add(entry + href)
        }
    })
    
    // 返回 dom 以及 js 文件路径 和 代码块
    return { dom, jsUrl: [...jsUrl], jsCode: [...jsCode] }
}

// 解析 html
const parseHtml = async (entry) => {
    // 请求 子应用 的入口文件,即常规的 index.html 
    const html = await request(entry)
    
    // 创建一个 div 的代码片段,并将 子应用 的 html 字符串填充进去
    const $div = document.createElement('div')
    $div.innerHTML = html
    
    // 解析资源
    const { dom, jsUrl, jsCode } = await parseResources($div, entry)
    
    // 获取 js 文件 
    const jsFile = await Promise.all(jsUrl.map(async url => request(url)))
    
    // 整合 js 资源
    const script = jsCode.concat(jsFile)
    
    // 返回
    return { dom, script }
}

// 执行 js
const performScript = (script, app, _window) => {
    // 拼接函数体,并且返回执行完后 以 子应用名称定义的对象(生命周期!)
    const fnBody = `
        try {
            ${script}
            return window['${app.name}']
        } catch (e) {
            console.error('run JavaScript error:', e)
        }
    `
    // 创建函数
    const fn = new Function(fnBody)
    
    // 执行并返回
    return fn.call(_window, _window)
}

// 运行 js
const runJavaScript = (script, app) => {
    // 创建沙箱
    const sandbox = new ProxySandBox()
    
    // 设置环境变量
    sandbox.proxy.__MICRO_APP__ = true
    
    // 循环执行所有的 js 代码
    script.forEact(item => {
        const subAppConfig = performScript(script, app, sandbox.proxy)
        
        // 私有生命周期注册
        if (subAppConfig) {
            subAppConfig.created && (app.created = subAppConfig.created)
            subAppConfig.mounted && (app.mounted = subAppConfig.mounted)
            subAppConfig.unmount && (app.unmount = subAppConfig.unmount)
        }
    })
}

// 加载应用
export const loadApp = async (app) => {
    const { container, entry } = app
    
    // 解析 html 文件
    const { dom, script } = await parseHtml(entry)
    
    // 获取容器
    const $container = document.querySelector(container)
    
    // 边界判断
    if (!$container) throw new Error('容器不存在!')
    
    // 加载 dom 元属
    $container.innerHTML = dom
    
    // 运行 js 
    await runJavaScript(script, app)
    
    return app
}

沙箱机制

沙箱是保证 子应用 之间独立运行时的基石。

Proxy

// 文件名:sandbox.js

class ProxySandBox () {
    constructor () {
        this.proxy = null
        this.active()
    }
    
    // 激活
    active () {
        const _window = window
        
        // 以 window.prototype 创建的对象
        const _currWindowInstance = Object.create(Object.getPrototypeOf(_window))
        
        // 代理
        this.proxy = new Proxy(_window, {
            get (target, propKey) {
                // 如果是方法的话特殊处理
                if (type _currWindowInstance[propKey] === 'function') {
                    return _currWindowInstance[propKey].bind(_window)
                }
                
                // 如果是方法的话特殊处理
                if (type target[propKey] === 'function') {
                    return target[propKey].bind(_window)
                }
                
                // 常规值
                return _currWindowInstance[propKey] || target[propKey]
            },
            set (target, propKey, value) {
                _currWindowInstance[propKey] = value
                return true
            }
        })
    }
    
    // 失活
    inactive () {
        this.proxy = null
    }
}

快照

// 文件名:sandbox.js

class SnapShotSandBox {
    constructor () {
        this.proxy = window
        this.active()
    }
    
    // 激活
    active () {
        this.snapshot = new Map()
        
        // 生成当前状态下的 window 的快照
        for (const key in window) {
            this.snapshot[key] = window[key]
        }
    }
    
    // 失活
    inactive () {
        // 根据快照还原 window
        for (const key in window) {
            if (window[key] !== this.snapshot[key]) {
                window[key] = this.snapshot[key]
            }
        }
    }
}

通讯机制

通讯也是微前端里的中药机制,是应用间的 连接管道,如果应用间 完全独立 的话,那还不如直接用 iframe 是把!

class Store {

    // 初始化
    constructor (initValue) {
        this._store = initValue
        this._observers = []
    }
    
    // 获取状态
    getStore () => this._store
    
    // 设置状态,并触发监听
    setStore (newValue) => {
        this._store = newValue
        this._observers.forEach(fn => fn(newValue))
    }
    
    // 监听 store 发生变化
    change (fn) {
        if (typeof fn !== 'function') return 
        this._observers.push(fn)
    }
}

总结

在第一次听到 微前端 的概念的时候,感觉非常新奇和高大上,但在真正了解微前端后才发现它距离我们这么近。

但是 微前端 这一大类概念的水还很深,还有很多针对其他不同场景的实现方案,以及还有 微组件 这种概念的存在,每当我们以为对一个领域有进一步的了解后,才会发现它后面还有更多的未知等着你去探索!

这篇文章陆陆续续的写了 十八天 是我写过时间最久,也最最最长的文章了,中途还想过要不要算了,不过最终还是坚持下来了。

如果文本对您有帮助,那么可以给咱一个赞吗?🥺

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿