学着写一个简版的微前端框架(1)

伴随单页面项目规模越来越大,维护成本升高,微前端是解决此问题的方案之一。因此微前端是我想学习的技术之一,也因此想深刻理解其原理,也就有了此文。

之前读过一篇掘金的文章,用来比较后端的微服务和前端的微前端:微服务与微前端:区别在哪里,我看完有些收获,思考后有一些认识。

  • 微服务是后端的,微服务的项目代码可以处于不同的物理机,不管是运行时还是非运行时,然后通过接口通信
  • 微前端是前端的,微前端(子应用)的项目代码也可以放置于不同的物理机或者虚拟机,但是它的运行时需要在浏览器环境下运行,且和主应用的运行时在一起
  • 微前端的运行环境是浏览器,主应用项目代码运行时资源也在浏览器,而子应用项目代码在远程,所以需要远程获取
  • 微前端(子应用)资源被拉到浏览器后,当子应用切换时,还需要将上一个子应用的资源清除掉,否则主应用就会越来越大

也正因此微前端实现必须要解决隔离问题,解决资源加载问题,解决资源更新的问题。

各个子应用调度则通过主应用或充当中介者的媒介(微前端框架)来实现。媒介通过监听路由变化,决定是否调用特定子应用。

本文作为学习微前端的第一篇文章,先实现第一个目标:开发一个微前端框架,具备监听路由变化的功能;之后根据路由加载相应子应用的资源,渲染该资源,应用该资源;路由切换后清除上一个子应用资源。

实现路由监听

实现路由调度需要对路由url变化做监听,包括做hash变化监听以及活动记录改变的监听,使用addEventListener监听,监听事件分别为hashchangepopstate

同时考虑到人为使用pushStatereplaceState方法改变浏览器活动记录的可能,需要对这两个方法做拦截,切面拦截。然后

js 复制代码
const originalPushState = history.pushState
const originalReplaceState = history.replaceState

export default function overwriteApiAndSubscribeEvent(callback) {
    history.pushState = function (state, title, url) {
        const result = originalPushState.call(history, state, title, url)
        callback && callback()
        return result
    }
    
    history.replaceState = function (state, title, url) {
        const result = originalReplaceState.call(history, state, title, url)
        callback && callback()
        return result
    }
    
    window.addEventListener('popstate', () => {
       callback && callback()
    }, true)
    
    window.addEventListener('hashchange', () => {
       callback && callback()
    }, true)
}

其中callback是回调函数。

微前端框架类实现

写一个微前端框架类,具备注册微前端(子应用)的功能,注册子应用方法名为registerApplication

javascript 复制代码
class MicroFrontendFramework {
  constructor() {
    this.apps = {}; // 所有子应用缓存
  }

  registerApplication(name, app) {
    this.apps[name] = app;
  }

}

上面name是子应用名称,且是唯一的,注册方法的app参数用于保存子应用的各种配置。比如匹配路径或叫激活规则activeRule

javascript 复制代码
const framework = new MicroFrontendFramework();

framework.registerApplication('app1', {
  activeRule: '/app1'  // 路径名称可能与name不一致所以需要这一项
});

framework.registerApplication('app2', {
  activeRule: '/app2' 
});

为了实现路径和子应用激活规则匹配,MicroFrontendFramework增加切换应用方法switchApp

javascript 复制代码
class MicroFrontendFramework {
  constructor() {
    this.apps = {}; // 所有子应用缓存
  }

  registerApplication(name, app) {
    this.apps[name] = app;
  }
  
  switchApp(){
    const { hash, pathname } = window.location;
    const location = hash ? hash.slice(1) : pathname;
    for (let name in this.apps) {
      const app = this.apps[name];
      if (location.startsWith(app.activeRule)) {
        // 加载资源
      }
    }
  }
}

当路径匹配时,选择加载对应子应用资源。

子应用资源加载

开头说了,子应用的资源都在远程,如何加载到浏览器?

可以将所有子应用资源包括style样式、JavaScript都打包成cdn,之后将这些资源地址都注册到微前端框架中。比如增加一个source的配置项

