时间:2023-05-23 08:21:01 | 来源:网站运营
时间:2023-05-23 08:21:01 来源:网站运营
为了实践微前端,重构了自己的导航网站:笔者早期开发了一个导航网站,一直想要重构,因为懒拖了好几年,终于,在了解到微前端大法后下了决心,因为工作上一直没有机会实践,没办法,只能用自己的网站试试,思来想去,访问量最高的也就是这个破导航网站了,于是用最快的时间完成了基本功能的重构,然后准备通过微前端来扩展网站的功能,比如天气、待办、笔记、秒表计时等等,这些功能属于附加的功能,可能会越来越多,所以不能和导航本身强耦合在一起,需要做到能独立开发,独立上线,所以使用微前端再合适不过了。Vue
单文件来开发,然后页面上动态的进行加载渲染,所以会在微前端方式之外再尝试一下动态组件。本文内的项目都使用Vue CLI创建,Vue使用的是3.x版本,路由使用的都是hash模式
小程序
,首先要实现的是一个小程序的注册功能,详细来说就是:qiankun
:npm i qiankun -S
主应用需要做的很简单,注册微应用并启动,然后提供一个容器给微应用挂载,最后打开指定的url
即可。qiankun.js
文件:// qiankun.jsimport { 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
开头的来判断,是的话代表匹配到了。src
目录新增一个public-path.js
:// public-path.jsif (window.__POWERED_BY_QIANKUN__) { __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;}
然后修改main.js
,增加qiankun
的生命周期函数:// main.jsimport './public-path';import { createApp } from 'vue'import App from './App.vue'import router from './router'let app = nullconst 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' } }}
最后,还需要修改一下路由配置,有两种方式: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
里,不是很优雅。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/
的子路由。<div class="backBtn" v-if="isMicroApp" @click="back"> <span class="iconfont icon-fanhui"></span></div>
const back = () => { router.go(-1);};
这样当小程序为微应用时会显示一个返回按钮,但是有一个问题,当在微应用的首页时显然是不需要这个返回按钮的,我们可以通过判断当前的路由和微应用的activeRule
是否一致,一样的话就代表是在微应用首页,那么就不显示返回按钮:<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);});
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];});
url
满足小程序的激活规则,所以qiankun
会去加载对应的微应用,然而可能这时页面上连微应用的容器都没有,所以会报错,解决这个问题可以在页面加载后判断初始路由是否是小程序的路由,是的话就恢复一下,然后再去注册微应用:if (///index//applet///.test(route.fullPath)) { router.replace("/index");}initMicroApp();
Vue
组件的方式,笔者的想法是直接使用Vue
单文件来开发,开发完成后打包成一个js
文件,然后在导航网站上请求该js
文件,并把它作为动态组件渲染出来。stopwatch
测试组件,目前目录结构如下:App.vue
内容如下:<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
用来导出组件: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.jsconst { 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 = 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 = exports.comp.default
。关键词:导航,实践