从零开发——微前端框架实践

发表于 1年以前  | 总阅读数:565 次

阿里妹导读
我们对微前端框架的内容做了一个详细的介绍,并从零开始用Typescript实现了微前端的基本功能。

本文我们首先实现一个可进行子应用注册和资源加载的微前端框架,实现在一个vue3主应用中加载3个不同技术栈(vue2、react15、react16)的子应用,并且页面上渲染出各个子应用的内容;

然后,我们对该微前端框架实现扩展,实现

  • 运行环境隔离(沙箱)
  • css样式隔离
  • 应用间通讯(含父子通信、子应用间通信)
  • 全局状态管理(全局store的简单使用)
  • 利用应用缓存和预加载子应用提高加载性能

一、前置准备

再开发我们自己的微前端框架之前,我们需要做一定的架构设计准备。我们考虑微前端架构设计时的整体思路,并画出项目架构图。

1.1 微前端框架实现思路

  • 采用路由分发式(本文使用的是hash模式)
  • 主应用控制路由匹配和子应用加载,共享依赖加载
  • 子应用做功能,并接入主应用实现主子控制和联动

1.2 分析框架& 项目架构图

首先分析需求:

1 主应用功能

  • a.注册子应用;
  • b.加载、渲染子应用
  • c.路由匹配(activeWhen, rules- 由框架判断)
  • d.获取数据(公共依赖,通过数据做鉴权处理)
  • e.通信(父子通信、子父通信)

2.子应用功能:

  • a.渲- 染
  • b.监听通信(主应用传递过来的数据)

3.微前端框架功能

  • a.子应用的注册
  • b.有开始内容(应用加载完成)
  • c.路由更新判断
  • d.匹配对应的子应用
  • e.加载子应用的内容
  • f.完成所有依赖项的执行
  • g.将子应用渲染在固定的容器内
  • h.公共事件的管理i.异常的捕获和报错
  • j.全局的状态管理的内容
  • k.沙箱的隔离
  • l.通信机制

4.服务端的功能:提供数据服务

整体项目架构图如下:

二、开发微前端框架

本节我们开始开发主子应用并且实现微前端框架的基础功能

首先,我们实现主子应用的开发:按照我们的实际项目需求进行子应用的搭建和改造工作,每种子应用改造的方式大同小异;并且开发主应用,主应用起着整体的调度工作,按照对应的路由匹配规则渲染对应的子应用

然后我们实现微前端框架的基础功能,包括:应用注册、路由拦截、主应用生命周期添加、微前端生命周期添加、加载和解析html及js、渲染、执行脚本文件等内容。

2.1 准备子应用

技术选型

本文采用Vue3技术栈开发主应用,并准备了3个不同技术栈的子应用:

子应用

  • vue2子应用(实现home主页)
  • React15子应用(博客页)
  • React16子应用(照片页)

项目目录结构

注:在开发微前端框架前,我们首先需要准备3个子应用,子应用的具体实现并非重点,完整代码可见:blog-website-mircroFE-demo:https://code.alibaba-inc.com/sunxiaochun.sxc/blog-website-mircroFE-demo

项目目录结构如下:

.
├── main 主应用
    ├── micro 微前端框架
├── vue2 子应用
├── react15 子应用
├── react16 子应用
└── README.md

子应用改造接入微前端

当我们有了几个子应用后,需要对其进行一些改造,从而使其能接入微前端。

对于Vue2/3子应用,为其添加vue.config.js,配置关键点:设置devServer里的contentBase和headers允许跨域和output

// vue2/vue.config.js
const packageName = 'vue2';
const port = 9004;// 设置端口号
module.exports = {
  outputDir: 'dist', // 打包的目录
  assetsDir: 'static', // 打包的静态资源
  filenameHashing: true, // 打包出来的文件,会带有hash信息
  publicPath: 'http://localhost:9004',
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    hot: false,
    disableHostCheck: true,
    port,
    headers: {
      'Access-Control-Allow-Origin': '*', // 本地服务的跨域内容
    },
  },
  // 自定义webpack配置
  configureWebpack: {
    output: {
      library: `${packageName}`,//设置包名,从而可以通过window.vue2获得子应用内容
      libraryTarget: 'umd',
    },
  },
};

对于react15/16子应用,改造webpack.config.js,注意修改webpack.config.js里的output和devServer

