带你入门前端工程:微前端

可莉
• 阅读 1417

什么是微服务?先看看维基百科的定义:

微服务(英语:Microservices)是一种软件架构风格,它是以专注于单一责任与功能的小型功能区块 (Small Building Blocks) 为基础,利用模块化的方式组合出复杂的大型应用程序,各功能区块使用与语言无关 (Language-Independent/Language agnostic)的API集相互通信。

换句话说,就是将一个大型、复杂的应用分解成几个服务,每个服务就像是一个组件,组合起来一起构建成整个应用。

想象一下,一个上百个功能、数十万行代码的应用维护起来是个什么场景?

  1. 牵一发而动全身,仅仅修改一处代码,就需要重新部署整个应用。经常有“修改一分钟,编译半小时”的情况发生。
  2. 代码模块错综复杂,互相依赖。更改一处地方的代码,往往会影响到应用的其他功能。

如果使用微服务来重构整个应用有什么好处?

一个应用分解成多个服务,每个服务独自服务内部的功能。例如原来的应用有 abcd 四个页面,现在分解成两个服务,第一个服务有 ab 两个页面,第二个服务有 cd 两个页面,组合在一起就和原来的应用一样。

当应用其中一个服务出故障时,其他服务仍可以正常访问。例如第一个服务出故障了, ab 页面将无法访问,但 cd 页面仍能正常访问。

好处:不同的服务独立运行,服务与服务之间解耦。我们可以把服务理解成组件,就像本小书第 3 章《前端组件化》中所说的一样。每个服务可以独自管理,修改一个服务不影响整体应用的运行,只影响该服务提供的功能。

另外在开发时也可以快速的添加、删除功能。例如电商网站,在不同的节假日时推出的活动页面,活动过后马上就可以删掉。

难点:不容易确认服务的边界。当一个应用功能太多时,往往多个功能点之间的关联会比较深。因而就很难确定这一个功能应该归属于哪个服务。

PS:微前端就是微服务在前端的应用,也就是前端微服务。

微服务实践

现在我们将使用微前端框架 qiankun 来构建一个微前端应用。之所以选用 qiankun 框架,是因为它有以下几个优点:

  • 技术栈无关,任何技术栈的应用都能接入。
  • 样式隔离,子应用之间的样式互不干扰。
  • 子应用的 JavaScript 作用域互相隔离。
  • 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。

样式隔离

样式隔离的原理是:每次切换子应用时,都会加载该子应用对应的 css 文件。同时会把原先的子应用样式文件移除掉,这样就达到了样式隔离的效果。

我们可以自己模拟一下这个效果:

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <link rel="stylesheet" href="index.css">
<body>
    <div>移除样式文件后将不会变色</div>
</body>
</html>
/* index.css */
body {
    color: red;
}

带你入门前端工程:微前端

现在我们加一段 JavaScript 代码,在加载完样式文件后再将样式文件移除掉:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <link rel="stylesheet" href="index.css">
<body>
    <div>移除样式文件后将不会变色</div>
    <script>
        setTimeout(() => {
            const link = document.querySelector('link')
            link.parentNode.removeChild(link)
        }, 3000)
    </script>
</body>
</html>

这时再打开页面看一下,可以发现 3 秒后字体样式就没有了。

带你入门前端工程:微前端

JavaScript 作用域隔离

主应用在切换子应用之前会记录当前的全局状态,然后在切出子应用之后恢复全局状态。假设当前的全局状态如下所示:

const global = { a: 1 }

在进入子应用之后,无论全局状态如何变化,将来切出子应用时都会恢复到原先的全局状态:

// global
{ a: 1 }

官方还提供了一张图来帮助我们理解这个机制:

带你入门前端工程:微前端

好了,现在我们来创建一个微前端应用吧。这个微前端应用由三部分组成:

  • main:主应用,使用 vue-cli 创建。
  • vue:子应用,使用 vue-cli 创建。
  • react: 子应用,使用的 react 16 版本。

对应的目录如下:

-main
-vue
-react

