微前端实现--下

171 阅读3分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第3天,点击查看活动详情。 前言: Iframe?Single-Spa?QianKun? 微前端很多,技术栈百花齐放,我们自己实现一波?

    继上篇文章讲了,微前端的rollup搭建环境、导出导入相关的register和start和rerouter方法、生命
    周期等,这节主要讲如何实现注册预加载应用。

一、 新建src/lifecycles/load.js

import { LOADING_SOURCE_CODE, NOT_BOOTSTRAPPED } from "../applications/app.helpers";

// 传入的启动方法有可能是个数组,需要组装成一个大的Promise对象, 就是redux中源码的compose写法
function flattenFnArray (fns) { 
  fns = Array.isArray(fns) ? fns : [fns]
  return (props) => fns.reduce((p,fn) => p.then(() => fn(props)), Promise.resolve())
}

export async function toLoadPromise (app) { 
  app.status = LOADING_SOURCE_CODE
  let { bootstrap, mount, unmount } = await app.loadApp(app.customProps)
  app.status = NOT_BOOTSTRAPPED
  app.bootstrap = flattenFnArray(bootstrap) 
  app.mount = flattenFnArray(mount) 
  app.unmount = flattenFnArray(unmount) 
  return app
}

1.1 reroute.js中的loadApps方法去加载应用

 // 预加载应用
  async function loadApps () {  
    let apps =  await Promise.all(appsToLoad.map(toLoadPromise))// bootstrap, mount, unmount 方法放在App上
    console.log(apps,'apps');
  }

1.2 尝试解析index.html中的注册应用的实例

<!DOCTYPE html>
<html lang="en">
  <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" />
    <title>Document</title>
  </head>
  <body>
    <script src="/lib/umd/single-spa.js"></script>
    <script>
      // 参数1 注册应用的名字 参数2 加载app的方法必须返回一个pormise方法
      singleSpa.registerApplication(
        'app1',
        async props => {
          // 这个函数需要返回结果
          return {
            bootstrap: [async props => {}, async props => {}],
            mount: async props => {},
            unmount: async props => {},
          }
        },
        location => location.hash.startsWith('#/app1'),
        { store: { name: 'zf', age: 10 } }
      )

      // 启动这个应用
      singleSpa.start()
    </script>
  </body>
</html>

image.png 注册应用成功

二、load和mount应用

2.1 reroute.js加载应用方法


  // 根据路径来装载应用
  async function performaAppChanges () { 
      // 先卸载不需要的应用
    let unmountPoromises = appsTouUmount.map(toUnmountPromise)
    // 去加载需要的应用
    appsToLoad.map(async app => { 
      app = await toLoadPromise(app)
      app = await  toBootstrapPromise(app)
      return await  toMountPromise(app)
    })
    // 去挂载应用
    appsToMount.map(async app => { 
      app = await  toBootstrapPromise(app)
      return await  toMountPromise(app)
    })
  }  

2.2 src/lifecycles/bootstrap.js

import { BOOTSTRAPPING, NOT_BOOTSTRAPPED, NOT_MOUNTED } from "../applications/app.helpers";


export async function toBootstrapPromise (app) { 
  if (app.status !== NOT_BOOTSTRAPPED) { 
    return app
  }
  app.status = BOOTSTRAPPING
  await app.bootstrap(app.customProps)
  app.status = NOT_MOUNTED
  return app
}

2.3 src/lifecycles/mounted.js

import {  MOUNTED, MOUNTING, NOT_MOUNTED } from "../applications/app.helpers";


export async function toMountPromise (app) { 
  if (app.status !== NOT_MOUNTED) { 
    return app
  }
  app.status = MOUNTING
  await app.mount(app.customProps)
  app.status = MOUNTED
  return app
}
  

2.4 src/lifecycles/unmount.js

import { MOUNTED, NOT_MOUNTED, UNMOUNTING } from "../applications/app.helpers";

export async function toUnmountPromise (app) { 
  // 当前应用没有被挂载直接什么都不做
  if (app.status != MOUNTED) { 
    return app
  }
  app.status = UNMOUNTING
  await app.unmount(app.customProps)
  app.status = NOT_MOUNTED
  return app
}

尝试启动, bootstrap和mount的执行了

image.png

2.5 问题与思考

我们在index.html中打印load

<script>
      // 参数1 注册应用的名字 参数2 加载app的方法必须返回一个pormise方法
      singleSpa.registerApplication(
        'app1',
        async props => {
          console.log('load');
          // 这个函数需要返回结果
          return {
            bootstrap: async props => { console.log('bootstrap1')},
            mount: async props => {console.log('mount');},
            unmount: async props => {console.log('unmount');},
          }
        },
        location => location.hash.startsWith('#/app1'),
        { store: { name: 'zf', age: 10 } }
      )

      // 启动这个应用
      singleSpa.start()
    </script>