// react16/webpack.config.js
const path = require('path')

module.exports = {
  entry: {
    path: ['./index.js']
  },
  // +++
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'react15.js',
    library: 'react15',
    libraryTarget: 'umd',
    umdNamedDefine: true,
    publicPath: 'http://localhost:9002/'
  },
  devServer: {
    // 子应用配置本地允许跨域
    headers: { 'Access-Control-Allow-Origin': '*' },
    contentBase: path.join(__dirname, 'dist'),
    compress: true,
    port: 9002,
    historyApiFallback: true,
    hot: true,
  }
}

设置启动脚本

当新建好3个子应用后,我们的目录结构是这样的

.
├── build 全局启动脚本
├── main 主应用
├── vue2 子应用
├── react15 子应用

├── react16 子应用
├── package.json 定义start启动脚本
└── README.md

以启动react16子应用为例,cd react16 &&yarn start 只能启动单个react子应用,我们需要配置一个命令来一次性启动所有子项目:

// package.json

"scripts": {
  // 在根目录的package.json配置start命令
  "start": "node ./build/run.js"
}
// build/run.js
const childProcess = require('child_process')
const path = require('path');

const filePath = {
  vue2:path.join(__dirname,'../vue2'),
  react15:path.join(__dirname,'../react15'),
  react16:path.join(__dirname,'../react16'),
}
// cd 子应用目录;npm start启动项目
function runChild(){
  Object.values(filePath).forEach(item =>{
    childProcess.spawn(`cd ${item} && yarn start`,{stdio:"inherit",shell:true});
  })
}
runChild();

这样,在根目录下执行yarn start即可一次性启动3个子应用

2.2 主应用开发

主应用负责所有子应用的卸载、更新和加载整个流程,主应用是链接子应用和微前端框架的工具。

构建主应用基本页面

主应用需要负责整体页面布局,使用vue3开发主应用main,其主体框架如下:

<!--main/src/App.vue -->
<template>
  <MainNav />
  <div class="sub-container">
    <Loading v-show="loading" />
    <div v-show="!loading" id="micro-container">子应用内容</div>
  </div>
  <Footer />
</template>

可以看到,主应用在页面中设置了一个子应用的容器<div v-show="!loading" id="micro-container">子应用内容</div>,在这个区域中,我们展示不同的子应用内容。

在主应用中进行子应用注册

有了主应用之后,首先需求在主应用中注册子应用的信息。需要存储的子应用信息:

  • name:子应用唯一id
  • activeRule:激活状态(即在哪些路由下渲染该子应用)
  • container: 渲染容器(子应用要放在哪个容器里显示)
  • entry 子应用的资源入口:(从哪里获取子应用的文件)

通过主应用注册子应用:设置一个subNavList存储子应用信息的注册信息,本文的3个子应用的注册信息如下


// main/store/sub.ts
export interface IAppDTO {
  name:string;
  activeRule:string;
  container: string;
  entry:string;
}
export const subNavList: IAppDTO[] = [
  {
    name:'react15',
    activeRule:'/react15',
    container:'#micro-container',
    entry:'//localhost:9002/',
  },
  {
    name:'react16',
    activeRule:'/react16',
    container:'#micro-container',
    entry:'//localhost:9003/',
  },
  {
    name:'vue2',
    activeRule:'/vue2',
    container:'#micro-container',
    entry:'//localhost:9004/',
  },
]

然后编写微前端框架中注册子应用的方法,将子应用注册进微前端框架(这里为了简单直接挂载到window)里


// main/micro/start.ts
/** 微前端框架提供的方法 */
export const registerMicroApps = (appList:any[]) => {
  (window as any).appList = appList;
}

最后在主应用中注册子应用

// main/src/main.js
import { subNavList} from'./store/sub'
import { registerApp } from './util'
/** 注册4个子应用,即只需要将传入的subNavList进行保存即可 */
registerApp(subNavList)

2.3 实现路由拦截

有了子应用列表后,我们需要启动微前端,以便来渲染对应的子应用,本节实现的是:主应用控制路由匹配和子应用加载

路由拦截方法

下面我们编写微前端中路由拦截方法,实现思路:

  • 监听路由切换 & 重写路由切换事件pushState和replaceState
  • 并且监听浏览器的前进/后退按钮window.onpopstate

// main/micro/router/rewriteRouter

