为了实践微前端,重构了自己的导航网站

  • A+
所属分类:Web前端
摘要

笔者早期开发了一个导航网站,一直想要重构,因为懒拖了好几年,终于,在了解到微前端大法后下了决心,因为工作上一直没有机会实践,没办法,只能用自己的网站试试,思来想去,访问量最高的也就是这个破导航网站了,于是用最快的时间完成了基本功能的重构,然后准备通过微前端来扩展网站的功能,比如天气、待办、笔记、秒表计时等等,这些功能属于附加的功能,可能会越来越多,所以不能和导航本身强耦合在一起,需要做到能独立开发,独立上线,所以使用微前端再合适不过了。

笔者早期开发了一个导航网站,一直想要重构,因为懒拖了好几年,终于,在了解到微前端大法后下了决心,因为工作上一直没有机会实践,没办法,只能用自己的网站试试,思来想去,访问量最高的也就是这个破导航网站了,于是用最快的时间完成了基本功能的重构,然后准备通过微前端来扩展网站的功能,比如天气、待办、笔记、秒表计时等等,这些功能属于附加的功能,可能会越来越多,所以不能和导航本身强耦合在一起,需要做到能独立开发,独立上线,所以使用微前端再合适不过了。

另外,因为有些功能可能非常简单,比如秒表计时,单独创建一个项目显得没有必要,但是又不想直接写在导航的代码里,最好是能直接通过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,微应用的访问urlhttp://lxqnsys.com/applets/hougelou/,注意最后面的/不可省略,否则微应用的资源路径可能会出现错误。

另外解释一下激活规则activeRule,导航网站的url为:http://lxqnsys.com/d/#/index,微应用的路由规则为:applet/:appletId,所以一个微应用的激活规则为页面urlhash部分,但是这里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 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); }); 

为了实践微前端,重构了自己的导航网站

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 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-clivue-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刻意为之的,这在通过构建工具使用打包后的库来说是很方便的,但是我们是需要直接在页面运行的时候动态的引入组件,不经过打包工具的处理,所以exportsmoduledefinerequire等对象或方法都是没有的,没有没关系,我们可以手动注入,我们使用第二个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

当然,小程序关闭的时候不要忘记删除添加的样式节点。

总结

本文简单了尝试两种网站功能的扩展方式,各位如果有更好的方式的话可以评论留言分享,线上效果演示地址http://lxqnsys.com/d/