js 复制代码
microFramework.registerApp('app1', {
  activeRule: '/vue',
  source: {
    css: ['http://cdn1.com/css1.js'],
    js: ['http://cdn1.com/js1.js']
  }
});

但这样之后,各个子应用一旦重新打包,就需要更换资源地址,这种方式显然是不行的,这违背了开发自动化的原则。

本文采用直接获取子应用资源所在html文件的方式,通过读取html文件,获取资源链接,之后将资源注入到主应用中。

为了实现这个功能,注册子应用方法registerApplication增加子应用入口链接配置pageEntry

javascript 复制代码
const framework = new MicroFrontendFramework();

framework.registerApplication('app1', {
    activeRule: '/app1'  // 路径名称可能与name不一致所以需要这一项
    pageEntry: 'http://localhost:8002',
});

拉取 HTML 内容

MicroFrontendFramework增加loadHtml方法,用以拉取html内容,具体采用原生的xml请求,实际就是一个普通的get请求

javascript 复制代码
class MicroFrontendFramework {
  constructor() {
    this.apps = {}; // 所有子应用缓存
  }

  registerApplication(name, app) {
    this.apps[name] = app;
  }
  
  switchApp(){
    const { hash, pathname } = window.location;
    const location = hash ? hash.slice(1) : pathname;
    for (let name in this.apps) {
      const app = this.apps[name];
      if (location.startsWith(app.activeRule)) {
        this.loadHtml(app.pageEntry);
      }
    }
  }
   
  loadHtml(){
    var xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.onreadystatechange = function() {
      if (xhr.readyState == 4 && xhr.status == 200) {
        // 处理内容
      }
    }.bind(this);
    xhr.send();
  }
}

在获取html文件时,要保证子应用允许跨域,要对子应用配置做修改。我的子应用之一,vue2单页面项目配置文件vue.config.js修改

js 复制代码
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  lintOnSave: false,
  devServer: {
    client: {
      overlay: false,
    }
  },
  devServer: {
    port: 8001,
    headers: {
        'Access-Control-Allow-Origin': '*' // 允许跨域
    }
  },
  // css: {
  //   extract: true,
  // },
  publicPath: 'http://localhost:8001/',
})

子应用之二create-react-app创建的单页面react项目,安装@rescripts/cli,通过修改.rescriptsrc.js修改内置webpack配置

js 复制代码
module.exports = {
  webpack: config => {
    config.output.libraryTarget = 'umd';
    config.output.library = 'react-app';
    config.output.publicPath = '//localhost:8002/';

    return config;
  },
  devServer: (config) => {
    config.headers = {
      'Access-Control-Allow-Origin': '*', // 允许跨域
    }
    config.historyApiFallback = true;

    config.hot = false;
    config.watchContentBase = false;
    config.liveReload = false;
    return config;
  },
  // 其他配置...
};

挂载到指定div上

主应用不能和子应用共用一个挂载点,否则子应用会将主应用内容覆盖,所以需要指定子应用的挂载点,注册方法registerApplication增加挂载点配置项mountPoint

javascript 复制代码
const framework = new MicroFrontendFramework();

framework.registerApplication('app1', {
    activeRule: '/app1'  // 路径名称可能与name不一致所以需要这一项
    pageEntry: 'http://localhost:8002',
    mountPoint: 'app1' // 挂载点
});

处理HTML内容

xml获取到html文件后,需要对其内容进行处理。MicroFrontendFramework增加方法loadResources,用于处理内容

javascript 复制代码
class MicroFrontendFramework {
  constructor() {
    this.apps = {}; // 所有子应用缓存
  }

  registerApplication(name, app) {
    this.apps[name] = app;
  }
  
  switchApp(){
    const { hash, pathname } = window.location;
    const location = hash ? hash.slice(1) : pathname;
    for (let name in this.apps) {
      const app = this.apps[name];
      if (location.startsWith(app.path)) {
        this.loadHtml(app.htmlUrl);
      }
    }
  }
   
  loadHtml(){
    var xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.onreadystatechange = function() {
      if (xhr.readyState == 4 && xhr.status == 200) {
        this.loadResources(xhr.responseText);
      }
    }.bind(this);
    xhr.send();
  }
  