// 给当前的路由跳转打补丁 globalEvent 原生事件 eventName 自定义事件名称
export const patchRouter = (globalEvent,eventName) => {
  return function (){  
    // 1.创建新事件
    const e = new Event(eventName);
    // 2.原生事件代码函数执行
    globalEvent.apply(this,arguments);//this指向globalEvent
    // 3.触发刚创建好的事件
    window.dispatchEvent(e);
  }
}

export const turnApp = ()=>{
  console.log("路由切换了");
}

// 重写window的路由跳转
export const rewriteRouter = ()=>{

  window.history.pushState = patchRouter(window.history.pushState,'micro_push');
  window.history.replaceState = patchRouter(window.history.replaceState,'micro_replace');

  window.addEventListener('micro_push',turnApp),
  window.addEventListener('micro_replace',turnApp)

  // 监听返回事件
  window.onpopstate = function(){
    turnApp();//路由切换防范
  }
}

在启动微前端框架时调用我们重写的路由监听方法

// main/micro/start
import {rewriteRouter} from './router/rewriteRouter';
rewriteRouter();// 实现路由拦截

根据路由查找子应用

子应用注册时保存了子应用列表的信息,对于当前路由,需要找到其对应的子应用。如下。我们编写一个根据当前路由获取子应用的通用方法


// main/micro/utils/index.js

/**
 * 根据当前路由获取子应用(利用activeRule判断)
 */
export const currentApp = ()=>{
  const currentUrl = window.location.pathname;
  return filterApp('activeRule',currentUrl)
}
/**
 * 查找子应用的函数
 */
const filterApp = (key,value)=>{
  const currentApp = getList().filter(item=> item[key] === value);
  return currentApp && currentApp.length ? currentApp[0] : {};
}

然后,我们编写启动微前端框架的路由查找方法,实现当进入项目,对于首个展示的子应用,先验证当前子应用列表是否为空,不为空根据route匹配找到当前对应的子应用,如果子应用存在,则跳转到子应用对应的url。


// main/micro/start.ts
import { getList, setList } from "./const/subApps";
import {rewriteRouter} from './router/rewriteRouter';
const { currentApp}  = require('./utils/index.js')
export const start = ()=>{
  // 1.验证当前子应用列表是否为空
  const apps = getList();
  // 子应用列表为空
  if(!apps.length){
    throw Error('子应用列表为空,请正确注册')
  }
  // 有子应用的内容,查找到符合当前路由的子应用
  const app = currentApp();
  if(app){    
    const {pathname,hash} = window.location;
    const url = pathname+hash
    window.history.pushState('','',url);
  }
  (window as any).__CURRENT_SUB_APP__ = app.activeRule;
}

2.4 生命周期

实现路由拦截后,对于如何挂载和卸载该路由下的子应用,就需要去实现一套生命周期。

子应用生命周期

子应用通常有下面三个生命周期:

1.bootstrap:开始加载应用,用于配置子应用的全局信息等

2.mount:应用进行挂载,用来渲染子应用

3.unmount:应用进行卸载,用于销毁子应用

这是一个协议接入,只要子应用实现了boostrap、mount和unmount这三个生命周期狗子,有这三个函数导出,我们的框架就可以知道如何加载这个子应用。


// /vu2/main.js
let instance = null;
const render = () => {
  //Vue2子应用本身创建实例new Vue()
  // 在微前端框架中,这个实例的执行与销毁,都应该交给主应用去执行
  instance = new Vue({
    router,
    render: h => h(App)
  }).$mount('#app-vue')
}
// 如果不在微前端环境下,直接执行
if(!window.__MICRO_WEB__){
  render();
}
// 以vue2子应用为例,定义其生命周期的内容,这里我们只是简单的打印一些内容
export const bootstrap = () => {
  console.log("vue2子应用开始加载");
}
export const mount = () =>{
  render();
  console.log("vue2子应用渲染成功");
}
export const unmount = () =>{
  console.log('vue2子应用卸载',instance);
}

主应用生命周期

主应用的生命周期主要有三个:

1.beforeLoad:挂载子应用前,开始加载

2.mounted 挂载子应用后,渲染完成,

3.destoryed 卸载子应用卸载完成,

我们改写微前端框架提供的方法,加入生命周期的内容