如图,是执行了两次

image.png

2.6 优化和解决执行两次的问题

改造load.js

export async function toLoadPromise (app) { 
  if (app.loadPromise) { 
    return app.loadPromise // 缓存机制
  }

  return (app.loadPromise = Promise.resolve().then(async () => { 
    app.status = LOADING_SOURCE_CODE
    let { bootstrap, mount, unmount } = await app.loadApp(app.customProps)
    // console.log(bootstrap,'bootstrap999');
    app.status = NOT_BOOTSTRAPPED
    app.bootstrap = flattenFnArray(bootstrap) 
    app.mount = flattenFnArray(mount) 
    app.unmount = flattenFnArray(unmount) 
    return app
  }))

 
}

load现在就只有一次了

image.png

2.7 尝试注册两个应用

<script>
      // 参数1 注册应用的名字 参数2 加载app的方法必须返回一个pormise方法
      singleSpa.registerApplication(
        'app1',
        async props => {
          console.log('load');
          // 这个函数需要返回结果
          return {
            bootstrap: async props => { console.log('bootstrap1')},
            mount: async props => {console.log('mount');},
            unmount: async props => {console.log('unmount');},
          }
        },
        location => location.hash.startsWith('#/app1'),
        { store: { name: 'zf', age: 10 } }
      )
       // 参数1 注册应用的名字 参数2 加载app的方法必须返回一个pormise方法
       singleSpa.registerApplication(
        'app2',
        async props => {
          console.log('load2');
          // 这个函数需要返回结果
          return {
            bootstrap: async props => { console.log('bootstrap2')},
            mount: async props => {console.log('mount2');},
            unmount: async props => {console.log('unmount2');},
          }
        },
        location => location.hash.startsWith('#/app2'),
        { store: { name: 'zf2', age: 20 } }
      )

      // 启动这个应用
      singleSpa.start()
    </script>

image.png

    问题和思考: 应用二确实注册成功了,但是只有重新刷新页面才会成功,路由切换并不会成功

三、重写路由

路由改变主要是hashchange和popstate,我们基于这个来重写 新建/src/navigations/navigator-evetns

// hashchange popstate
import { reroute } from './reroute'

export const routingEventsListeningTo = ['hashchange', 'popstate']

function urlReroute () { 
  reroute([], arguments)
}

const capturedEventListeners = {
  hashchange: [],
  popstate: []
}


window.addEventListener('hashchange', urlReroute)
window.addEventListener('popstate', urlReroute)

const originalAddEventListener = window.addEventListener
const originalRemoveEventListener = window.removeEventListener

window.addEventListener = function (eventName, fun) {
  if (routingEventsListeningTo.indexOf(eventName) >= 0 && !capturedEventListeners[eventName].some(listener => listener == fn)) { 
    capturedEventListeners[eventName].push(fn)
  }
  return originalAddEventListener.apply(this,arguments)
}

window.removeEventListener = function (eventName, fn) { 
  if (routingEventsListeningTo.indexOf(eventName) >= 0) { 
    capturedEventListeners[eventName] = capturedEventListeners[eventName].filter(l => l !== fn)
    return originalRemoveEventListener.apply(this,arguments)
  }
}

// 用户可能还会绑定自己的路由事件  vue


// 当我们应用切换后, 还需要处理原来的方法, 需要在应用切换后在执行

  • index.html
<div>
      <a href="#/app1">应用1</a>
      <a href="#/app2">应用2</a>
    </div>
  • reroute.js
import './navigator-events'
现在点击切换路由也可重写初始化微应用

smud1-if2gi.gif

3.2 兼容h5路由


// 如果是hash路由 hash变化时可以切换
// 浏览器路由,浏览器路由是h5api的 如果切换时不会触发popstate

function patchedUpdateState (updateState, methodName) { 
  return function () { 
    const urlBefore = window.location.href
    updateState.apply(this, arguments)
    const urlAfter = window.location.href

    if (urlBefore !== urlAfter) { 
      // 重新加载应用 传入事件源
      urlReroute(new PopStateEvent('popstate'))
    }
  }
}

window.history.pushState = patchedUpdateState(window.history.pushState, 'pushState')
window.history.replaceState = patchedUpdateState(window.history.replaceState, 'replaceState')

index.html

   <div>
      <a onclick="app1()">应用1</a>
      <a onclick="app2()">应用2</a>
    </div>
    
     function app1() {
        history.pushState({},'','/app1')
      }
      function app2() {
        history.pushState({},'','/app2')
      }

image.png