为了验证某些事,我实现了一个toy微前端框架万字长文,请君一览

Posted 恪愚

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了为了验证某些事,我实现了一个toy微前端框架万字长文,请君一览相关的知识,希望对你有一定的参考价值。

众所周知微前端是企业中前端发展的必经之路。

我司搭建了自己的微前端脚手架工具,它将项目变成了“多页应用”,跳转方式由 location.href 这类api进行。所以笔者之前在想:这种方式跳转能不能有动画效果呢?
在“上层建筑”中进行“反直觉”的操作,结果当然是失败的。但是笔者又有了一个新想法:自己实现一个微前端框架,由通过劫持路由和 history 实现一些小操作!

本文将我实现第一版微前端架子的步骤呈现给各位,希望对大家能有一些帮助。


结构

这是toy微前端的项目基本结构,简单介绍下主要目录文件:

  • 控制启动的build目录
  • 项目所需后端service目录(由node实现)
  • 主应用main目录
  • 自应用vue2目录(存放vue2项目代码)
  • 自应用vue3目录(存放vue3项目代码)
  • 框架启动和版本控制文件package.json

在vue2、vue3同级,你当然还可以创建react相关目录结构或者使用其他技术栈实现相关项目。这是不限制的,也是微前端的优势。

拿vue来说,你必然知道的是:项目启动是和 package.json 息息相关的,我们来看本框架的此文件:


  "name": "toy-micro-web",
  "version": "1.0.0",
  "description": "create micro project for myself",
  "main": "index.js",
  "scripts": 
    "start": "node ./build/run.js"
  ,
  "author": "yancy",
  "license": "ISC",
  "devDependencies": ,
  "dependencies": 

没有多余代码,也不必有。其中关键在于:scripts 字段。这是我们命令行的执行命令。
它的意思是:当我们按下 npm start(或 npm run start)时,会通过node去执行 ./build/run.js 文件。

启动你的微前端

这就是上面我们说的启动目录下的唯一一个文件。它要做的也很简单 —— 进入项目对应目录,执行他们各自的启动命令。

const childProcess = require('child_process')
const path = require('path')

const filePath = 
    vue2: path.join(__dirname, '../vue2'),
    vue3: path.join(__dirname, '../vue3'),
    service: path.join(__dirname, '../service'), //启动后端应用node
    // react15: path.join(__dirname, '../react15'),
    // react16: path.join(__dirname, '../react16'),
    main: path.join(__dirname, '../main')


// cd 子应用的目录 npm start 启动项目
function runChild () 
    Object.values(filePath).forEach(item => 
      childProcess.spawn(`cd $item && npm start`,  stdio: "inherit", shell: true )
    )


runChild()

我们借助了 child_process 模块的帮助。

这里是qiankun的思想,一次将所有子应用全部启动。本来想按照qiankun的思路去实现,但是写的时候突然想到这就是一个为了我研究其他东西的小玩具,那么精细干啥。后面第二版第三版也有修改但是代码量就多了不放出来了先。
各位在用新技术尝试的时候也可以把这里按照自己想法改进下。

子应用:vue2

执行完这个文件后,命令行中你会发现依次进入到主应用以及各个子应用中了。我们先来看子应用:以vue2为例

这就是一个普通的vue2项目目录,将其置身于“微前端”场景下时,我们需要着重关注 vue.config.js 以及 src 下的 main.js 文件。

在 vue.config.js 中笔者进行了端口号的重置、指定打包路径、热更新的开启、本地服务的跨域内容等等。最重要的还得是“自定义webpack配置项”了:

// 自定义webpack配置
configureWebpack: 
  resolve: 
    alias: 
      '@': resolve('src'),
    ,
  ,
  output: 
    // 把子应用打包成 umd 库格式 commonjs 浏览器,node环境
    libraryTarget: 'umd',
    library: `$packageName`, //在全局环境下获取到打包的内容 ——umd,把这一行去掉与显示在浏览器控制台打印 window.此vue项目文件夹名
    // filename: 'vue2.js', //打包的名字
    // jsonpFunction: `webpackJsonp_$packageName`
  ,
,

我们通过 libraryTarget 字段指定打包格式为 umd!并且通过 library 字段指定了打包出来的名字。

