上一篇对知识库做了个大概的介绍,接下来就从前端开始吧!go~~
一、介绍
前端框架及组件库使用介绍
包管理:pnpm + monorepo
微前端:micro-app
前端框架:react + react-router + zustand
UI组件:antd + shadcn/ui
接口请求:axios + fetch(下载使用)
二、项目结构
项目中,我使用的是 pnpm + monorepo 进行项目的包管理和应用管理。对于单个代码库,多应用的模式,这样的项目代码目录结构,确实很方便。
我的项目结构:
- apps 存放应用
- dist 存放打包后文件(按应用目录分)
- packages 存放公共的库包
- scripts 存放功能脚本
三、构建工具
构建工具,我使用的是webpack5(为啥没选择vite呢,可能是不喜欢吧)
项目中 packages/builder,是基于webpack5进行的构建封装。
之前基于webpack封装构建包的时候,每个项目都需要单独安装webpack + webpack-dev-server,着实比较麻烦,这次参考网上一个大佬的方式,还挺方便的(想把之前参考的大佬文章放这的,结果没找到)
// packages/builder/dev-builder.ts
/**
* 开发环境构建
*/
export default (config: IConfigOption) => {
let _config: Webpack.Configuration = {
mode: 'development',
devtool: 'inline-source-map',
target: 'web'
}
// ...
// 常规webpack配置
// 合并配置
const devConfig: Webpack.Configuration = merge(_config, config as any)
// 主要就是用WebpackDevServer的实例来操作
// devConfig,devConfig.devServer 这个就是webpack正常的配置参数了
const compiler = Webpack(devConfig)
const devServer = new WebpackDevServer(devConfig.devServer, compiler)
// 启用服务器
const runServer = async () => {
console.log(chalk.greenBright(`👉 开发环境启动,端口号: ${devConfig.devServer?.port} \r\n`))
await devServer.start()
}
runServer()
}
项目中使用就比较方便,新建个webpack.config.js(这个参考了vue.config.js)
// shell-web/webpack.config.js
import { basename, dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from '@mkmp/builder'
export default defineConfig((mode: string) => {
const __dirname = dirname(fileURLToPath(import.meta.url))
return {
entry: resolve(__dirname, './src/index.tsx'),
output: {
clean: true,
filename: 'js/[name].[contenthash:8].js',
path: resolve(__dirname, `../../dist/${basename(__dirname)}`),
publicPath: '/'
// 子应用的配置(配置方式和baseroute及路由模式有关)
// publicPath: mode === 'production' ? '/cp-kbs/' : '/',
},
resolve: {
alias: {
'@': resolve(__dirname, './src')
}
},
externals: {
react: 'React',
'react-dom': 'ReactDOM',
'react-router-dom': 'ReactRouterDOM',
'kk-ui': 'KKUI',
axios: 'axios'
},
sass: {
additionalData: `@import "@/assets/styles/normalize.scss";@import "@/assets/styles/mixin.scss";`
},
devServer: {
port: process.env.PORT || 4001
}
}
})
注:打包的目录我做了调整,应用打包输出都放到了dist目录下,并且以应用文件夹名作为区分。
四、启动应用
项目采用的是pnpm+monorepo的包管理方式,所以启动项目的时候要输入:
pnpm -F shell-web start。
这么一长串输入着实麻烦,那有没什么命令行交互式方面的库呢? 答案是肯定的:有。
经过对比,我最终选择的是:
inquirer:基于 Node.js 的命令行交互式用户界面的库
execa:调用 shell 命令和本地外部程序的JavaScript库
detect-port:端口检测器
实现方式:
// scripts/start.ts
import inquirer from 'inquirer'
import { execa } from 'execa'
import detectPort from 'detect-port'
import AppConfig from '../apps.json'
type IAppOption = {
name: string
packageName: string
dir: string
port: number
env: object
}
type IAppConfig = {
[key: string]: IAppOption
}
// 解析参数
// const argvs = minimist(process.argv.slice(2))
// 已占用端口列表
const occupiedList: any = []
// 应用配置
const _Configs: IAppConfig = AppConfig
// 应用配置Key
const _ConfigKeys: Array<string> = Object.keys(_Configs)
// 检测端口
const checkPorts = _ConfigKeys.map(
(key) =>
new Promise((resolve) => {
detectPort(_Configs[key].port).then((port) => {
resolve({
name: _Configs[key].name,
packageName: _Configs[key].packageName,
isOccupied: port !== _Configs[key].port
})
})
})
)
/**
* 启动命令
*/
const runInquirerCommand = () => {
inquirer
.prompt([
{
name: 'startPackage',
type: 'list',
message: '请选择要启动的应用',
choices: _ConfigKeys.map((key) => {
const { name, packageName, port } = _Configs[key]
const occupied = occupiedList.find((item: IAppOption) => item.packageName === packageName)
return {
name: `${name}(${packageName}:${port})`,
value: key,
disabled: occupied && occupied.isOccupied ? '已启动' : false
}
})
}
])
.then(async (answers) => {
const { packageName, dir, port, env } = _Configs[answers.startPackage]
execa('pnpm', ['run', 'start'], {
cwd: `./${dir}/${packageName}`,
stdio: 'inherit',
env: { PORT: port.toString(), NODE_ENV: 'development', ...env }
})
})
}
Promise.all(checkPorts).then((ports: any) => {
occupiedList.push(...ports)
runInquirerCommand()
})
最终效果:
五、React集成
1、目录结构
应用的目录结构,我参考大佬卡颂这篇文章《一个简洁、强大、可扩展的前端项目架构是什么样的?》
2、引入React
React技术栈,我采用的是umd方式引入。至于引入的文件,我是将各库打包后的文件内容手工进行合并的。
具体包含:react、react-dom、@remix-run/router、react-router、react-router-dom、zustand、axios
(大伙要是有更好的办法,欢迎留言)
// 主应用index.html文件引入。global属性是micro-app提供的缓存文件方式
<script global src="<%=process.env.APP_ASSET_URL %>/common/core/index.min.js"></script>
// 子应用index.html文件引入。exclude属性是micro-app提供的资源过滤
<script exclude src="<%=process.env.APP_ASSET_URL %>/common/core/index.min.js"></script>
注:使用umd方式引入react技术栈的时候,我遇到个ts报错提示:找不到“react”。后来排查了好久,发现是webpack5中,我使用的是“esbuild-loader”,而“esbuild-loader”的预设没起作用。后来尝试无果后,还是换回了“babel-loader”。
接下来就属于react项目的常规操作了,集成路由、状态管理等,就不再赘述了。
六、Micro-App集成
介绍篇也说到,使用micro-app,主要目的是将模块按子应用进行分开,相互独立,这样在后期扩展和维护会方便很多,毕竟在软件生命周期中,开发周期相比维护周期,可以忽略了。
既然用了微前端,那子应用的相关配置信息,就要考虑如何维护了。
1、接入方式
常用的方式
方式1:主应用加配置文件
直接在主应用加个配置文件,每次新增子应用时,修改配置文件,然后打包主应用上传更新。 新增子应用时,要重新打包主应用并发布。
方式2:增加配置文件,外链访问配置文件
在1的基础上,解决了更新配置文件不需要发布主应用的问题。
方式3:配置记录到数据库
配置写入到数据库,前端增加配置页面,后端提供API接口获取(获取时增加redis缓存)。
最终选择
方式1:每次增加修改子应用,都要发布主应用,不是我想要的。
方式2:获取配置文件如何进行身份校验,我没解决,所以也放弃。
方式3:我目前使用的方式。选择这个方式的另一个原因就是,我想将菜单加到每个应用下面,所以数据库存一份,也挺合理。
2、主应用集成micro-app
// /src/index.tsx
import microApp from '@micro-zoe/micro-app'
// 初始化micro-app
microApp.start({
// 此处我选择的是 native 模式,主要原因就是地址栏看着舒服
'router-mode': 'native',
plugins: {
global: [
{
processHtml: (code: string) => {
// 将主应用全局变量注入,防止子应用获取不到
return code.replace(
'<head>',
`<head><script>(function(window){
['React', 'ReactDOM', 'ReactRouter', 'ReactRouterDOM','RemixRouter', 'axios'].forEach((key) => {
window[key] = window.rawWindow[key]
})
})(window);</script>`
)
}
}
]
}
})
注:micro-app使用的是1.0.0-rc.5版本,react框架我采用的是umd的方式引入。在使用umd方式引入react的时候,遇到了个问题,就是子应用获取不到window下的react对象,原因就是micro-app子应用要通过window.rawWindow、window.rawDocument 获取真实的window、document。所以就按上述方式,做了一层处理。
3、子应用集成micro-app
// /src/index.tsx
import { ConfigProvider, zhCN } from 'kk-ui'
import { AppProvider } from '@/provider'
import App from './App'
const _window = window as any
function mount() {
const root = _window.ReactDOM.createRoot(document.getElementById('child-root') as HTMLElement)
root.render(
<ConfigProvider locale={zhCN}>
<AppProvider>
<App />
</AppProvider>
</ConfigProvider>
)
console.log('微应用child-kbs加载成功')
}
// 将卸载操作放入 unmount 函数
function unmount() {
_window.ReactDOM.unmountComponentAtNode(document.getElementById('child-root')!)
console.log('微应用child-kbs卸载了')
}
// 微前端环境下,注册mount和unmount方法
if (_window.__MICRO_APP_ENVIRONMENT__) {
_window[`micro-app-${_window.__MICRO_APP_NAME__}`] = { mount, unmount }
} else {
// 非微前端环境直接渲染
mount()
}
七、路由集成
micro-app集成好了,那接下来就是前端调取接口获取应用,进行路由的渲染了。
1、主应用中集成子应用路由
// /src/router/routes.tsx
import { checkFunctionCode, INavItem, IAppItem } from '@mkmp/core'
/**
* 创建主应用模块路由
* @param navs
*/
export const createModuleRoutes = (navs: Array<INavItem>) => {
// 加载所有模块路由
const files = (require as any).context('./modules', false, /\.tsx$/)
const moduleRoutes = [] as any
files.keys().forEach((key: string) => {
const item = files(key)
moduleRoutes.push(...item.default)
})
// 权限校验
return checkFunctionCode(moduleRoutes, navs)
}
/**
* 创建微前端路由
* @returns
*/
export const createMicroAppRoutes = (user: any, apps: Array<IAppItem>, navs: Array<INavItem>, permissions: any) => {
const microRoutes = []
for (const app of apps) {
if (app.isMain || !app.url) {
continue
}
microRoutes.push({
id: Symbol(app.code),
path: `${app.baseRoute}*`,
element: (
<micro-app
name={app.code}
url={app.url}
baseroute={app.baseRoute}
destroy={true}
onCreated={() => {
console.log(`${app.name} 创建了`)
}}
onBeforemount={() => {
console.log(`${app.name} 即将被渲染`)
}}
onMounted={() => {
console.log(`${app.name} 已经渲染完成`)
}}
onUnmount={() => {
console.log(`${app.name} 卸载了`)
}}
onError={() => {
console.log(`${app.name} 加载出错了`)
}}
onDataChange={(e: CustomEvent) => {
console.log(`来自子应用 ${app.name} 的数据: ${app.name} 即将被渲染`, e.detail.data)
}}
></micro-app>
)
})
}
return microRoutes
}
注:权限的校验方式,就不在此处细说了。计划放到下一篇,对一些功能点进行细说
3、主应用创建路由
// /src/router/index.tsx
import { useState, useEffect } from 'react'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import microApp from '@micro-zoe/micro-app'
import { lazily } from '@mkmp/core'
import { useAuth } from '@/hooks'
import { useAppStore } from '@/stores'
import { Loading } from '@/components'
import { BasicLayout } from '@/features/layout'
import { Login } from '@/features/auth'
import { createModuleRoutes, createMicroAppRoutes } from './routes'
const { NotFound } = lazily(() => import('@/features/common'))
/**
* 创建router
*/
const router = createBrowserRouter([
{ path: '/', element: <BasicLayout />, children: [] },
{ path: '/login', handle: {
name: 'login'
// 路由格式示例
// code: 'login' // 用于路由权限校验
// isPublic: true // 路由无需权限校验
}, element: <Login /> },
{ path: '*', Component: NotFound }
])
/**
* 创建路由
* @returns
*/
const useRouter = () => {
const [loading, setLoading] = useState(true)
const auth = useAuth()
const { initData } = useAppStore()
useEffect(() => {
// 判断是否登录
if (!auth.isAuthenticated) {
const { pathname } = window.location
setLoading(false)
if (pathname !== '/login') {
return window.location.replace(`/login?redirect=${encodeURIComponent(pathname)}`)
}
return
}
initData().then(({ user, apps, navs, permissions }) => {
// 集成应用内模块路由
const moduleRoutes = createModuleRoutes(navs)
// 创建子应用路由
const appRoutes = createMicroAppRoutes(user, apps, navs, permissions)
router.routes[0].children = moduleRoutes.concat(appRoutes)
// 注册主应用路由
microApp.router.setBaseAppRouter(router)
const { pathname, search } = window.location
if (pathname === '/') {
// 固定默认是知识库首页
router.navigate(`/kbs/cloud/plan`, { replace: true })
} else {
// replace当前路由,为了触发路由匹配。
// 如果不进行此步操作,路由是能正常匹配,但是拿不到路由中的handle属性,这点我没怎么想明白原因
router.navigate(`${pathname}${search}`, { replace: true })
}
setLoading(false)
})
}, [])
if (loading) {
return <Loading />
}
return <RouterProvider router={router} />
}
export default useRouter
注:路由中replace那一步,我没太明白,后来请教了《通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)》 这篇文章的作者,才解决。 我项目中路由处理的方式,也大部分参考了他的文章,在此感谢。
八、UI组件库
目前项目中使用的是antd + shadcn/ui。
为啥会进行混搭呢? 其实这个是我在这个项目中,技术上处理的一个失败点。
我本来计划是使用shadcn/ui。项目上安装后有源代码,样式、逻辑修改方便,所以会很灵活。但是灵活也是需要成本的。最后经过考虑,所以就进行了混搭,然后想后期再进行逐步替换掉antd。
引入UI组件库的方式,同引入react一样,还是采用umd形式。
// 主应用index.html文件引入。global属性是micro-app提供的缓存文件方式
<link global href="<%=process.env.APP_ASSET_URL %>/common/ui/index.min.css" rel="stylesheet" type="text/css" />
<script global src="<%=process.env.APP_ASSET_URL %>/common/ui/index.min.js"></script>
// 子应用index.html文件引入。exclude属性是micro-app提供的资源过滤
<link exclude href="<%=process.env.APP_ASSET_URL %>/common/ui/index.min.css" rel="stylesheet" type="text/css" />
<script exclude src="<%=process.env.APP_ASSET_URL %>/common/ui/index.min.js"></script>
九、应用布局
整个系统,我是加了个基础的布局(BasicLayout),主要包含三部分:左侧导航、头部区域、内容区域。这也都是常规操作,没啥好说的。
但是在确定布局前,我纠结了一个问题,如果子应用不需要左侧导航、头部区域呢, 这要如何处理?
我考虑的方式:
1、应用自己实现布局:每个应用自己实现布局,数据从主应用下发到子应用。那如果有10个子应用,就要实现10遍。或者提供个业务组件... 额,不是我想要的
2、主应用实现布局:主应用实现一套布局,然后子应用以消息的形式告诉主应用需要显示哪一块。
感觉方式2更符合我的要求。
实现的过程中,有个点又让我纠结了(有点强迫症,太难了)。
既然是子应用发消息告诉主应用,那肯定要主应用加载完子应用后,子应用才能触发消息,在这个时间上,主应用的左侧导航和头部区域,肯定是一直显示的。 如果我直接刷新子应用的无导航页面,就会出现左侧和头部内容,一闪又消失的情况,体验不是那么好。
哈哈,在一阵纠结后,既然你会闪,那我干脆主应用的布局页上左侧导航、头部区域默认就不显示。而是否需要显示,我再通过子应用的消息进行通知,顺便在布局页左侧、头部显示的时候加个小动画,那效果是不是比直接闪会好点。
最终按上述实现思路,效果还不错,符合我的要求,哈哈~~
十、最后
至此,项目的主框架大部分就确定、实现好了。
接下来就是细节以及业务功能的实现了。