  loadResources(html) {
    // 将资源注入到主应用中
  }
}

内容处理具体逻辑:将所有style、link、script以及body标签内的内容注入到子应用中。

为此,首先将html变为document选择器可以应用的格式,这里使用DOMParser对象,之后选择挂载点

js 复制代码
    ...
    let parser = new DOMParser();
    let doc = parser.parseFromString(html, 'text/html');
    let mountPoint = document.getElementById(this.apps[this.currentApp].mountPoint);
    ...

在将子应用注入到主应用之前,考虑到挂载点存在上一个子应用的内容,且有可能不适合此刻显示出来,所以选择清空上一个子应用挂载点内容

js 复制代码
   // 清空挂载点
    while (mountPoint.firstChild) {
      mountPoint.removeChild(mountPoint.firstChild);
    }

然后,将html文件body标签内的非script标签内容注入到挂载点,将html文件link样式和style样式注入到主应用document.head内,将script资源注入到主应用document.body

注意:直接将资源代码注入到主应用是不会执行的,需要创建新标签linkstylescript,然后将资源写到新标签,之后再注入才会执行。我想这应该是是浏览器规则限制。

js 复制代码
loadResources(html) {
    let parser = new DOMParser();
    let doc = parser.parseFromString(html, 'text/html');
    let mountPoint = document.getElementById(this.apps[this.currentApp].mountPoint);
    
    // 清空挂载点
    while (mountPoint.firstChild) {
      mountPoint.removeChild(mountPoint.firstChild);
    }

    
    const fragmentForMountPoint = document.createDocumentFragment();
    const childNodes = Array.from(doc.body.childNodes)
    for (let i = 0, childNode; childNode = childNodes[i++];) {
      if (childNode.tagName !== 'SCRIPT') {
        // cloneNode如果传递给它的参数是 true,它还将递归复制当前节点的所有子孙节点。否则,它只复制当前节点
        fragmentForMountPoint.appendChild(childNode.cloneNode(true));
      }
    }
    mountPoint.appendChild(fragmentForMountPoint);

    appendStyle(doc.head, this.currentApp)
    appendLink(doc.head, this.currentApp)

    appendScript(doc.head, 'head',this.currentApp)
    appendScript(doc.body, 'body',this.currentApp)
}

appendStyleappendLinkappendScript方法如下,方法中采用document.createDocumentFragment()生成片段,片段先将多个link、style或script收集到一起,然后再appendChild到相应的位置。

这种方式能够减少与document的多次交互,从而提升性能

js 复制代码
export function appendScript(docTag,type, currentApp) {
    // 插入<head>中的<script>标签
    const fragmentForScript = document.createDocumentFragment();
    const scripts = Array.from(docTag.getElementsByTagName('script'));
    for (let i = 0, script; script = scripts[i++];) {
        let newScript = document.createElement('script');
        if (script.src) {
            newScript.src = script.src;
        } else {
            newScript.textContent = script.textContent;
        }
        newScript.dataset.app = currentApp;
        fragmentForScript.appendChild(newScript);
    }
    document[type].appendChild(fragmentForScript);
}

export function appendLink(docTag, currentApp) {
    const fragmentForLInk = document.createDocumentFragment();
    const links = Array.from(docTag.getElementsByTagName('link'));
    for (let i = 0, link; link = links[i++];) {
        let newLink = document.createElement('link');
        newLink.rel = link.rel;
        newLink.href = link.href;
        newLink.dataset.app = currentApp;
        fragmentForLInk.appendChild(newLink);
    }
    document.head.appendChild(fragmentForLInk);
}


export function appendStyle(docTag, currentApp) {
    const fragmentForStyle = document.createDocumentFragment();
    const styles = Array.from(docTag.getElementsByTagName('style'));
    for (let i = 0, style; style = styles[i++];) {
        let newStyle = document.createElement('style');
        newStyle.textContent = style.textContent;
        newLink.dataset.app = currentApp;
        fragmentForStyle.appendChild(newStyle);
    }
    document.head.appendChild(fragmentForStyle)
}

路由监听和子应用关联