这里是关键,也是微前端中很多操作的根本!其原因随后陈述。

先来看 main.js 文件。都知道这里面主要干了一件事:new Vue

new Vue(
  router,
  render: h => h(App)
).$mount('#app-vue');

这是一般的写法。但是现在是微前端!在子应用加载前会不会要处理必要的参数?在加载时是不是可能要进行一些额外的处理?切换其他应用后要不要取消监听?…

子应用微前端场景改造

所以我们应当区分情况,并暴露出三个函数:

let instance = null;

const render = () => 
  // 这个函数就可以在微前端框架中应用,我们也可以通过window.vue2获取到内容
  instance = new Vue(
    router,
    render: h => h(App)
  ).$mount('#app-vue');


// 通过render触发,但是有一个前提:如果当前是微前端环境则不自己触发,而是需要根据我们的微前端生命周期去对应的触发当前的render函数
if (!window.__MICRO_WEB__) 
  render()


// 微前端下
export const bootstrap = () => 
  // 开始加载结构 - 比如一些在加载之前必要的参数处理
  console.log('开始加载')

export const mount = () => 
  render();
  // 然后得到vue实例
  console.log('渲染成功')

export const unmount = () => 
  // 比如撤销监听事件,或者处理当前容器的显示内容,,,
  console.log('卸载', instance)

现在,让我们执行 npm start,看一看 umd 的“魅力”:

而若是将 libraryTarget: 'umd', 这一行去掉,则会打印undefined。说白了就是把子应用打包好作为节点挂载道window上供全局调用。这也就是其原因所在了。

很明显就是依赖webpack的打包特性和 umd 模块的特殊性。
其实微前端并不是一个新的技术,只是新的概念罢了。早期的“服务端组合SSI技术”、以及后来重新被大家认识的“iframe”、还有新兴的“web components技术”,不同于微前端发展初期概念里的“页面级别组合”,他们甚至可以进行组件级别的组合。

这里和另一个技术也“异曲同工” —— webpack5 联邦模块!

const  ModuleFederationPlugin  = require("webpack").container;

//...
  plugins: [
    new ModuleFederationPlugin(
      // MF 应用名称
      name: "app1",
      library:  type: "var", name: "app_1" ,
      // MF 模块入口,可以理解为该应用的资源清单
      filename: `remoteEntry.js`,
      // 定义应用导出哪些模块
      exposes: 
        "./utils": "./src/utils",
        "./foo": "./src/foo",
      ,
    ),
  ],

这段代码就是联邦模块导出的配置。其中libraryname字段即是 umd 导出的name!

主应用:main

OK,让我们说回toy微前端。
子应用部分已经结束,接下来该“重中之重”,主应用的介绍。主应用担负起了调配子应用、路由拦截、全局通信、应用启动等诸多功能。
我们来看下主应用的结构:

主应用是以vue3实现的,其中micro目录是重点 —— 微前端控制器。
让我来一一介绍。

首先依然是 main.js文件,这里稍有不同:主应用是微前端主体,它当然也要有生命周期,用来和子应用生命周期“遥相呼应”,但正因为是主体所以自己调用自己不太合适,故而主应用的生命周期不能以export的方式写在这里!
上面提到主应用应该肩负起调配子应用的职责,故而我们应当在主应用初始化前注册子应用:

import  createApp  from 'vue'
import App from './App.vue'
import router from './router'
import  subNavList  from "./store/sub"
import  registerApp  from "./util"

// 在整个实例初始化之前先将子应用注册好
registerApp(subNavList)

createApp(App).use(router()).mount('#micro_web_main_app')

subNavList很简单,就是子应用的一些信息:路径、名字、需要挂载的节点等,后面我们要根据这些信息处理子应用

export const subNavList = [
    
      name: 'vue2',
      activeRule: '/vue2',
      container: '#micro-container',
      entry: '//localhost:9004/',
    ,
    
      name: 'vue3',
      activeRule: '/vue3',
      container: '#micro-container',
      entry: '//localhost:9005/',
    ,
]

然后registerApp用来将这些子应用注册到微前端中,并且在这个时候注册主应用的生命周期!

import  registerMicroApps, start  from "../../micro"

import  loading  from "../store"

