筆者早期開發(fā)了一個(gè)導(dǎo)航網(wǎng)站,一直想要重構(gòu)锣杂,因?yàn)閼型狭撕脦啄曛耄K于,在了解到微前端大法后下了決心元莫,因?yàn)楣ぷ魃弦恢睕](méi)有機(jī)會(huì)實(shí)踐赖阻,沒(méi)辦法,只能用自己的網(wǎng)站試試踱蠢,思來(lái)想去火欧,訪問(wèn)量最高的也就是這個(gè)破導(dǎo)航網(wǎng)站了,于是用最快的時(shí)間完成了基本功能的重構(gòu)茎截,然后準(zhǔn)備通過(guò)微前端來(lái)擴(kuò)展網(wǎng)站的功能苇侵,比如天氣、待辦企锌、筆記榆浓、秒表計(jì)時(shí)等等,這些功能屬于附加的功能撕攒,可能會(huì)越來(lái)越多陡鹃,所以不能和導(dǎo)航本身強(qiáng)耦合在一起烘浦,需要做到能獨(dú)立開發(fā),獨(dú)立上線萍鲸,所以使用微前端再合適不過(guò)了闷叉。
另外,因?yàn)橛行┕δ芸赡芊浅:?jiǎn)單脊阴,比如秒表計(jì)時(shí)握侧,單獨(dú)創(chuàng)建一個(gè)項(xiàng)目顯得沒(méi)有必要,但是又不想直接寫在導(dǎo)航的代碼里蹬叭,最好是能直接通過(guò)Vue
單文件來(lái)開發(fā)藕咏,然后頁(yè)面上動(dòng)態(tài)的進(jìn)行加載渲染,所以會(huì)在微前端方式之外再嘗試一下動(dòng)態(tài)組件秽五。
本文內(nèi)的項(xiàng)目都使用Vue CLI創(chuàng)建孽查,Vue使用的是3.x版本,路由使用的都是hash模式
小程序注冊(cè)
為了顯得高大上一點(diǎn)坦喘,擴(kuò)展功能我把它稱為小程序
盲再,首先要實(shí)現(xiàn)的是一個(gè)小程序的注冊(cè)功能,詳細(xì)來(lái)說(shuō)就是:
1.提供一個(gè)表單瓣铣,輸入小程序名稱答朋、描述、圖標(biāo)棠笑、url梦碗、類型(微前端方式還需要配置激活規(guī)則,組件方式需要配置樣式文件的url)蓖救,如下:
2.導(dǎo)航頁(yè)面上顯示注冊(cè)的小程序列表洪规,點(diǎn)擊后渲染對(duì)應(yīng)的小程序:
微前端方式
先來(lái)看看微前端的實(shí)現(xiàn)方式,筆者選擇的是qiankun框架循捺。
主應(yīng)用
主應(yīng)用也就是導(dǎo)航網(wǎng)站斩例,首先安裝qiankun
:
npm i qiankun -S
主應(yīng)用需要做的很簡(jiǎn)單,注冊(cè)微應(yīng)用并啟動(dòng)从橘,然后提供一個(gè)容器給微應(yīng)用掛載念赶,最后打開指定的url
即可。
因?yàn)槲?yīng)用列表都存儲(chǔ)在數(shù)據(jù)庫(kù)里恰力,所以需要先獲取然后進(jìn)行注冊(cè)叉谜,創(chuàng)建qiankun.js
文件:
// qiankun.js
import { registerMicroApps, start } from 'qiankun'
import api from '@/api';
// 注冊(cè)及啟動(dòng)
const registerAndStart = (appList) => {
// 注冊(cè)微應(yīng)用
registerMicroApps(appList)
// 啟動(dòng) qiankun
start()
}
// 判斷是否激活微應(yīng)用
const getActiveRule = (hash) => (location) => location.hash.startsWith(hash);
// 初始化小程序
export const initMicroApp = async () => {
try {
// 請(qǐng)求小程序列表數(shù)據(jù)
let { data } = await api.getAppletList()
// 過(guò)濾出微應(yīng)用
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)
};
})
// 注冊(cè)并啟動(dòng)微應(yīng)用
registerAndStart(appList)
} catch (e) {
console.log(e);
}
}
一個(gè)微應(yīng)用的數(shù)據(jù)示例如下:
{
container: '#appletContainer',
name: '后閣樓',
entry: 'http://lxqnsys.com/applets/hougelou/',
activeRule: getActiveRule('#/index/applet/hougelou')
}
可以看到提供給微應(yīng)用掛載的容器為#appletContainer
,微應(yīng)用的訪問(wèn)url
為http://lxqnsys.com/applets/hougelou/
踩萎,注意最后面的/
不可省略正罢,否則微應(yīng)用的資源路徑可能會(huì)出現(xiàn)錯(cuò)誤。
另外解釋一下激活規(guī)則activeRule
,導(dǎo)航網(wǎng)站的url
為:http://lxqnsys.com/d/#/index
翻具,微應(yīng)用的路由規(guī)則為:applet/:appletId
履怯,所以一個(gè)微應(yīng)用的激活規(guī)則為頁(yè)面url
的hash
部分,但是這里activeRule
沒(méi)有直接使用字符串的方式:#/index/applet/hougelou
裆泳,這是因?yàn)楣P者的導(dǎo)航網(wǎng)站并沒(méi)有部署在根路徑叹洲,而是在/d
目錄下,所以#/index/applet/hougelou
這個(gè)規(guī)則是匹配不到http://lxqnsys.com/d/#/index/applet/hougelou
這個(gè)url
的工禾,需要這樣才行:/d/#/index/applet/hougelou
运提,但是部署的路徑有可能會(huì)變,不方便直接寫到微應(yīng)用的activeRule
里闻葵,所以這里使用函數(shù)的方式民泵,自行判斷是否匹配,也就是根據(jù)頁(yè)面的location.hash
是否是以activeRule
開頭的來(lái)判斷槽畔,是的話代表匹配到了栈妆。
微應(yīng)用
微應(yīng)用也就是我們的小程序項(xiàng)目,根據(jù)官方文檔的介紹Vue 微應(yīng)用厢钧,首先需要在src
目錄新增一個(gè)public-path.js
:
// public-path.js
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
然后修改main.js
鳞尔,增加qiankun
的生命周期函數(shù):
// main.js
import './public-path';
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
let app = null
const render = (props = {}) => {
// 微應(yīng)用使用方式時(shí)掛載的元素需要在容器的范圍下查找
const { container } = props;
app = createApp(App)
app.use(router)
app.mount(container ? container.querySelector('#app') : '#app')
}
// 獨(dú)立運(yùn)行時(shí)直接初始化
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
// 三個(gè)生命周期函數(shù)
export async function bootstrap() {
console.log('[后閣樓] 啟動(dòng)');
}
export async function mount(props) {
console.log('[后閣樓] 掛載');
render(props);
}
export async function unmount() {
console.log('[后閣樓] 卸載');
app.unmount();
app = null;
}
接下來(lái)修改打包配置vue.config.js
:
module.exports = {
// ...
configureWebpack: {
devServer: {
// 主應(yīng)用需要請(qǐng)求微應(yīng)用的資源,所以需要允許跨域訪問(wèn)
headers: {
'Access-Control-Allow-Origin': '*'
}
},
output: {
// 打包為umd格式
library: `hougelou`,
libraryTarget: 'umd'
}
}
}
最后早直,還需要修改一下路由配置寥假,有兩種方式:
1.設(shè)置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
這種方式的缺點(diǎn)也是把主應(yīng)用的部署路徑寫死在base
里,不是很優(yōu)雅霞扬。
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
在微前端環(huán)境下把路由都作為/index/applet/hougelou/
的子路由糕韧。
效果如下:
優(yōu)化
1.返回按鈕
如上面的效果所示,微應(yīng)用內(nèi)部頁(yè)面跳轉(zhuǎn)后喻圃,如果要回到上一個(gè)頁(yè)面只能通過(guò)瀏覽器的返回按鈕兔沃,顯然不是很方便,可以在標(biāo)題欄上添加一個(gè)返回按鈕:
<div class="backBtn" v-if="isMicroApp" @click="back">
<span class="iconfont icon-fanhui"></span>
</div>
const back = () => {
router.go(-1);
};
這樣當(dāng)小程序?yàn)槲?yīng)用時(shí)會(huì)顯示一個(gè)返回按鈕级及,但是有一個(gè)問(wèn)題,當(dāng)在微應(yīng)用的首頁(yè)時(shí)顯然是不需要這個(gè)返回按鈕的额衙,我們可以通過(guò)判斷當(dāng)前的路由和微應(yīng)用的activeRule
是否一致饮焦,一樣的話就代表是在微應(yīng)用首頁(yè),那么就不顯示返回按鈕:
<div class="backBtn" v-if="isMicroApp && isInHome" @click="back">
<span class="iconfont icon-fanhui"></span>
</div>
router.afterEach(() => {
if (!isMicroApp.value) {
return;
}
let reg = new RegExp("^#" + route.fullPath + "?$");
isInHome.value = reg.test(payload.value.activeRule);
});
2.微應(yīng)用頁(yè)面切換時(shí)滾動(dòng)位置恢復(fù)
如上面的動(dòng)圖所示窍侧,當(dāng)從列表頁(yè)進(jìn)入到詳情頁(yè)再返回列表時(shí)县踢,列表回到了頂部,這樣的體驗(yàn)是很糟糕的伟件,我們需要記住滾動(dòng)的位置并恢復(fù)硼啤。
可以通過(guò)把url
和滾動(dòng)位置關(guān)聯(lián)并記錄起來(lái),在router.beforeEach
時(shí)獲取當(dāng)前的滾動(dòng)位置斧账,然后和當(dāng)前的url
關(guān)聯(lián)起來(lái)并存儲(chǔ)谴返,當(dāng)router.afterEach
時(shí)根據(jù)當(dāng)前url
獲取存儲(chǔ)的數(shù)據(jù)并恢復(fù)滾動(dòng)位置:
const scrollTopCache = {};
let scrollTop = 0;
// 監(jiān)聽容器滾動(dòng)位置
appletContainer.value.addEventListener("scroll", () => {
scrollTop = appletContainer.value.scrollTop;
});
router.beforeEach(() => {
// 緩存滾動(dòng)位置
scrollTopCache[route.fullPath] = scrollTop;
});
router.afterEach(() => {
if (!isMicroApp.value) {
return;
}
// ...
// 恢復(fù)滾動(dòng)位置
appletContainer.value.scrollTop = scrollTopCache[route.fullPath];
});
3.初始url為小程序url的問(wèn)題
正常在關(guān)閉小程序時(shí)會(huì)把頁(yè)面的路由恢復(fù)至頁(yè)面原本的路由煞肾,但是比如我在打開小程序的情況下直接刷新頁(yè)面,那么因?yàn)?code>url滿足小程序的激活規(guī)則嗓袱,所以qiankun
會(huì)去加載對(duì)應(yīng)的微應(yīng)用籍救,然而可能這時(shí)頁(yè)面上連微應(yīng)用的容器都沒(méi)有,所以會(huì)報(bào)錯(cuò)渠抹,解決這個(gè)問(wèn)題可以在頁(yè)面加載后判斷初始路由是否是小程序的路由蝙昙,是的話就恢復(fù)一下,然后再去注冊(cè)微應(yīng)用:
if (/\/index\/applet\//.test(route.fullPath)) {
router.replace("/index");
}
initMicroApp();
Vue組件方式
接下來(lái)看看使用Vue
組件的方式梧却,筆者的想法是直接使用Vue
單文件來(lái)開發(fā)奇颠,開發(fā)完成后打包成一個(gè)js
文件,然后在導(dǎo)航網(wǎng)站上請(qǐng)求該js
文件放航,并把它作為動(dòng)態(tài)組件渲染出來(lái)烈拒。
簡(jiǎn)單起見(jiàn)我們直接在導(dǎo)航項(xiàng)目下新建一個(gè)文件夾作為小程序的目錄,這樣可以直接使用項(xiàng)目的打包工具三椿,新增一個(gè)stopwatch
測(cè)試組件缺菌,目前目錄結(jié)構(gòu)如下:
組件App.vue
內(nèi)容如下:
<template>
<div class="countContainer">
<div class="count">{{ 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
用來(lái)導(dǎo)出組件:
import App from './App.vue';
export default App
// 配置數(shù)據(jù)
const config = {
width: 450
}
export {
config
}
為了個(gè)性化,還支持導(dǎo)出它的配置數(shù)據(jù)搜锰。
接下來(lái)需要對(duì)組件進(jìn)行打包伴郁,我們直接使用vue-cli
,vue-cli
支持指定不同的構(gòu)建目標(biāo)蛋叼,默認(rèn)為應(yīng)用模式焊傅,我們平常項(xiàng)目打包運(yùn)行的npm run build
,其實(shí)運(yùn)行的就是vue-cli-service build
命令狈涮,可以通過(guò)選項(xiàng)來(lái)修改打包行為:
vue-cli-service build --target lib --dest dist_applets/stopwatch --name stopwatch --entry src/applets/stopwatch/index.js
上面這個(gè)配置就可以打包我們的stopwatch
組件狐胎,選項(xiàng)含義如下:
--target app | lib | wc | wc-async (默認(rèn)為app應(yīng)用模式,我們使用lib作為庫(kù)打包模式)
--dest 指定輸出目錄 (默認(rèn)輸出到dist目錄歌馍,我們改成dist_applets目錄下)
--name 庫(kù)或 Web Components 模式下的名字 (默認(rèn)值:package.json 中的 "name" 字段或入口文件名握巢,我們改成組件名稱)
--entry 指定打包的入口,可以是.js或.vue文件(也就是組件的index.js路徑)
更詳細(xì)的信息可以移步官方文檔:構(gòu)建目標(biāo)松却、CLI 服務(wù)暴浦。
但是我們的組件是不定的,數(shù)量可能會(huì)越來(lái)越多晓锻,所以直接在命令行輸入命令打包會(huì)非常的麻煩歌焦,我們可以通過(guò)腳本來(lái)完成,在/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()
// 創(chuàng)建打包任務(wù)
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"
}
}
運(yùn)行命令npm run buildApplets
砚哆,可以看到打包結(jié)果如下:
我們使用其中css
文件和umd
類型的js
文件独撇,打開.umd.js
文件看看:
factory
函數(shù)執(zhí)行返回的結(jié)果就是組件index.js
里面導(dǎo)出的數(shù)據(jù),另外可以看到引入vue
的代碼,這表明Vue
是沒(méi)有包含在打包后的文件里的纷铣,這是vue-cli
刻意為之的卵史,這在通過(guò)構(gòu)建工具使用打包后的庫(kù)來(lái)說(shuō)是很方便的,但是我們是需要直接在頁(yè)面運(yùn)行的時(shí)候動(dòng)態(tài)的引入組件关炼,不經(jīng)過(guò)打包工具的處理程腹,所以exports
、module
儒拂、define
寸潦、require
等對(duì)象或方法都是沒(méi)有的,沒(méi)有沒(méi)關(guān)系社痛,我們可以手動(dòng)注入见转,我們使用第二個(gè)else if
,也就是我們需要手動(dòng)來(lái)提供exports
對(duì)象和require
函數(shù)蒜哀。
當(dāng)我們點(diǎn)擊Vue
組件類型的小程序時(shí)我們使用axios
來(lái)請(qǐng)求組件的js
文件斩箫,獲取到的是js
字符串,然后使用new Function
來(lái)執(zhí)行js
撵儿,注入我們提供的exports
對(duì)象和require
函數(shù)乘客,然后就可以通過(guò)exports
對(duì)象獲取到組件導(dǎo)出的數(shù)據(jù),最后再使用動(dòng)態(tài)組件渲染出組件即可淀歇,同時(shí)如果存在樣式文件的話也要?jiǎng)討B(tài)加載樣式文件易核。
<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)
}
// 請(qǐng)求組件js資源
let { data } = await axios.get(payload.value.url);
// 執(zhí)行組件js
let run = new Function('exports', 'require', `return ${data}`)
// 手動(dòng)提供exports對(duì)象和require函數(shù)
const exports = {}
const require = () => {
return Vue;
}
// 執(zhí)行函數(shù)
run(exports, require)
// 獲取組件選項(xiàng)對(duì)象,扔給動(dòng)態(tài)組件進(jìn)行渲染
comp.value = exports.stopwatch.default
} catch (error) {
console.error(error);
}
};
執(zhí)行完組件的js
后我們注入的exports
對(duì)象如下:
所以通過(guò)exports.stopwatch.default
就能獲取到組件的選項(xiàng)對(duì)象傳遞給動(dòng)態(tài)組件進(jìn)行渲染浪默,效果如下:
大功告成牡直,最后我們?cè)偕晕⑿薷囊幌拢驗(yàn)橥ㄟ^(guò)exports.stopwatch.default
獲取組件導(dǎo)出內(nèi)容我們還需要知道組件的打包名稱stopwatch
纳决,這顯然有點(diǎn)麻煩碰逸,我們可以改成一個(gè)固定的名稱,比如就叫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
參數(shù)由之前的${name}
改成寫死comp
即可饵史,打包結(jié)果如下:
exports
對(duì)象結(jié)構(gòu)變成如下:
然后我們就可以通過(guò)comp
名稱來(lái)應(yīng)對(duì)任何組件了comp.value = exports.comp.default
。
當(dāng)然胜榔,小程序關(guān)閉的時(shí)候不要忘記刪除添加的樣式節(jié)點(diǎn)胳喷。
總結(jié)
本文簡(jiǎn)單了嘗試兩種網(wǎng)站功能的擴(kuò)展方式,各位如果有更好的方式的話可以評(píng)論留言分享苗分,線上效果演示地址http://lxqnsys.com/d/。