帶你入門前端工程(十一):微前端

什麼是微服務?先看看維基百科的定義:css

微服務(英語:Microservices)是一種軟件架構風格,它是以專一於單一責任與功能的小型功能區塊 (Small Building Blocks) 爲基礎,利用模塊化的方式組合出複雜的大型應用程序,各功能區塊使用與語言無關 (Language-Independent/Language agnostic)的API集相互通訊。

換句話說,就是將一個大型、複雜的應用分解成幾個服務,每一個服務就像是一個組件,組合起來一塊兒構建成整個應用。html

想象一下,一個上百個功能、數十萬行代碼的應用維護起來是個什麼場景?前端

  1. 牽一髮而動全身,僅僅修改一處代碼,就須要從新部署整個應用。常常有「修改一分鐘,編譯半小時」的狀況發生。
  2. 代碼模塊錯綜複雜,互相依賴。更改一處地方的代碼,每每會影響到應用的其餘功能。

若是使用微服務來重構整個應用有什麼好處?

一個應用分解成多個服務,每一個服務獨自服務內部的功能。例如原來的應用有 abcd 四個頁面,如今分解成兩個服務,第一個服務有 ab 兩個頁面,第二個服務有 cd 兩個頁面,組合在一塊兒就和原來的應用同樣。vue

當應用其中一個服務出故障時,其餘服務仍能夠正常訪問。例如第一個服務出故障了, ab 頁面將沒法訪問,但 cd 頁面仍能正常訪問。node

好處:不一樣的服務獨立運行,服務與服務之間解耦。咱們能夠把服務理解成組件,就像本小書第 3 章《前端組件化》中所說的同樣。每一個服務能夠獨自管理,修改一個服務不影響總體應用的運行,隻影響該服務提供的功能。react

另外在開發時也能夠快速的添加、刪除功能。例如電商網站,在不一樣的節假日時推出的活動頁面,活動事後立刻就能夠刪掉。webpack

難點:不容易確認服務的邊界。當一個應用功能太多時,每每多個功能點之間的關聯會比較深。於是就很難肯定這一個功能應該歸屬於哪一個服務。git

PS:微前端就是微服務在前端的應用,也就是前端微服務。github

微服務實踐

如今咱們將使用微前端框架 qiankun 來構建一個微前端應用。之因此選用 qiankun 框架,是由於它有如下幾個優勢:web

  • 技術棧無關,任何技術棧的應用都能接入。
  • 樣式隔離,子應用之間的樣式互不干擾。
  • 子應用的 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(子應用分別爲 800一、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?
相關文章
相關標籤/搜索