// main/micro/start.ts
export interface ILifeCycle {
  beforeLoad?: any;
  mounted?: any;
  destroyed?: any;
}
export const registerMicroApps = (appList:any[],lifeCycle:ILifeCycle) => {
  // 1.设置子应用列表
  setList(appList)
  // 2. 生命周期
  lifeCycle.beforeLoad[0]();
  setTimeout(()=>{
    lifeCycle.mounted[0]();
  },2000)
  setMainLifeCycle(lifeCycle);
}

在主应用中利用微前端提供的注册微应用的方法registerMicroApps中设置主应用的生命周期,并通过主应用生命周期去控制子应用的内容显示

// main/src/utils/index.ts
import { registerMicroApps, start } from "../../micro"
import { loading } from '../store'
/**
 * 注册主应用
 * @param list 子应用列表
 */
export const registerApp = (list: any[]) =>{
  // 注册到微前端框架里
  registerMicroApps(list,{
    beforeLoad:[
      ()=> {
        loading.changeLoading(true);
        console.log("开始加载");
      }
    ],
    mounted:[
      ()=>{
        loading.changeLoading(false);
        console.log('渲染完成');
      }
    ],
    destroy:[
      ()=>{
        console.log('卸载完成');
      }
    ]
  });

  // 启动微前端框架
  start();
}

微前端框架生命周期

微前端的生命周期:如果路由变化时,对子应用进行对应的销毁和加载操作

首先,编写微前端框架监听子应用是否做了切换的方法isTurnChild、以及根据路由获取子应用的方法findAppByRoute

// micro/utils/index.js
/**
 * 子应用是否做了切换 
 * */
export const isTurnChild = () =>{
  const {pathname} = window.location;
  let prefix = pathname.match(/(\/\w+)/)
  if (prefix) {
    prefix = prefix[0]
  }
  // 上一个子应用
  window.__ORIGIN_APP__ = window.__CURRENT_SUB_APP__;
  if(window.__CURRENT_SUB_APP__ === prefix){
    return false;
  }
  const currentApp = window.location.pathname.match(/(\/\w+)/);
  if(!currentApp){
    return;
  }
  // 当前子应用
  window.__CURRENT_SUB_APP__ = currentApp[0] ;
  return true
}
/**
 * 根据路由获取子应用
 */
export const findAppByRoute = (router)=>{
  return filterApp('activeRule',router)
}

其次,编写微前端框架的生命周期lifeCycle方法实现思路是:微前端挂载到主应用,执行注册函数时,传入的就是主应用的生命周期,遍历执行这个主应用的所有生命周期,微前端框架就可以实现将子应用注册到主应用。

// main/micro/lifeCycle/index.ts
import { findAppByRoute } from "../utils";
import { getMainLifeCycle } from '../const/mainLifeCycle'
import {IAppDTO} from '../../src/interface/IAppDTO'
import { loadHtml } from "../loader";
export const lifecycle = async ()=>{
  // 1.获取到上一个子应用
  const prevApp = findAppByRoute((window as any).__ORIGIN_APP__);
  // 2.获取到要跳转的子应用
  const nextApp = findAppByRoute((window as any).__CURRENT_SUB_APP__)
  // 没有下一个子应用,后续方法无需执行
  if(!nextApp){
    return;
  }
  // 如果有下一个子应用,卸载上一个子应用
  if(prevApp && prevApp.unmount){
    if(prevApp.proxy){
      prevApp.proxy.inactive();//将沙箱销毁
    }
    await destroyed(prevApp);
  }
  // 加载下一个子应用
  const app = await beforeLoad(nextApp);
  // 渲染下一个子应用
  await mounted(app);
}

// 微前端-加载子应用
export const beforeLoad = async (app:IAppDTO) =>{
  await runMainLifeCycle('beforeLoad')
  app && app.bootstrap && app.bootstrap();
  const subApp = await loadHtml(app) // 获取的子应用的内容
  subApp && subApp.bootstrap && subApp.bootstrap();
  return subApp;
}
// 微前端-渲染子应用
export const mounted = async (app:IAppDTO)=>{
  app && app.mount && app.mount({
    appInfo:app.appInfo,
    entry:app.entry
  });
  await runMainLifeCycle('mounted');
}