到此为止,上面已经有了路由监听实现和微前端框架类实现。但二者没有关联起来。

此处将二者关联起来,MicroFrontendFramework增加静态方法start。微前端框架切换子应用的方法switchApp作为路由监听函数的回调函数,从而将路由监听和微前端框架关联

javascript 复制代码
import overwriteApiAndSubscribeEvent from './overwriteApiAndSubscribeEvent'
class MicroFrontendFramework {
  constructor() {
    this.apps = {}; // 所有子应用缓存
  }

  registerApplication(name, app) {
    this.apps[name] = app;
  }
  
  switchApp(){
    const { hash, pathname } = window.location;
    const location = hash ? hash.slice(1) : pathname;
    for (let name in this.apps) {
      const app = this.apps[name];
      if (location.startsWith(app.path)) {
        this.loadHtml(app.htmlUrl);
      }
    }
  }
  
  static start() {
    const instance = new MicroFrontendFramework();
     // 关联起来 
     overwriteApiAndSubscribeEvent(instance.switchApp.bind(instance))
    return instance;
  }
}

再之后,MicroFrontendFramework静态方法start返回MicroFrontendFramework实例,在主应用中引入该实例,之后只要注册子应用就可以了,我的主应用是vue2单页面项目,入口文件是main.js,如下使用MicroFrontendFramework

js 复制代码
import Vue from 'vue'
import App from './App.vue'
import router from './router'

import microFramework from './micro'

Vue.config.productionTip = false

function render(){
  new Vue({
    router,
    render: h => h(App),
  }).$mount('#main')
}

render()

// 注册子应用1
microFramework.registerApp('app1', {
  activeRule: '/vue',
  pageEntry: 'http://localhost:8001',
  mountPoint: 'app1'
});
// 注册子应用2
microFramework.registerApp('app2', {
  activeRule: '/react',
  pageEntry: 'http://localhost:8002',
  mountPoint: 'app2'
});

切换子应用时清除上一个子应用资源

现在路由监听有了,微前端框架也实现了。现在开始调试。

当子应用切换时,发现存在不再需要当前子应用的可能性,所以需要清空当前子应用资源。

这里选择在下一个应用挂载前清除上一个应用的资源,同时清空完毕时才选择加载新资源,这样做是为了避免当前子应用资源没有及时清除导致与下一个子应用资源冲突的问题。

增加清除方法unloadResources

js 复制代码
  unloadResources(name) {
    return new Promise((resolve, reject) => {
      const app = this.apps[name];
      const mountPoint = document.getElementById(app.mountPoint);

      // 清空mountPoint
      mountPoint.innerHTML = '';
      
      // 清空link
      unloadLinkAndStyle(name, 'link')
      // 清空style
      unloadLinkAndStyle(name, 'style')
      // 清空script1
      unloadScript(name, document.head)
      // 清空script2
      unloadScript(name, document.body)

      resolve();
    })
  }

unloadLinkAndStyleunloadScript函数,清除采用的方式是使用removeChild,原理是在资源注入到主应用时,注入到主应用的link、style、script标签都进行标记,使用data-app标记,标记为子应用项目名,清除时专门清除这些带有标记的资源

js 复制代码
export function unloadScript(name, docTag) {
    // 移除<body>和<head>中的<script>标签
    const bodyElements = Array.from(docTag.querySelectorAll('script[data-app="' + name + '"]'));
    for (let i = 0, script; script= bodyElements[i++];) {
        if (script.parentNode === docTag) {
            docTag.removeChild(script);
        }
    }
}

export function unloadLinkAndStyle(name, type) {
    // 移除<head>中的<style>和link标签
    const headStyleElements = Array.from(document.head.querySelectorAll(`${type}[data-app="${name}"]`));
    for (let i = 0, style; style= headStyleElements[i++];) {
        if (style.parentNode === document.head) {
            document.head.removeChild(style);
        }
    }
}

不重复加载子应用

继续调试发现,子应用一直在加载,只要路径变了就加载,即使是切换子应用的内部地址也在加载,这显然不行。

