筆者早期開發了一個導航網站,一直想要重構,因為懶拖了好幾年,終于,在了解到微前端大法后下了決心,因為作業上一直沒有機會實踐,沒辦法,只能用自己的網站試試,思來想去,訪問量最高的也就是這個破導航網站了,于是用最快的時間完成了基本功能的重構,然后準備通過微前端來擴展網站的功能,比如天氣、待辦、筆記、秒表計時等等,這些功能屬于附加的功能,可能會越來越多,所以不能和導航本身強耦合在一起,需要做到能獨立開發,獨立上線,所以使用微前端再合適不過了,
另外,因為有些功能可能非常簡單,比如秒表計時,單獨創建一個專案顯得沒有必要,但是又不想直接寫在導航的代碼里,最好是能直接通過Vue單檔案來開發,然后頁面上動態的進行加載渲染,所以會在微前端方式之外再嘗試一下動態組件,
本文內的專案都使用Vue CLI創建,Vue使用的是3.x版本,路由使用的都是hash模式
小程式注冊
為了顯得高大上一點,擴展功能我把它稱為小程式,首先要實作的是一個小程式的注冊功能,詳細來說就是:
1.提供一個表單,輸入小程式名稱、描述、圖示、url、型別(微前端方式還需要配置激活規則,組件方式需要配置樣式檔案的url),如下:

2.導航頁面上顯示注冊的小程式串列,點擊后渲染對應的小程式:

微前端方式
先來看看微前端的實作方式,筆者選擇的是qiankun框架,
主應用
主應用也就是導航網站,首先安裝qiankun:
npm i qiankun -S
主應用需要做的很簡單,注冊微應用并啟動,然后提供一個容器給微應用掛載,最后打開指定的url即可,
因為微應用串列都存盤在資料庫里,所以需要先獲取然后進行注冊,創建qiankun.js檔案:
// qiankun.js
import { registerMicroApps, start } from 'qiankun'
import api from '@/api';
// 注冊及啟動
const registerAndStart = (appList) => {
// 注冊微應用
registerMicroApps(appList)
// 啟動 qiankun
start()
}
// 判斷是否激活微應用
const getActiveRule = (hash) => (location) => location.hash.startsWith(hash);
// 初始化小程式
export const initMicroApp = async () => {
try {
// 請求小程式串列資料
let { data } = await api.getAppletList()
// 過濾出微應用
let appList = data.data.filter((item) => {
return item.type === 'microApp';
}).map((item) => {
return {
container: '#appletContainer',
name: item.name,
entry: item.url,
activeRule: getActiveRule(item.activeRule)
};
})
// 注冊并啟動微應用
registerAndStart(appList)
} catch (e) {
console.log(e);
}
}
一個微應用的資料示例如下:
{
container: '#appletContainer',
name: '后閣樓',
entry: 'http://lxqnsys.com/applets/hougelou/',
activeRule: getActiveRule('#/index/applet/hougelou')
}
可以看到提供給微應用掛載的容器為#appletContainer,微應用的訪問url為http://lxqnsys.com/applets/hougelou/,注意最后面的/不可省略,否則微應用的資源路徑可能會出現錯誤,
另外解釋一下激活規則activeRule,導航網站的url為:http://lxqnsys.com/d/#/index,微應用的路由規則為:applet/:appletId,所以一個微應用的激活規則為頁面url的hash部分,但是這里activeRule沒有直接使用字串的方式:#/index/applet/hougelou,這是因為筆者的導航網站并沒有部署在根路徑,而是在/d目錄下,所以#/index/applet/hougelou這個規則是匹配不到http://lxqnsys.com/d/#/index/applet/hougelou這個url的,需要這樣才行:/d/#/index/applet/hougelou,但是部署的路徑有可能會變,不方便直接寫到微應用的activeRule里,所以這里使用函式的方式,自行判斷是否匹配,也就是根據頁面的location.hash是否是以activeRule開頭的來判斷,是的話代表匹配到了,
微應用
微應用也就是我們的小程式專案,根據官方檔案的介紹Vue 微應用,首先需要在src目錄新增一個public-path.js:
// public-path.js
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
然后修改main.js,增加qiankun的生命周期函式:
// main.js
import './public-path';
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
let app = null
const render = (props = {}) => {
// 微應用使用方式時掛載的元素需要在容器的范圍下查找
const { container } = props;
app = createApp(App)
app.use(router)
app.mount(container ? container.querySelector('#app') : '#app')
}
// 獨立運行時直接初始化
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
// 三個生命周期函式
export async function bootstrap() {
console.log('[后閣樓] 啟動');
}
export async function mount(props) {
console.log('[后閣樓] 掛載');
render(props);
}
export async function unmount() {
console.log('[后閣樓] 卸載');
app.unmount();
app = null;
}
接下來修改打包配置vue.config.js:
module.exports = {
// ...
configureWebpack: {
devServer: {
// 主應用需要請求微應用的資源,所以需要允許跨域訪問
headers: {
'Access-Control-Allow-Origin': '*'
}
},
output: {
// 打包為umd格式
library: `hougelou`,
libraryTarget: 'umd'
}
}
}
最后,還需要修改一下路由配置,有兩種方式:
1.設定base
import { createRouter, createWebHashHistory } from 'vue-router';
let routes = routes = [
{ path: '/', name: 'List', component: List },
{ path: '/detail/:id', name: 'Detail', component: Detail },
]
const router = createRouter({
history: createWebHashHistory(window.__POWERED_BY_QIANKUN__ ? '/d/#/index/applet/hougelou/' : '/'),
routes
})
export default router
這種方式的缺點也是把主應用的部署路徑寫死在base里,不是很優雅,
2.使用子路由
import { createRouter, createWebHashHistory } from 'vue-router';
import List from '@/pages/List';
import Detail from '@/pages/Detail';
import Home from '@/pages/Home';
let routes = []
if (window.__POWERED_BY_QIANKUN__) {
routes = [{
path: '/index/applet/hougelou/',
name: 'Home',
component: Home,
children: [
{ path: '', name: 'List', component: List },
{ path: 'detail/:id', name: 'Detail', component: Detail },
],
}]
} else {
routes = [
{ path: '/', name: 'List', component: List },
{ path: '/detail/:id', name: 'Detail', component: Detail },
]
}
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router
在微前端環境下把路由都作為/index/applet/hougelou/的子路由,
效果如下:

優化
1.回傳按鈕
如上面的效果所示,微應用內部頁面跳轉后,如果要回到上一個頁面只能通過瀏覽器的回傳按鈕,顯然不是很方便,可以在標題欄上添加一個回傳按鈕:
<div v-if="isMicroApp" @click="back">
<span ></span>
</div>
const back = () => {
router.go(-1);
};
這樣當小程式為微應用時會顯示一個回傳按鈕,但是有一個問題,當在微應用的首頁時顯然是不需要這個回傳按鈕的,我們可以通過判斷當前的路由和微應用的activeRule是否一致,一樣的話就代表是在微應用首頁,那么就不顯示回傳按鈕:
<div v-if="isMicroApp && isInHome" @click="back">
<span ></span>
</div>
router.afterEach(() => {
if (!isMicroApp.value) {
return;
}
let reg = new RegExp("^#" + route.fullPath + "?$");
isInHome.value = https://www.cnblogs.com/wanglinmantan/p/reg.test(payload.value.activeRule);
});

2.微應用頁面切換時滾動位置恢復
如上面的動圖所示,當從串列頁進入到詳情頁再回傳串列時,串列回到了頂部,這樣的體驗是很糟糕的,我們需要記住滾動的位置并恢復,
可以通過把url和滾動位置關聯并記錄起來,在router.beforeEach時獲取當前的滾動位置,然后和當前的url關聯起來并存盤,當router.afterEach時根據當前url獲取存盤的資料并恢復滾動位置:
const scrollTopCache = {};
let scrollTop = 0;
// 監聽容器滾動位置
appletContainer.value.addEventListener("scroll", () => {
scrollTop = appletContainer.value.scrollTop;
});
router.beforeEach(() => {
// 快取滾動位置
scrollTopCache[route.fullPath] = scrollTop;
});
router.afterEach(() => {
if (!isMicroApp.value) {
return;
}
// ...
// 恢復滾動位置
appletContainer.value.scrollTop = scrollTopCache[route.fullPath];
});

3.初始url為小程式url的問題
正常在關閉小程式時會把頁面的路由恢復至頁面原本的路由,但是比如我在打開小程式的情況下直接重繪頁面,那么因為url滿足小程式的激活規則,所以qiankun會去加載對應的微應用,然而可能這時頁面上連微應用的容器都沒有,所以會報錯,解決這個問題可以在頁面加載后判斷初始路由是否是小程式的路由,是的話就恢復一下,然后再去注冊微應用:
if (/\/index\/applet\//.test(route.fullPath)) {
router.replace("/index");
}
initMicroApp();
Vue組件方式
接下來看看使用Vue組件的方式,筆者的想法是直接使用Vue單檔案來開發,開發完成后打包成一個js檔案,然后在導航網站上請求該js檔案,并把它作為動態組件渲染出來,
簡單起見我們直接在導航專案下新建一個檔案夾作為小程式的目錄,這樣可以直接使用專案的打包工具,新增一個stopwatch測驗組件,目前目錄結構如下:

組件App.vue內容如下:
<template>
<div >
<div >{{ count }}</div>
<button @click="start">開始</button>
</div>
</template>
<script setup>
import { ref } from "vue";
const count = ref(0);
const start = () => {
setInterval(() => {
count.value++;
}, 1000);
};
</script>
<style lang="less" scoped>
.countContainer {
text-align: center;
.count {
color: red;
}
}
</style>
index.js用來匯出組件:
import App from './App.vue';
export default App
// 配置資料
const config = {
width: 450
}
export {
config
}
為了個性化,還支持匯出它的配置資料,
接下來需要對組件進行打包,我們直接使用vue-cli,vue-cli支持指定不同的構建目標,默認為應用模式,我們平常專案打包運行的npm run build,其實運行的就是vue-cli-service build命令,可以通過選項來修改打包行為:
vue-cli-service build --target lib --dest dist_applets/stopwatch --name stopwatch --entry src/applets/stopwatch/index.js
上面這個配置就可以打包我們的stopwatch組件,選項含義如下:
--target app | lib | wc | wc-async (默認為app應用模式,我們使用lib作為庫打包模式)
--dest 指定輸出目錄 (默認輸出到dist目錄,我們改成dist_applets目錄下)
--name 庫或 Web Components 模式下的名字 (默認值:package.json 中的 "name" 欄位或入口檔案名,我們改成組件名稱)
--entry 指定打包的入口,可以是.js或.vue檔案(也就是組件的index.js路徑)
更詳細的資訊可以移步官方檔案:構建目標、CLI 服務,
但是我們的組件是不定的,數量可能會越來越多,所以直接在命令列輸入命令打包會非常的麻煩,我們可以通過腳本來完成,在/applets/目錄下新增build.js:
// build.js
const { exec } = require('child_process');
const path = require('path')
const fs = require('fs')
// 獲取組件串列
const getComps = () => {
let res = []
let files = fs.readdirSync(__dirname)
files.forEach((filename) => {
// 是否是目錄
let dir = path.join(__dirname, filename)
let isDir = fs.statSync(dir).isDirectory
// 入口檔案是否存在
let entryFile = path.join(dir, 'index.js')
let entryExist = fs.existsSync(entryFile)
if (isDir && entryExist) {
res.push(filename)
}
})
return res
}
let compList = getComps()
// 創建打包任務
let taskList = compList.map((comp) => {
return new Promise((resolve, reject) => {
exec(`vue-cli-service build --target lib --dest dist_applets/${comp} --name ${comp} --entry src/applets/${comp}/index.js`, (error, stdout, stderr) => {
if (error) {
reject(error)
} else {
resolve()
}
})
});
})
Promise.all(taskList)
.then(() => {
console.log('打包成功');
})
.catch((e) => {
console.error('打包失敗');
console.error(e);
})
然后去package.json新增如下命令:
{
"scripts": {
"buildApplets": "node ./src/applets/build.js"
}
}
運行命令npm run buildApplets,可以看到打包結果如下:

我們使用其中css檔案和umd型別的js檔案,打開.umd.js檔案看看:

factory函式執行回傳的結果就是組件index.js里面匯出的資料,另外可以看到引入vue的代碼,這表明Vue是沒有包含在打包后的檔案里的,這是vue-cli刻意為之的,這在通過構建工具使用打包后的庫來說是很方便的,但是我們是需要直接在頁面運行的時候動態的引入組件,不經過打包工具的處理,所以exports、module、define、require等物件或方法都是沒有的,沒有沒關系,我們可以手動注入,我們使用第二個else if,也就是我們需要手動來提供exports物件和require函式,
當我們點擊Vue組件型別的小程式時我們使用axios來請求組件的js檔案,獲取到的是js字串,然后使用new Function來執行js,注入我們提供的exports物件和require函式,然后就可以通過exports物件獲取到組件匯出的資料,最后再使用動態組件渲染出組件即可,同時如果存在樣式檔案的話也要動態加載樣式檔案,
<template>
<component v-if="comp" :is="comp"></component>
</template>
import * as Vue from 'vue';
const comp = ref(null);
const load = async () => {
try {
// 加載樣式檔案
if (payload.value.styleUrl) {
loadStyle(payload.value.styleUrl)
}
// 請求組件js資源
let { data } = await axios.get(payload.value.url);
// 執行組件js
let run = new Function('exports', 'require', `return ${data}`)
// 手動提供exports物件和require函式
const exports = {}
const require = () => {
return Vue;
}
// 執行函式
run(exports, require)
// 獲取組件選項物件,扔給動態組件進行渲染
comp.value = https://www.cnblogs.com/wanglinmantan/p/exports.stopwatch.default
} catch (error) {
console.error(error);
}
};
執行完組件的js后我們注入的exports物件如下:

所以通過exports.stopwatch.default就能獲取到組件的選項物件傳遞給動態組件進行渲染,效果如下:

大功告成,最后我們再稍微修改一下,因為通過exports.stopwatch.default獲取組件匯出內容我們還需要知道組件的打包名稱stopwatch,這顯然有點麻煩,我們可以改成一個固定的名稱,比如就叫comp,修改打包命令:
// build.js
// ...
exec(`vue-cli-service build --target lib --dest dist_applets/${comp} --name comp --entry src/applets/${comp}/index.js`, (error, stdout, stderr) => {
if (error) {
reject(error)
} else {
resolve()
}
})
// ...
把--name引數由之前的${name}改成寫死comp即可,打包結果如下:

exports物件結構變成如下:

然后我們就可以通過comp名稱來應對任何組件了comp.value = https://www.cnblogs.com/wanglinmantan/p/exports.comp.default,
當然,小程式關閉的時候不要忘記洗掉添加的樣式節點,
總結
本文簡單了嘗試兩種網站功能的擴展方式,各位如果有更好的方式的話可以評論留言分享,線上效果演示地址http://lxqnsys.com/d/,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/419936.html
標籤:JavaScript