// 微前端-卸载子应用
export const destroyed = async(app:IAppDTO)=>{
  app && app.unmount && app.unmount();
  await runMainLifeCycle('destroyed')
}
// 对应的执行主应用的生命周期
export const runMainLifeCycle = async(type:string) => {
  const mainlife = getMainLifeCycle();
  // 等待所有生命周期执行完成
  await Promise.all(mainlife[type].map(async item => await item()));
}

这样,当微前端框架监听到对应的子应用切换时,就执行微前端的生命周期


// micro/router/routerHandle
const {isTurnChild} = require('../utils/index.js')
const { lifecycle } =require('../lifeCycle')
export const turnApp = ()=>{
  if(isTurnChild()){
    // 路由切换的时候,微前端的生命周期执行
    lifecycle();
  }
}

最终效果:当我们切换路由时(例如从vue2子应用切换到react15子应用时),控制台打印如下:

开始加载
vue2子应用开始加载
vue2渲染成功
渲染完成
vue2卸载
卸载完成
开始加载
react15开始加载
react15渲染成功
渲染完成

2.5 获取需要展示的页面

为了展示子应用页面,首先需要做的:主应用获取子应用的生命周期,结构,方法,文件等。这样主应用才能控制子应用的渲染和加载。

主应用的生命周期有三个:beforeLoad开始加载、mounted 渲染完成、destoryed 卸载完成。实际上,我们在主应用生命周期beforeLoad中去获取页面内容(加载资源):

  • 子应用显示页面本质上是通过get请求**(network中的doc)去获取页面信息**,由此我们就可以在微前端框架中通过get请求去获取到子应用对应的页面,根据url通过fetch拿到页面内容,最后再将内容赋值给对应的容器就可以显示子应用对应的页面了,
  • 但是直接赋值给容器,容器是没法解析html中的标签,对于link和script(src,js代码)元素,需要专门提取出来这些元素进行处理

加载和解析html

因为所有的网站都是以HTML作为入口文件的,这份HTML中实际已经包含了子应用的所有信息(网页结构、js与css资源等),在微前端框架中,可以通过找到HTML中的静态资源并加载,从而渲染出对应的子应用。


// micro/loader/index.js
import { IAppDTO } from "../../src/interface/IAppDTO";
// 发送请求,调用原生fetch获取页面html的内容
export const fetchResource = (url:string) =>{
  // 会触发跨越限制,所以要对子应用的配置文件进行改造(见2.1.2节)
  return fetch(url).then(async res =>
    await res.text()
  )
}
// 加载html的方法(核心思路:根据子应用入口,找到其对应的html,并渲染至对应容器)
export const loadHtml = async (app:IAppDTO) =>{
  // 1.子应用需要显示在哪里
  const container = app.container; //#id内容
  // 2.子应用的入口
  const entry = app.entry;
  // 3. 获取html
  const html = await parseHtml(entry)
  //4. 渲染至对应容器
  const ct = document.querySelector(container);
  if(!ct){
    throw new Error('容器不存在,请查看');
  }
  ct.innerHTML = html;
  return app;
}
// 解析HTML的内容
export const parseHtml = async (entry:string) =>{
  const html = await fetchResource(entry);
  const div = document.createElement('div');
  div.innerHTML = html;
  return html
}

有了加载和解析子应用的HTML的方法,就需要在加载子应用的生命周期中进行调用


// micro/lifeCycle/index.js
// 微前端-加载子应用
export const beforeLoad = async (app:IAppDTO) =>{
  await runMainLifeCycle('beforeLoad')
  app && app.bootstrap && app.bootstrap();
  const subApp = await loadHtml(app) // 获取的子应用的内容并渲染至容器中
  subApp && subApp.bootstrap && subApp.bootstrap();
  return subApp;
}

加载和执行js

上面我们只加载了HTML资源,但实际上子应用除了dom资源外,还有script资源也需加载。

实现思路:在getResource方法中我们递归寻找元素,将 link、script元素找出来并做对应的解析处理即可。对于dom资源,直接进行渲染,对于script资源有两种情况:

1.内部script脚本2.外部scriptUrl链接资源

我们分别进行处理

// micro/loader/index.js
/**
 * 解析子应用所有的资源
 * @param entry 子应用入口
 * @returns 返回子应用所有的dom和script资源
 */