创建主应用

我们使用 vue-cli 创建主应用(然后执行 npm i qiankun 安装 qiankun 框架):

vue create main

如果主应用只是起到一个基座的作用,即只用于切换子应用。那可以不需要安装 vue-router 和 vuex。

改造 App.vue 文件

主应用必须提供一个能够安装子应用的元素,所以我们需要将 App.vue 文件改造一下:

<template>
    <div class="mainapp">
        <!-- 标题栏 -->
        <header class="mainapp-header">
            <h1>QianKun</h1>
        </header>
        <div class="mainapp-main">
            <!-- 侧边栏 -->
            <ul class="mainapp-sidemenu">
                <li @click="push('/vue')">Vue</li>
                <li @click="push('/react')">React</li>
            </ul>
            <!-- 子应用  -->
            <main class="subapp-container">
                <h4 v-if="loading" class="subapp-loading">Loading...</h4>
                <div id="subapp-viewport"></div>
            </main>
        </div>
    </div>
</template>

<script>
export default {
    name: 'App',
    props: {
        loading: Boolean,
    },
    methods: {
        push(subapp) { history.pushState(null, subapp, subapp) }
    }
}
</script>

可以看到我们用于安装子应用的元素为 #subapp-viewport,另外还有切换子应用的功能:

<!-- 侧边栏 -->
<ul class="mainapp-sidemenu">
    <li @click="push('/vue')">Vue</li>
    <li @click="push('/react')">React</li>
</ul>

带你入门前端工程:微前端

改造 main.js

根据 qiankun 文档说明,需要使用 registerMicroApps()start() 方法注册子应用及启动主应用:

import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
  {
    name: 'react app', // app name registered
    entry: '//localhost:7100',
    container: '#yourContainer',
    activeRule: '/yourActiveRule',
  },
  {
    name: 'vue app',
    entry: { scripts: ['//localhost:7100/main.js'] },
    container: '#yourContainer2',
    activeRule: '/yourActiveRule2',
  },
]);
start();

所以现在需要将 main.js 文件改造一下:

import Vue from 'vue'
import App from './App'
import { registerMicroApps, runAfterFirstMounted, setDefaultMountApp, start, initGlobalState } from 'qiankun'

let app = null

function render({ loading }) {
    if (!app) {
        app = new Vue({
            el: '#app',
            data() {
                return {
                    loading,
                }
            },
            render(h) {
                return h(App, {
                    props: {
                        loading: this.loading
                    }
                })
            }
        });
    } else {
        app.loading = loading
    }
}

/**
 * Step1 初始化应用(可选)
 */
render({ loading: true })

const loader = (loading) => render({ loading })

/**
 * Step2 注册子应用
 */

registerMicroApps(
    [
        {
            name: 'vue', // 子应用名称
            entry: '//localhost:8001', // 子应用入口地址
            container: '#subapp-viewport',
            loader,
            activeRule: '/vue', // 子应用触发路由
        },
        {
            name: 'react',
            entry: '//localhost:8002',
            container: '#subapp-viewport',
            loader,
            activeRule: '/react',
        },
    ],
    // 子应用生命周期事件
    {
        beforeLoad: [
            app => {
                console.log('[LifeCycle] before load %c%s', 'color: green', app.name)
            },
        ],
        beforeMount: [
            app => {
                console.log('[LifeCycle] before mount %c%s', 'color: green', app.name)
            },
        ],
        afterUnmount: [
            app => {
                console.log('[LifeCycle] after unmount %c%s', 'color: green', app.name)
            },
        ],
    },
)

// 定义全局状态,可以在主应用、子应用中使用
const { onGlobalStateChange, setGlobalState } = initGlobalState({
    user: 'qiankun',
})

// 监听全局状态变化
onGlobalStateChange((value, prev) => console.log('[onGlobalStateChange - master]:', value, prev))

// 设置全局状态
setGlobalState({
    ignore: 'master',
    user: {
        name: 'master',
    },
})

/**
 * Step3 设置默认进入的子应用
 */
