基于JS技术栈我搬了个知识库 - 前端篇(二)

378 阅读6分钟

基于JS技术栈我搬了个知识库 - 介绍篇(一)

基于JS技术栈我搬了个知识库 - 前端篇(二)

上一篇对知识库做了个大概的介绍,接下来就从前端开始吧!go~~

一、介绍

前端框架及组件库使用介绍

包管理:pnpm + monorepo

微前端:micro-app

前端框架:react + react-router + zustand

UI组件:antd + shadcn/ui

接口请求:axios + fetch(下载使用)

二、项目结构

项目中,我使用的是 pnpm + monorepo 进行项目的包管理和应用管理。对于单个代码库,多应用的模式,这样的项目代码目录结构,确实很方便。

我的项目结构:

image.png

  • 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()
})

最终效果: image.png

五、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.rawWindowwindow.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更符合我的要求。

实现的过程中,有个点又让我纠结了(有点强迫症,太难了)。

既然是子应用发消息告诉主应用,那肯定要主应用加载完子应用后,子应用才能触发消息,在这个时间上,主应用的左侧导航和头部区域,肯定是一直显示的。 如果我直接刷新子应用的无导航页面,就会出现左侧和头部内容,一闪又消失的情况,体验不是那么好。

哈哈,在一阵纠结后,既然你会闪,那我干脆主应用的布局页上左侧导航、头部区域默认就不显示。而是否需要显示,我再通过子应用的消息进行通知,顺便在布局页左侧、头部显示的时候加个小动画,那效果是不是比直接闪会好点。

最终按上述实现思路,效果还不错,符合我的要求,哈哈~~

十、最后

至此,项目的主框架大部分就确定、实现好了。

接下来就是细节以及业务功能的实现了。