export const parseHtml = async (entry:string) =>{
  const html = await fetchResource(entry);
  let allScript = [];
  const div = document.createElement('div');
  div.innerHTML = html;
  //html中包含标签、link、script
  const [dom,scriptUrl,script] = await getResource(div,entry);
  const fetchedScripts = await Promise.all(scriptUrl.map(async item => fetchResource(item)));
  allScript = script.concat(fetchedScripts);
  return [dom,allScript]
}
/**
 * 获取对应的子应用的资源
 * @param root 容器
 * @param entry 入口
 * @returns dom:子应用html scriptUrl:外链js  script:js脚本
 */
export const getResource = async(root:any,entry:string)=>{
  const scriptUrl:any[] = [];
  const script:any[] = [];
  const dom = root.outerHTML;
  // 深度解析
  function deepParse(element:any){
    const children = element.children;
    const parent = element.parent;
    // 1. 处理script中的内容
    if(element.nodeName.toLowerCase() === 'script'){
      const src = element.getAttribute('src');
      if(!src){
        script.push(element.outerHTML);// script没有src属性,即没有外链其他的js资源,直接在script中书写的内容
      }else{
        if(src.startsWith('http')){
          scriptUrl.push(src);
        }else{
          scriptUrl.push(`http:${entry}/${src}`)
        }
      }
      if(parent){
        parent.replaceChild(document.createComment('此js文件已经被微前端替换'),element)
      }
    }
    // link中也会有js的内容
    if(element.nodeName.toLowerCase() === 'link'){
      const href = element.getAttribute('href');
      if(href.endsWith('.js')){
        if(href.startsWith('http')){
          scriptUrl.push(href);
        }else{
          scriptUrl.push(`http:${entry}/${href}`)
        }
      }
    }
    for(let i=0;i<children.length;i++){
      deepParse(children[i]);
    }
  }
  deepParse(root)
  return [dom,scriptUrl,script]
}

结果如下:

congratulations!

到现在我们就实现了在一个vue3主应用中加载3个不同技术栈(vue2、react15、react16)的子应用,并且成功在页面上展示了出来!

三、微前端框架-辅助功能

上文我们已经解决了微前端中应用的加载和切换,本节我们给微前端添加其他辅助功能,例如:预加载、应用通讯、全局store等功能

3.1 运行环境隔离 - 沙箱

为了避免应用间发生冲突,不同子应用之间的运行环境应该进行隔离,防止全局变量的互相污染。沙箱有两种实现:快照沙箱 & 代理沙箱

快照沙箱

快照沙箱就是在应用沙箱挂载和卸载的时候记录快照,在应用切换时依据快照来恢复环境。

具体实现思路是:记录当前执行的子应用的变量,当子应用切换的时候,将变量置为初始值。


// main/micro/sandbox/snapShotSandbox
// 快照沙箱,应用场景:比较老版本的浏览器
export class SnapShotSandbox {
  constructor(){
    // 1. 代理对象
    this.proxy = window;
    this.active();
  }
  // 沙箱激活
  active(){
    // 创建一个沙箱快照,用for循环window,new map一个新对象作为快照对象
    this.snapshot = new Map();
    // 遍历全局环境
    for(const key in window){
      this.snapshot[key] = window[key];
    }
  }
  // 沙箱销毁
  inactive(){
    for(const key in window){
      if(window[key]!== this.snapshot[key]){
        // 还原操作
        window[key] = this.snapshot[key];
      }
    }
  }
}

代理沙箱

代理沙箱是利用ES6的proxy去代理window,监听window的改变,是更现代化的沙箱方法。

具体实现思路:设置一个空对象defaultValue去储存子应用的变量

  • 当window改变时,将改变值以key,value的形式存储到defaultValue中;当需要获取window属性值的时候,也在代理的get中去返回defaultValue对应的值
  • 沙箱销毁的时候inactive也只需要将defaultValue置为{}
// main/micro/sandbox/proxySandbox.js
// 代理沙箱
let defaultValue = {} // 子应用的沙箱容器
export class ProxySandbox{
  constructor(){
    this.proxy = null;
    this.active();
  }
  // 沙箱激活
  active(){
    //子应用需要设置属性
    this.proxy = new Proxy(window,{
      get(target, key) {
        if (typeof target[key] === 'function') {
          return target[key].bind(target)
        }
        return defaultValue[key] || target[key]
      },
      set(target,key,value){
        defaultValue[key]=value;
        return true;
      }
    })
  }
  // 沙箱销毁
  inactive(){
    defaultValue = {};
  }
}