setDefaultMountApp('/vue')

/**
 * Step4 启动应用
 */
start()

runAfterFirstMounted(() => {
    console.log('[MainApp] first app mounted')
}) 

这里有几个注意事项要注意一下:

  1. 子应用的名称 name 必须和子应用下的 package.json 文件中的 name 一样。
  2. 每个子应用都有一个 loader() 方法,这是为了应对用户直接从子应用路由进入页面的情况而设的。进入子页面时判断一下是否加载了主应用,没有则加载,有则跳过。
  3. 为了防止在切换子应用时显示空白页面,应该提供一个 loading 配置。
  4. 设置子应用的入口地址时,直接填入子应用的访问地址。

更改访问端口

vue-cli 的默认访问端口一般为 8080,为了和子应用保持一致,需要将主应用端口改为 8000(子应用分别为 8001、8002)。创建 vue.config.js 文件,将访问端口改为 8000:

module.exports = {
    devServer: {
        port: 8000,
    }
}

至此,主应用就已经改造完了。

创建子应用

子应用不需要引入 qiankun 依赖,只需要暴露出几个生命周期函数就可以:

  1. bootstrap,子应用首次启动时触发。
  2. mount,子应用每次启动时都会触发。
  3. unmount,子应用切换/卸载时触发。

现在将子应用的 main.js 文件改造一下:

import Vue from 'vue'
import VueRouter from 'vue-router'
import App from './App.vue'
import routes from './router'
import store from './store'

Vue.config.productionTip = false

let router = null
let instance = null

function render(props = {}) {
    const { container } = props
    router = new VueRouter({
        // hash 模式不需要下面两行
        base: window.__POWERED_BY_QIANKUN__ ? '/vue' : '/',
        mode: 'history',
        routes,
    })

    instance = new Vue({
        router,
        store,
        render: h => h(App),
    }).$mount(container ? container.querySelector('#app') : '#app')
}

if (window.__POWERED_BY_QIANKUN__) {
    // eslint-disable-next-line no-undef
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
} else {
    render()
}

function storeTest(props) {
    props.onGlobalStateChange &&
        props.onGlobalStateChange(
            (value, prev) => console.log(`[onGlobalStateChange - ${props.name}]:`, value, prev),
            true,
        )
    props.setGlobalState &&
        props.setGlobalState({
            ignore: props.name,
            user: {
                name: props.name,
            },
        })
}

export async function bootstrap() {
    console.log('[vue] vue app bootstraped')
}

export async function mount(props) {
    console.log('[vue] props from main framework', props)
    storeTest(props)
    render(props)
}

export async function unmount() {
    instance.$destroy()
    instance.$el.innerHTML = ''
    instance = null
    router = null
}

可以看到在文件的最后暴露出了 bootstrap mount unmount 三个生命周期函数。另外在挂载子应用时还需要注意一下,子应用是在主应用下运行还是自己独立运行:container ? container.querySelector('#app') : '#app'

配置打包项

根据 qiankun 文档提示,需要对子应用的打包配置项作如下更改:

const packageName = require('./package.json').name;
module.exports = {
  output: {
    library: `${packageName}-[name]`,
    libraryTarget: 'umd',
    jsonpFunction: `webpackJsonp_${packageName}`,
  },
};

所以现在我们还需要在子应用目录下创建 vue.config.js 文件,输入以下代码:

// vue.config.js
const { name } = require('./package.json')

module.exports = {
    configureWebpack: {
        output: {
            // 把子应用打包成 umd 库格式
            library: `${name}-[name]`,
            libraryTarget: 'umd',
            jsonpFunction: `webpackJsonp_${name}`
        }
    },
    devServer: {
        port: 8001,
        headers: {
            'Access-Control-Allow-Origin': '*'
        }
    }
}

vue.config.js 文件有几个注意事项:

  1. 主应用、子应用运行在不同端口下,所以需要设置跨域头 'Access-Control-Allow-Origin': '*'
  2. 由于在主应用配置了 vue 子应用需要运行在 8001 端口下,所以也需要在 devServer 里更改端口。