需要根据激活条件判断当前是否需要加载子应用资源,所以重新修改上面的切换子应用方法switchApp

  1. 查找匹配的子应用
  2. 找到了匹配的子应用,如果它不是当前的子应用,那么切换子应用
  3. 如果它是当前的子应用,那么不做任何操作
  4. 如果没有找到匹配的子应用,但是有当前的子应用,那么卸载当前的子应用
js 复制代码
  switchApp() {
    const {pathname} = window.location;
      // 查找匹配的子应用
    const appName = Object.keys(this.apps).find(name => pathname.startsWith(this.apps[name].activeRule));
    console.log(appName, 'appNameappNameappName')
    if (appName) {
      const app =  this.apps[appName]
      // 如果找到了匹配的子应用
      if (this.currentApp !== appName) {
        // 如果它不是当前的子应用,那么切换子应用
        if (this.currentApp) {
          this.unloadResources(this.currentApp).then(() => {
            this.loadHtml(app.pageEntry);
            this.currentApp = appName;
          });
        }
        this.loadHtml(app.pageEntry);
        this.currentApp = appName;
      }
      // 如果它是当前的子应用,那么不做任何操作
    } else if (this.currentApp) {
      // 如果没有找到匹配的子应用,但是有当前的子应用,那么卸载当前的子应用
      this.unloadResources(this.currentApp);
      this.currentApp = null;
    }
  }

清除子应用js生成的style

继续调试发现:子应用自己生成的style,不能被清除。这是因为没有被标记,这个我研究了一下,发现可以通过覆写document.head.appendChild的方法,拦截appendChild,判断注入的是否是style节点,如果是则给style添加data-app标记

这样就可以清除这部分样式了。覆写代码

js 复制代码
const originalAppendChild = document.head.appendChild;

export default function overwriteHeadAppendChild(callback) {
    // 重写appendChild方法
    document.head.appendChild = function (node) {
        // 如果是style节点,添加自定义属性

        if (node.tagName === 'STYLE') {
            const currentApp = callback && callback()
            node.dataset.app = currentApp;
        }

        // 调用原始的appendChild方法
        return originalAppendChild.call(this, node);
    };
}

在静态方法start中使用

js 复制代码
 ...
 getCurrentApp(){
    return this.currentApp
  }
  static start() {
    const instance = new MicroFrontendFramework();
    overwriteApiAndSubscribeEvent(instance.switchApp.bind(instance))
    overwriteHeadAppendChild(instance.getCurrentApp.bind(instance))
    return instance;
  }
  ...

测试效果

项目代码地址:github.com/zhensg123/r...

后期计划

到这里微前端框架类已经实现了子应用调度、资源加载和资源更新功能。有问题欢迎留言。

第二篇将学习实现简版微前端的剩余部分:子应用的生命周期函数、window隔离、元素隔离、样式隔离和添加子应用之间通信。

本文完。

参考文章

手把手教你写一个简易的微前端框架

相关推荐
蟾宫曲3 小时前
在 Vue3 项目中实现计时器组件的使用(Vite+Vue3+Node+npm+Element-plus,附测试代码)
前端·npm·vue3·vite·element-plus·计时器
秋雨凉人心3 小时前
简单发布一个npm包
前端·javascript·webpack·npm·node.js
liuxin334455663 小时前
学籍管理系统:实现教育管理现代化
java·开发语言·前端·数据库·安全
qq13267029403 小时前
运行Zr.Admin项目(前端)
前端·vue2·zradmin前端·zradmin vue·运行zradmin·vue2版本zradmin
魏时烟4 小时前
css文字折行以及双端对齐实现方式
前端·css
哥谭居民00015 小时前
将一个组件的propName属性与父组件中的variable变量进行双向绑定的vue3(组件传值)
javascript·vue.js·typescript·npm·node.js·css3
踢足球的,程序猿5 小时前
Android native+html5的混合开发
javascript
2401_882726485 小时前
低代码配置式组态软件-BY组态
前端·物联网·低代码·前端框架·编辑器·web
web130933203985 小时前
ctfshow-web入门-文件包含(web82-web86)条件竞争实现session会话文件包含
前端·github
胡西风_foxww5 小时前
【ES6复习笔记】迭代器(10)
前端·笔记·迭代器·es6·iterator