3.2 CSS样式隔离

利用沙箱解决了JS之间的副作用冲突,接下来我们需要解决CSS之间的冲突,为CSS做样式隔离。

常用样式隔离方法有:

  1. css modules:利用webpack配置打包机制进行css模块化处理,通过编译生成不冲突的选择器名

  2. shadow dom :创建个新的元素进行包裹,但语法较新,兼容性较差,设置shadow dom的步骤:

    1. 设置mode 利用attachShadow得到shadow
    2. 为shadow dom添加内容
  3. minicss (本框架选用):一个webpack插件,该插件将css打包成单独的文件,然后页面通过link进行引用

module: {
    rules: [
      {
        test: /\.(cs|scs)s$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']
      },
    ]
  },
  1. csss in js:将应用的css样式写在javaScript文件里,最终生成不冲突的选择器。

3.3 应用间通信

对于应用通讯,有两种实现方式:props和customevent

基于Props的方式

这里有一个父子通信的实例场景:子应用动态控制主应用nav的显示与隐藏。如下,在react16子应用中编写了login登陆页面,在该页面中不应显示导航条nav

实现如下:首先,子应用需要一个控制主应用中nav显示和隐藏的方法。所以我们在主应用的store中维护一个navStatus属性,表示导航条nav是否显示和隐藏至,暴露一个修改该navStatus属性的方法changeNav


// main/src/store/nav.ts
import { ref } from 'vue';
// 控制导航nav显示的状态navStatus
export const navStatus = ref(true);
// 更改navStatus显示的方法
export const changeNav = (type:boolean) => navStatus.value = type;

将该状态绑定至主应用导航条中,

然后,将主应用的所有store内容(包含navStatus)用Props的方式传递给所有子应用。


// 将主应用的store用props的方式传递给子应用
import * as appInfo from '../store/index';
export const subNavList: IAppDTO[] = [
  {
    name:'vue2',
    activeRule:'/vue2',
    container:'#micro-container',
    entry:'//localhost:9004/',
    appInfo
  },
  {
    name:'react15',
    activeRule:'/react15',
    container:'#micro-container',
    entry:'//localhost:9002/',
    appInfo,
  },
  {
    name:'react16',
    activeRule:'/react16',
    container:'#micro-container',
    entry:'//localhost:9003/',
    appInfo
  },
]

修改子应用的mounted生命周期方法,注册子应用时将含有navStatus属性的appInfo传递给子应用

// main/micro/lifeCycle/index.ts
// 微前端-渲染子应用
export const mounted = async (app:IAppDTO)=>{
  app && app.mount && app.mount({
    // ++ 给子应用传递appInfo
    appInfo:app.appInfo,
    // ++
    entry:app.entry
  });
  await runMainLifeCycle('mounted');
}

最后,以react16子应用为例,在react16子应用中拿到主应用传递来的信息,并在子应用的渲染中隐藏nav。

// react16/index.js
export const mount = (app) =>{
  // ++ 利用主应用传递过来的方法changeNav来隐藏nav
  app.appInfo.header.changeNav(false);
  // +++
  render();
  console.log("react16渲染成功");
}

上述是一个主子应用通信的实例,对于子应用之间的通讯,也可以利用基于props的方式:子应用1 跟父应用交互,父应用再传递给子应用2。

基于CustomerEvent的方式

另一种应用间通讯的模型是挂一个事件总线,应用之间不直接相互交互,都去一个事件总线上注册和监听事件。实现思路:通过new CustomEvent对象去监听事件(on)和触发事件(emit),如下:

首先,利用customEvent创建Custom类

// main/micro/customevent/index.js
export class Custom{
  // 事件监听
  on(name,cb){
    window.addEventListener(name,(e)=>{
      cb(e.detail)
    })
  }
  // 事件触发
  emit(name,data){
    const event = new CustomEvent(name,{
      detail:data
    });
    window.dispatchEvent(event);
  }
}

有了Custom类后, 我们在微前端框架中创建一个custom对象,为其添加事件监听方法test,并将其挂载到全局window上

// main/micro/start.ts

const { Custom } = require('./customevent/index.js');

const custom = new Custom();
custom.on('test',(data:any)=>{
  console.log("监听到的数据:",data);
});

window.custom = custom;