另外一个子应用 react 的改造方法和 vue 是一样的,所以在此不再赘述。

部署

我们将使用 express 来部署项目,除了需要在子应用设置跨域外,没什么需要特别注意的地方。

主应用服务器文件 main-server.js

const fs = require('fs')
const express = require('express')
const app = express()
const port = 8000

app.use(express.static('main-static'))

app.get('*', (req, res) => {
    fs.readFile('./main-static/index.html', 'utf-8', (err, html) => {
        res.send(html)
    })
})

app.listen(port, () => {
    console.log(`main app listening at http://localhost:${port}`)
})

vue 子应用服务器文件 vue-server.js

const fs = require('fs')
const express = require('express')
const app = express()
const cors = require('cors')
const port = 8001

// 设置跨域
app.use(cors())
app.use(express.static('vue-static'))

app.get('*', (req, res) => {
    fs.readFile('./vue-static/index.html', 'utf-8', (err, html) => {
        res.send(html)
    })
})

app.listen(port, () => {
    console.log(`vue app listening at http://localhost:${port}`)
})

react 子应用服务器文件 react-server.js

const fs = require('fs')
const express = require('express')
const app = express()
const cors = require('cors')
const port = 8002

// 设置跨域
app.use(cors())
app.use(express.static('react-static'))

app.get('*', (req, res) => {
    fs.readFile('./react-static/index.html', 'utf-8', (err, html) => {
        res.send(html)
    })
})

app.listen(port, () => {
    console.log(`react app listening at http://localhost:${port}`)
})

另外需要将这三个应用打包后的文件分别放到 main-staticvue-staticreact-static 目录下。然后分别执行命令 node main-server.jsnode vue-server.jsnode react-server.js 即可查看部署后的页面。现在这个项目目录如下:

-main
-main-static // main 主应用静态文件目录
-react
-react-static // react 子应用静态文件目录
-vue
-vue-static // vue 子应用静态文件目录
-main-server.js // main 主应用服务器
-vue-server.js // vue 子应用服务器
-react-server.js // react 子应用服务器

我已经将这个微前端应用的代码上传到了 github,建议将项目克隆下来配合本章一起阅读,效果更好。下面放一下 DEMO 的运行效果图:

带你入门前端工程:微前端

带你入门前端工程:微前端

带你入门前端工程:微前端

小结

对于大型应用的开发和维护,使用微前端能让我们变得更加轻松。不过如果是小应用,建议还是单独建一个项目开发。毕竟微前端也有额外的开发、维护成本。

参考资料

带你入门前端工程 全文目录:

  1. 技术选型:如何进行技术选型?
  2. 统一规范:如何制订规范并利用工具保证规范被严格执行?
  3. 前端组件化:什么是模块化、组件化?
  4. 测试:如何写单元测试和 E2E(端到端) 测试?
  5. 构建工具:构建工具有哪些?都有哪些功能和优势?
  6. 自动化部署:如何利用 Jenkins、Github Actions 自动化部署项目?
  7. 前端监控:讲解前端监控原理及如何利用 sentry 对项目实行监控。
  8. 性能优化(一):如何检测网站性能?有哪些实用的性能优化规则?
  9. 性能优化(二):如何检测网站性能?有哪些实用的性能优化规则?
  10. 重构:为什么做重构?重构有哪些手法?
  11. 微服务:微服务是什么?如何搭建微服务项目?
  12. Severless:Severless 是什么?如何使用 Severless?

本文转自 https://segmentfault.com/a/1190000039110977,如有侵权,请联系删除。