export const registerApp = (list) => 
    // 将子应用注册到主应用里是没有任何效果的,所以我们应该注册到微前端框架里
    // 主应用的生命周期也需要在此时注册好!
    registerMicroApps(list, 
        beforeLoad: [
            () => 
                // 在生命周期中控制loading
                loading.changeLoading(true)
                console.log('开始加载')
            
        ],
        mounted: [
            () => 
                loading.changeLoading(false)
                console.log('渲染完成')
            
        ],
        destoryed: [
            () => 
                console.log('卸载完成')
            
        ]
    )

    start()

我们通过参数的方式将生命周期传到处理函数中,这里需要注意的是,主应用的生命周期函数可能不止一个(要干的事情和时机不一样),所以我们用数组形式处理。

先来看registerMicroApps函数:

export const registerMicroApps = (appList, lifeCycle) => 
    setList(appList)

    setMainLifeCycle(lifeCycle)

很简答,首先接收子应用列表,然后处理子应用和对应生命周期。setList函数其实就是一个赋值函数,对应的还有一个getList取值函数,这是为了避免随意往window上挂载东西。
setMainLifeCycle也是如此,先将生命周期“存起来”。

什么时候用呢?上一段代码最后还有一个start函数,我们来看下:

export const start = () => 
    // 首先验证当前子应用列表是否为空
    let apps = getList();
    if(!apps.length) 
        console.error('当前没有子应用注册')
        return;
    
    // 查找到符合当前url的子应用
    let app = currentApp()

    if(app) 
        const  pathname, hash  = window.location
        const url = pathname + hash
        window.history.pushState('', '', url)
    

    // 这时候我们会发现路由被触发了不止一次,我们可以加一个限制
    window.__CURRENT_SUB_APP__ = app.activeRule

首先判断当前有没有子应用注册,若有,取到子应用并将路由赋值,而后将当前行为“告知”全局,我们用“公共变量”__CURRENT_SUB_APP__去接收,这一点是为了方便后面区分“上一个子应用”和“在一个子应用”。

currentApp中即是先通过window.location.pathname获取当前路由,然后在getList中查找:

const filterApp = (key, value) => 
    const currentApp = getList().filter(item => item[key] === value)

    return currentApp && currentApp.length ? currentApp[0] : 

路由监听

我们会发现上面的操作大多和“router”相关,这里就引出了微前端中另一个重点概念:路由监听。以及后期的“路由劫持”!

其实就是“重写路由跳转函数”,重写的原因也很简单:我们需要做一些符合自己需求的额外处理。

export const rewriteRouter = () => 
    window.history.pushState = patchRouter(window.history.pushState, 'micro_push')
    window.history.replaceState = patchRouter(window.history.replaceState, 'micro_repalce')

    // 给新事件加一个事件绑定
    window.addEventListener('micro_push', turnApp);
    window.addEventListener('micro_replace', turnApp);

    // 给返回事件也添加绑定
    window.onpopstate = function () 
        turnApp()
    

我们通过重写pushStatereplaceState绑定了两个新事件,在路由切换后去执行生命周期~

export const turnApp = async () => 
    if(!isTurnChild()) 
        return
    
    console.log('路由切换')
    // 执行微前端生命周期
    await lifeCycle()

其中判断子应用是否切换的函数isTurnChild就是利用了我们刚刚说到的挂载到window上的“公共变量”:

export const isTurnChild = () => 
    // 需要在判断之前先拿到全局变量,用两个全局变量获取上一个子应用和下一个子应用
    window.__ORIGIN_APP__ = window.__CURRENT_SUB_APP__
    if(window.__CURRENT_SUB_APP__ === window.location.pathname) 
        return false
    
    let currentApp = window.location.pathname.match(/(\\/\\w+)/)
    if(!currentApp) 
        return
    
    window.__CURRENT_SUB_APP__ = currentApp[0]
    return true

其中currentApp的判断是为了处理路由字符串,方便后面加载资源。

然后我们来看如何执行微前端生命周期lifeCycle

export const lifeCycle = async () => 
    // 获取到上一个子应用:执行他的unload。我们需要先将上一个子应用卸载掉
    const prevApp = findAppByRoute(window.__ORIGIN_APP__)

    // 获取到要跳转的子应用:执行他的生命周期
    const nextApp = findAppByRoute(window.__CURRENT_SUB_APP__)

    console.log(prevApp, nextApp)

    if(!nextApp) 
        return
    

    if(prevApp && prevApp.destoryed) 
        await destoryed(prevApp)
    

    const app = await beforeLoad(nextApp)

    await mounted(app)