这样,在子应用里,我们就可以通过window.custom来获取custom对象。3.4 全局状态管理 - 全局store

建立微前端中的全局状态管理基于发布订阅模式,通过主应用监听某个方法、子应用添加订阅者来监听到一些全局状态的改变。

具体实现思路是:利用store保存数据,observer管理订阅者(subscribeStore方法用于添加订阅者),并提供获取和更新store的getStore和updateStore的方法。

// main/micro/store/index.ts
export const creatStore = (initData:{} = {}) => (() => {
  // 利用闭包去保存传参的初始数据
  let store = initData;
  // 管理所有的订阅者,依赖
  const observers = [];
  // 获取store
  const getStore = () => {
    return store;
  }
  // 更新store
  const updateStore = (newValue) => new Promise((res) => {
    if (newValue !== store) {
      // 执行保存store的操作
      let oldValue = store;
      // 将store更新
      store = newValue;
      res(store);
      // 通知所有的订阅者,监听store的变化
      observers.forEach(fn => fn(newValue, oldValue));
    }
  })
  // 添加订阅者,fn为方法
  const subscribeStore = (fn) => {
    observers.push(fn);
  }
  // 整个store本质是一个闭包函数,把方法return出去
  return { getStore, updateStore, subscribeStore }
})()

有了全局状态,如何在主应用中引入即可。若要在子应用中也使用store,将store挂载到window上,子应用直接访问window.store即可,使用全局变量也是应用通讯的一种方式。

3.5 提高加载性能

应用缓存

当前,当切换不同的子应用时,都会重新加载页面,若页面资源非常多,加载会比较缓慢,所以需要在微前端框架中对应用进行缓存,提升加载性能。

应用缓存的实现思路是:

  • 定义一个cache对象,根据子应用的appName来做缓存
  • 如果当前子应用的html已经解析并且加载过,就返回已经加载过的内容。如果没有,则走正常加载和解析的流程

//main/micro/loader/index.ts
const cache = {};// 根据子应用的name做缓存
/**
 * 得到子应用所有的资源
 * @param entry 子应用入口 name:appName
 */
export const parseHtml = async (entry:string,name:string) =>{
  // 如果命中应用缓存,直接返回缓存内容,没有则继续进行资源解析加载流程
  if(cache[name]){
    return cache[name];
  }
  const html = await fetchResource(entry);
  let allScript = [];
  const div = document.createElement('div');
  div.innerHTML = html;
  const [dom,scriptUrl,script] = await getResource(div,entry);
  const fetchedScripts = await Promise.all(scriptUrl.map(async item => fetchResource(item)));
  allScript = script.concat(fetchedScripts);
  // 将该子应用的资源保存至缓存对象cache中
  cache[name] = [dom,script];
  return [dom,allScript]
}

预加载子应用

提升加载性能的另一个思路是预加载子应用,即在启动微前端框架时,先获取当前要加载的子应用,再预加载剩下的所有的子应用。


// main/micro/loader/prefetch.ts
import { getList } from "micro/const/subApps";
import { parseHtml } from './index'
export const prefetch = async() => {
  // 1. 获取到所有子应用列表 - 不包含当前正在显示的
  const list = getList().filter(item => window.location.pathname.startsWith(item.activeRule));
  // 2. 预加载剩下的子应用
  Promise.all(list.map(async item => await parseHtml(item.entry,item.name)));
}

这样,在做子应用切换时,避免一定的延迟感~

四、写在最后

在之前的内容中,我们对微前端框架的内容做了一个详细的介绍,并从零开始用Typescript实现了微前端的基本功能,对这样的一个简易微前端框架,仍有可以扩展和继续学习的地方。例如

  • 如何实现微前端框架的自动发布?如何通过npm来发布我们编写的框架?能否创建一个自动部署平台来实现应用的自动化部署?
  • 现有热门的微前端框架(qiankun、single-spa、icestark)是如何实现微前端框架的应用注册&加载、沙箱隔离、全局状态管理、预加载这些功能?

本文由哈喽比特于1年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/YOr1OnCEk8-XssrTyeM6Pw

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
为Electron程序添加运行时日志 5年以前  |  20342次阅读
Node.js下通过配置host访问URL 5年以前  |  5893次阅读
用 esbuild 让你的构建压缩性能翻倍 4年以前  |  5785次阅读
 目录