点赞
收藏
评论区
推荐文章
秃头王路飞 秃头王路飞
5个月前
webpack5手撸vue2脚手架
webpack5手撸vue相信工作个12年的小伙伴们在面试的时候多多少少怕被问到关于webpack方面的知识,本菜鸟最近闲来无事,就尝试了手撸了下vue2的脚手架,第一次发帖实在是没有经验,望海涵。languageJavaScript"name":"vuecliversion2","version":"1.0.0","desc
浅梦一笑 浅梦一笑
5个月前
初学 Python 需要安装哪些软件?超级实用,小白必看!
编程这个东西是真的奇妙。对于懂得的人来说,会觉得这个工具是多么的好用、有趣,而对于小白来说,就如同大山一样。其实这个都可以理解,大家都是这样过来的。那么接下来就说一下python相关的东西吧,并说一下我对编程的理解。本人也是小白一名,如有不对的地方,还请各位大神指出01名词解释:如果在编程方面接触的比较少,那么对于软件这一块,有几个名词一定要了解,比如开发环
blmius blmius
1年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
技术小男生 技术小男生
5个月前
linux环境jdk环境变量配置
1:编辑系统配置文件vi/etc/profile2:按字母键i进入编辑模式,在最底部添加内容:JAVAHOME/opt/jdk1.8.0152CLASSPATH.:$JAVAHOME/lib/dt.jar:$JAVAHOME/lib/tools.jarPATH$JAVAHOME/bin:$PATH3:生效配置
光头强的博客 光头强的博客
5个月前
Java面向对象试题
1、请创建一个Animal动物类,要求有方法eat()方法,方法输出一条语句“吃东西”。创建一个接口A,接口里有一个抽象方法fly()。创建一个Bird类继承Animal类并实现接口A里的方法输出一条有语句“鸟儿飞翔”,重写eat()方法输出一条语句“鸟儿吃虫”。在Test类中向上转型创建b对象,调用eat方法。然后向下转型调用eat()方
刚刚好 刚刚好
5个月前
css问题
1、在IOS中图片不显示(给图片加了圆角或者img没有父级)<div<imgsrc""/</divdiv{width:20px;height:20px;borderradius:20px;overflow:h
小森森 小森森
5个月前
校园表白墙微信小程序V1.0 SayLove -基于微信云开发-一键快速搭建,开箱即用
后续会继续更新,敬请期待2.0全新版本欢迎添加左边的微信一起探讨!项目地址:(https://www.aliyun.com/activity/daily/bestoffer?userCodesskuuw5n)\2.Bug修复更新日历2.情侣脸功能大家不要使用了,现在阿里云的接口已经要收费了(土豪请随意),\\和注意
晴空闲云 晴空闲云
5个月前
css中box-sizing解放盒子实际宽高计算
我们知道传统的盒子模型,如果增加内边距padding和边框border,那么会撑大整个盒子,造成盒子的宽度不好计算,在实务中特别不方便。boxsizing可以设置盒模型的方式,可以很好的设置固定宽高的盒模型。盒子宽高计算假如我们设置如下盒子:宽度和高度均为200px,那么这会这个盒子实际的宽高就都是200px。但是当我们设置这个盒子的边框和内间距的时候,那
艾木酱 艾木酱
5个月前
快速入门|使用MemFire Cloud构建React Native应用程序
MemFireCloud是一款提供云数据库,用户可以创建云数据库,并对数据库进行管理,还可以对数据库进行备份操作。它还提供后端即服务,用户可以在1分钟内新建一个应用,使用自动生成的API和SDK,访问云数据库、对象存储、用户认证与授权等功能,可专
密钥管理系统-为你的天翼云资产上把“锁
本文关键词:数据安全,密码机,密钥管理一、你的云上资产真的安全么?1.2021年1月,巴西的一个数据库30TB数据被破坏,泄露的数据包含有1.04亿辆汽车和约4000万家公司的详细信息,受影响的人员数量可能有2.2亿;2.2021年2月,广受欢迎的音频聊天室应用Clubhouse的用户数据被恶意黑客或间谍窃取。据悉,一位身份不明的用户能够将Clubho
helloworld_28799839 helloworld_28799839
5个月前
常用知识整理
Javascript判断对象是否为空jsObject.keys(myObject).length0经常使用的三元运算我们经常遇到处理表格列状态字段如status的时候可以用到vue