// 作为微前端控制器,也需要有自己的生命周期函数
export const beforeLoad = async (app) => 
    await runMainLifeCycle('beforeLoad')
    app && app.beforeLoad && app.beforeLoad()

    // 获取到子应用所有的显示内容 —— 获取真正的子应用
    const appContext = await loadhtml(app)
    appContext && appContext.beforeLoad && appContext.beforeLoad()

    return appContext


export const mounted = async (app) => 
    app && app.mount && app.mount()
    await runMainLifeCycle('mounted')


export const destoryed = async (app) => 
    app && app.unmount && app.unmount()
    // 对应的执行一下主应用的生命周期
    await runMainLifeCycle('destoryed')


export const runMainLifeCycle = async (type)=> 
    const mainlife = getMainLifeCycle(); //get函数,获取到主应用生命周期数组

    // 等到所有的生命周期执行完毕才能下一步
    await Promise.all(mainlife[type].map(async item => await item()))

简单来讲就是一句话:先获取到上一个(也就是要跳出的)子应用,去执行它的卸载生命周期,然后获取到下一个(也就是即将跳转的)子应用,去执行它的“预挂载”生命周期,获取到子应用的内容!然后执行子应用的“挂载”生命周期。因为子应用的节点实际由主应用控制,所以在每次操作时都要去执行主应用对应的生命周期!

findAppByRoute和上面的currentApp作用一样,就是根据 router 获取到子应用信息。

这段代码里为什么充斥着async-await?除了在runMainLifeCycle中要等待主应用生命周期执行外还有一点:在mounted中加载子应用资源!
这也就到了第三个重点:资源的获取和处理。我们来看下。

dom获取、js代码获取&执行

export const loadHtml = async (app) => 
    // 子应用需要显示在哪里
    let container = app.container
    // 子应用的入口是啥
    let entry = app.entry

    const [dom, scripts] = await parseHtml(entry)

    console.log(scripts)

    const ct = document.querySelector(container)

    if(!ct) 
        console.error('容器不存在')
        return
    
    console.log(ct)
    ct.innerHTML = dom

    return app

在此函数中,我们的主要内容就是“下载”html、和链接的css、js资源。app参数就是获取到的子应用信息,前面提到的结构这里就派上用场了。
下载资源并不只是单纯的下载html就行了,headbody中的linkscripts标签也是我们需要关注的地方:我们需要将 js 内容也下载下来方便后面运行:

export const parseHtml = async (entry) => 
    // 资源加载其实是一个get请求,我们去模拟这个过程
    const html = await fetchResource(entry)

    let allScripts = []

    const div = document.createElement('div')
    div.innerHTML = html

    // 标签、link、script(src、代码)
    const [dom, scriptUrl, script] = await getResources(div, entry)

    // 获取所有的js资源
    const fetchedScripts = await Promise.all(scriptUrl.map(async item => await fetchResource(item)))
    allScripts = script.concat(fetchedScripts)
    return [dom, allScripts]

fetchResource就是fetch请求函数。不必细说。

笔者在getResources函数中进行了上述的处理。可以看到我将结构分为三类:domscript代码和script链接(然后根据连接请求资源得到代码合并进script中),为了归类我利用了递归扫描html代码。这里只放核心代码 —— 判断标签中有没有srchref属性,如果有,则说明是链接文件(被scriptUrl接收),这时还需要判断链接有没有前缀,因为有的链接是本地连接,如果直接请求资源则会“找不到资源路径”,需要处理下:

// 处理script
if(element.nodeName.toLowerCase() === 'script') 
    为了验证某些事,我实现了一个toy微前端框架万字长文,请君一览

头顶秃了,硬肝出百万字+千张图彻底吃透Spring Cloud微服务架构

万字长文史上最强csshtml总结~看完涨薪不再是梦

从零开始写一个微前端框架-沙箱篇

从零开始写一个微前端框架-沙箱篇

万字长言|你的新媒体运营,是时候升级操作系统了