b79907628d31f3a22de92a9b57713a5a.png

前言

前端技术的一个特点是项目之间会使用很多第三方npm包,在学习时,如果我们只关注其中一个框架,是很难有手感的,我自身就是一个具体的例子,花时间阅读完Vue3文档后,具有灵活运用还有一段距离,这个阶段就需要多看他人成熟项目是怎么编写的,多看具体的实例,本文记录我阅读element-plus-admin这个项目时的细节。

element-plus-admin(https://github.com/hsiangleev/element-plus-admin)使用了Vue中最新的技术栈(Vue3 + Vite + Vue-router 4 + Pinia + element-plus + tailwindcss)构建经典后台,理解其他的代码,方便日后“参考”着开发自己的前端项目。

注意,element-plus-admin项目中并没有使用Vue3最新的写法(因为当时Vue3的一些语法还没推出),但不妨碍我们学习,关于Vue3最新的语法可看:TypeScript+Vue3最新语法糖实践

VsCode Debug Vite构建的Vue3项目

看项目代码,第一件事不是直接看,而是应该将项目运行起来,然后再通过Debug的方式去调试其中的代码,这比单纯的静态分析代码快速靠谱很多。

Vue3官网文档中,只有如何使用VScode Debug Webpack构建的Vue项目的描述,对于Vite构建的项目并没有描述,经过查阅和实践,发现很简单。

点击VSCode中的debug,然后在.vscode目录下生成launch.json文件,其配置如下:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "pwa-msedge",
      "request": "launch",
      "name": "element-plus-admin",
      "url": "http://localhost:3002",
      "webRoot": "${workspaceFolder}"
    }
  ]
}

这里,我使用Edge浏览器来调试,使用Chrome也是可以的(测试过,可以的),默认生成的launch.json,其url配置如果不符合当前项目启动时的url,那么就需要改一下。

调整好launch.json配置后,直接通过debug形式运行项目,此时VSCode会唤醒Edge并尝试访问launch.json中配置的url,这里就是访问http://localhost:3002,为了让其正常访问,你还需要在命令行中启动项目(如:npm run dev、yarn dev之类的),项目启动后,Edge访问成功,VSCode就会基于你下的断点,停到相应的代码处,你就可以愉快的调试项目了,如下图:

3b3e93b63d5ef6774c226f33a929e957.png

项目启动流程

Vite项目会以项目根目录的index.html为项目入口,element-plus-admin也不例外,在index.html中,发现了它是使用了百度统计的代码,估计是统计有多少人使用了它的项目,代码如下:

<body>
  <div id="app"></div>
  <script type="module" src="/src/main.ts"></script>
  <script>
    var _hmt = _hmt || [];
    // 百度统计代码
    (function() {
      var hm = document.createElement("script");
      hm.src = "https://hm.baidu.com/hm.js?6f30ec463f12087163460a93581d2f3d";
      var s = document.getElementsByTagName("script")[0]; 
      s.parentNode.insertBefore(hm, s);
    })();
    </script>
</body>

果断注释掉百度统计相关的代码。

index.html中引入了/src/main.ts,标准的vite项目流程,看到main.ts的代码,主要是引入了各种组件:

const app = createApp(App)
direct(app)
app.use(ElementPlus)
app.use(router)
app.use(pinia)
app.component('SvgIcon', SvgIcon)

// 载入Icon系统 - 全局载入的形式
const ElIconsData = ElIcons as unknown as Array<() => Promise<typeof import('*.vue')>>
for (const iconName in ElIconsData) {
    app.component(`ElIcon${iconName}`, ElIconsData[iconName])
}
  
app.mount('#app')

其中Element-plus是饿了么提供的CSS样式库(支持Vue3),router则是vue-router,用于实现页面的路由功能,而pinia则是新一代的状态管理工具(用于替代vuex)。Element-plus提供了一套Icon样式,要在项目中随意使用,需要将其全局载入,上述代码中,通过for循环的方式,将Icon全局载入。

主题样式的切换

接着看到App.vue中的代码,template通过ElconfigProvider标签包裹,该标签是Element-plus中用于提供国际化的标签,使用该标签包裹,页面中的文字内容便可以自动切换不同的语言,代码如下:

<template>
    <!-- 国际化 https://segmentfault.com/a/1190000041239124 -->
    <ElConfigProvider :locale='locale'>
        <!-- vue-router 渲染标签 -->
        <router-view />
    </ElConfigProvider> 
</template>

setup() {
    changeThemeDefaultColor()
    const { getSetting } = useLayoutStore()

    // 重新获取主题色
    const f = () => {
        let themeArray = theme()
        return getSetting.theme >= themeArray.length ? themeArray[0] : themeArray[getSetting.theme]
    }

    let themeStyle:Ref<ITheme> = ref(f())
    // 监控变化
    watch(() => getSetting.theme, () => themeStyle.value = f())
    watch(() => getSetting.color.primary, () => themeStyle.value = f())

    // 省略剩余代码...

上述代码中,通过theme函数获得主题数组,然后基于用户的选择,获得数组中相应主题的配色,完整的主题替换逻辑比较多,这里抽一个细节来剖析,整体原理是一致的。

通过f函数,获得如下结构的对象:

{
    tagsActiveColor: '#fff',
    tagsActiveBg: color.primary,
    mainBg: '#f0f2f5',
    sidebarColor: '#fff',
    sidebarBg: '#001529',
    sidebarChildrenBg: '#000c17',
    sidebarActiveColor: '#fff',
    sidebarActiveBg: color.primary,
    sidebarActiveBorderRightBG: '#1890ff'
},

对象很被ref函数包裹,成为响应式数据,以对象中的tagsActiveColor属性为例,通过全局搜索,可以发现,它在layout-tags-active这个css标签中,代码如下:

.layout-tags-active {
    background-color: v-bind(themeStyle.tagsActiveBg);
    color: v-bind(themeStyle.tagsActiveColor);
}

而layout-tags-active会被使用在/src/layout/components/tags.vue中,代码如下:

<span
    v-for='v in tagsList'
    :key='v.path'
    :ref='getTagsDom'
    class='border border-gray-200 px-2 py-1 mx-1 cursor-pointer'
    <!-- 使用在这里 -->
    :class='{"layout-tags-active": v.isActive}'
    @contextmenu.prevent='contextRightMenu(v,$event)'
>

这样,用户选择不同样式时,tagsActiveColor属性对于的颜色不同,那么上述代码的span其显示的样式就会不同。

路由处理

element-plus-admin中的路由处理非常经典,在用户未登录时,会自动跳到登录页面,当用户token失效时,也会立刻调整到登录页面。

当我们看完App.vue后,会好奇最终的页面是怎么显示的?很明显与vue-router脱不开关系,因为页面会通过router-view标签完成渲染,而该标签主要由vue-router处理,需要注意,因为我们看的是Vue3项目,所以vue-router的版本需要大于4,否则还不支持Vue3中的一些写法。

看到element-plus-admin项目的/src/router目录,vue-router相关的逻辑在main.ts的import中就导入了:

/src/main.ts

import router from '/@/router/index'
import '/@/permission'

/src/router/index.ts定义了项目会跳转的路径,形式如下:

export const allowRouter:Array<IMenubarList> = [
    {
        name: 'Dashboard',
        path: '/',
        component: Components['Layout'],
        redirect: '/Dashboard/Workplace',
        meta: { title: '仪表盘', icon: 'el-icon-eleme' },
        children: [
            {
                name: 'Workplace',
                path: '/Dashboard/Workplace',
                component: Components['Workplace'],
                meta: { title: '工作台', icon: 'el-icon-tools' }
            }
        ]
    },
    // 省略剩余代码...

这是vuex-router的写法,没啥好说的,接着主要来看一下/src/permission.ts的逻辑,该文件的代码实现了权限检测、侧边栏路由载入、标签回切以及无权限重定向到login页面的逻辑。

在permission.ts中,会引入router并调用beforeEach函数执行每次访问路由前要执行的逻辑。

载入侧边栏路由

所谓侧边栏,如下图:

fab8ca0ff74748823fbbc1622e63a4bf.png

当项目第一次运行时,我们会访问根路径(即 / ),此时会先执行这里的逻辑,因为第一次执行,会触发未添加侧边栏路由的判断,从而执行添加路由带状态缓存中的逻辑,后续在router中,再使用此时存储的路由:

// /src/permission.ts

// 判断是否还没添加过路由
if(getMenubar.menuList.length === 0) {
    await GenerateRoutes()
    await getUser()
    for(let i = 0;i < getMenubar.menuList.length;i++) {
        router.addRoute(getMenubar.menuList[i] as RouteRecordRaw)
    }
    concatAllowRoutes()
    return to.fullPath
}

上述代码,通过getMenubar函数判断当前是否载入侧边栏路由,为了理清逻辑,先看一下侧边栏是如何展示的。

当用户完成登录后,会访问/src/layout/index.vue构建的页面,该页面由多个组件构建,其中就包括构成页面侧边栏的组件,代码如下:

// src/layout/index.vue

<div
    v-if='getSetting.mode === "vertical" || getMenubar.isPhone'
    class='layout-sidebar flex flex-col h-screen transition-width duration-200 shadow'
    :class='{ 
        "w-64": getMenubar.status === 0 || getMenubar.status === 2, 
        "w-0": getMenubar.status === 3, 
        "w-16": getMenubar.status === 1, 
        "absolute z-30": getMenubar.status === 2 || getMenubar.status === 3, 
    }'
>
    <div class='layout-sidebar-logo flex h-12 relative flex-center shadow-lg'>
        <img class='w-8 h-8' :src='icon'>
        <span v-if='getMenubar.status === 0 || getMenubar.status === 2' class='pl-2'>hsianglee</span>
    </div>
    <div class='layout-sidebar-menubar flex flex-1 overflow-hidden'>
        <el-scrollbar wrap-class='scrollbar-wrapper'>
            <layout-menubar />
        </el-scrollbar>
    </div>
</div>

其对于的组件为LayoutMenubar.vue,查看相关代码,可知,侧边栏具体的逻辑在LayoutMenubar.vue中,代码如下:

<template>
           <!-- 省略... -->
          <menubar-item v-for='v in filterMenubarData' :key='v.path' :index='v.path' :menu-list='v' />
           <!-- 省略... -->
</template>

<script lang='ts>
export default defineComponent ({
    // 省略...
    setup() {
        const route = useRoute()
        const router = useRouter()
        const { getMenubar, setRoutes, changeCollapsed, getSetting } = useLayoutStore()
        // 获得侧边栏路径
        const filterMenubarData = filterMenubar(getMenubar.menuList)
        setRoutes(filterMenubarData)
    // 省略...

</script>

从上述代码可知,侧边栏路径的数据来自于getMenubar函数,该函数会从状态管理数据(由pinia实现)中获取menubar变量的数据,而menubar变量的数据则来自于permission.ts中的逻辑,回看到permission.ts中的if判断,项目第一次加载时,侧边栏路由数据为空,则会执行if判断中的逻辑,其中的GenerateRoutes函数会获取侧边栏数据,代码如下:

// src/store/modules/layout.ts

async GenerateRoutes():Promise<void> {
    const res = await getRouterList()
    const { Data } = res.data
    generatorDynamicRouter(Data)
}


// src/api/layout/index.ts


const api = {
    login: '/api/User/login',
    getUser: '/api/User/getUser',
    getRouterList: '/api/User/getRoute',
    publickey: '/api/User/Publickey'
}


// 请求API,获得用户可以使用的路由
export function getRouterList(): Promise<AxiosResponse<IResponse<Array<IMenubarList>>>> {
    return request({
        url: api.getRouterList,
        method: 'get'
    })
}

没错,侧边栏的数据是通过API请求获得的,其原因是不同的用户可能用于不同的权限,而不同的选择,可以使用的路由是不同的,这里通过API的形式去请求当前用户所能使用的路由,让后端去处理用户权限的问题。

element-plus-admin没有实现后端,而是使用mock.js提供的mock功能,实现了接口响应,并将响应的数据返回,相关的逻辑在:

// /mock/index.ts

export default [
    // 省略...
    {
        url: '/api/User/getRoute',
        method: 'get',
        timeout: 300,
        response: (req: IReq) => {
            const userName = checkToken(req)
            if(!userName) return responseData(401, '身份认证失败', '')
            // 通过getRoute函数返回当前用户用于使用权限的路由
            return responseData(200, '', getRoute(userName))
        }
    },

判断登录与Token检测

用户登录后,会获得Token,每次访问路由前,会判断Token是否存在,如果不存在,则说明用户未登录,此时跳转到登录页面,此外,也会检Token是否失效,相关代码如下:

/src/permission.ts

// 判断是否登录
if(!getStatus.ACCESS_TOKEN) {
    // 返回login url
    return loginRoutePath + (to.fullPath ? `?from=${encode(to.fullPath)}` : '')
}

// 前端检查token是否失效
useLocal('token')
    .then(d => setToken(d.ACCESS_TOKEN))
    .catch(() => logout())

记录标签的切换

标签的切换如下图所示,用户访问任何路由,都会记录下来,用户后续可以通过标签访问会之前访问过的路由352f4279c7c3870451bbdb7b8b11815e.png

在用户访问任意路由前,都将路由记录起来便可以实现这种效果:

/src/permission.ts

changeTagNavList(to) // 切换导航,记录打开的导航(标签页)

changeTagnavList函数的代码如下:

/src/store/modules/layout.ts

// 切换导航,记录打开的导航
changeTagNavList(cRouter:RouteLocationNormalizedLoaded):void {
    if(!this.setting.showTags) return // 判断是否开启多标签页
    // if(cRouter.meta.hidden && !cRouter.meta.activeMenu) return // 隐藏的菜单如果不是子菜单则不添加到标签
    if(new RegExp('^\/redirect').test(cRouter.path)) return
    const index = this.tags.tagsList.findIndex(v => v.path === cRouter.path)
    this.tags.tagsList.forEach(v => v.isActive = false)
    // 判断页面是否打开过
    if(index !== -1) {
        this.tags.tagsList[index].isActive = true
        return
    }
    const tagsList:ITagsList = {
        name: cRouter.name as string,
        title: cRouter.meta.title as string,
        path: cRouter.path,
        isActive: true
    }
    this.tags.tagsList.push(tagsList)
},

changeTagNavList函数的核心逻辑就是通过访问的路由url构建tagsList,然后将其存到数组中。

登录流程

通过上面对element-plus-admin中路由流程的处理,知道了项目第一次运行时,登录页面会被优先载入,具体的逻辑在/src/views/User/Login.vue中,当用户点击登录时会调用onSubmit函数处理登录逻辑:

/src/views/User/Login.vue

const onSubmit = async() => {
    let { name, pwd } = form
    if(!await validate(ruleForm)) return
    await login({ username: name, password: pwd })
    ElNotification({
        title: '欢迎',
        message: '欢迎回来',
        type: 'success'
    })
}

先看前端是如何实现表单校验的,验证的主要逻辑在validate函数中。

element-plus框架为我们提供了表单验证的写法,看到element-plus文档中表单相关的内容,对表单验证有所介绍,而element-plus-admin项目中使用的正式文档中的写法,具体依赖于async-validator(因为跟文档一致,就不赘述了)。

看到login函数,其具体逻辑如下:

// /src/store/modules/layout.ts

async login(param: loginParam):Promise<void> {
    // 请求后端接口
    const res = await login(param)
    const token = res.data.Data
    // 设置Token
    this.status.ACCESS_TOKEN = token
    setLocal('token', this.status, 1000 * 60 * 60)
    const { query } = router.currentRoute.value
    router.push(typeof query.from === 'string' ? decode(query.from) : '/')
},
  
  
// /src/api/layout/index.ts
  
export function login(param: loginParam):Promise<AxiosResponse<IResponse<string>>> {
  return request({
      url: api.login,
      method: 'post',
      data: param
  })
}

上述代码中,login函数会调用/src/api/layout/index.ts的login函数对后端接口请求,然后将获得的Token通过setLocal函数添加到状态管理器中(pinia),token是有过期时间的,然后将路由重定向到登录页面。

主页的载入

理一理流程,用户访问项目时,会访问根路由,而访问路由前,会有对应的前置函数需要执行,具体逻辑在permission.ts中,其中会检测用户是否登录,如果没有登录会将路由重定向到登录页面,当用户登录完后,会重定向到主页,主页页面的具体逻辑在/src/layout/index.vue,核心页面代码如下:

<template>
    <div class='layout flex h-screen'>
        <div class='layout-sidebar-mask fixed w-screen h-screen bg-black bg-opacity-25 z-20' :class='{"hidden": getMenubar.status !== 2 }' @click='changeCollapsed' />
        <div
            v-if='getSetting.mode === "vertical" || getMenubar.isPhone'
            class='layout-sidebar flex flex-col h-screen transition-width duration-200 shadow'
            :class='{ 
                "w-64": getMenubar.status === 0 || getMenubar.status === 2, 
                "w-0": getMenubar.status === 3, 
                "w-16": getMenubar.status === 1, 
                "absolute z-30": getMenubar.status === 2 || getMenubar.status === 3, 
            }'
        >
            <div class='layout-sidebar-logo flex h-12 relative flex-center shadow-lg'>
                <img class='w-8 h-8' :src='icon'>
                <span v-if='getMenubar.status === 0 || getMenubar.status === 2' class='pl-2'>hsianglee</span>
            </div>
            <div class='layout-sidebar-menubar flex flex-1 overflow-hidden'>
                <el-scrollbar wrap-class='scrollbar-wrapper'>
                    <!-- 侧边栏 -->
                    <layout-menubar />
                </el-scrollbar>
            </div>
        </div>
        <div class='layout-main flex flex-1 flex-col overflow-x-hidden overflow-y-auto'>
            <div class='layout-main-navbar flex justify-between items-center h-12 shadow-sm overflow-hidden relative z-10'>
                <!-- 顶部状态栏 -->
                <layout-navbar />
            </div>
            <div
                v-if='getSetting.showTags'
                class='layout-main-tags h-8 leading-8 text-sm text-gray-600 relative'
            >
                <!-- 状态栏下的标签栏 -->
                <layout-tags />
            </div>
            <div class='layout-main-content flex-1 overflow-hidden'>
                <!-- 主页的内容主题 -->
                <layout-content />
            </div>
        </div>
        <div class='layout-sidebar-sidesetting fixed right-0 top-64 z-10'>
            <!-- 设置按钮 -->
            <layout-side-setting />
        </div>
    </div>

直观如下图:

699bd2687095d0524d095d61f0a962f5.png

/src/layout/index.vue的template非常干净,没啥值传递,所以没啥好说的,我们进去看里面的组件,看看具体是如何使用的。

侧边栏

// /src/layout/components/menubar.vue

<template>
    <el-menu
        :mode='getMenubar.isPhone ? "vertical" : getSetting.mode'
        :default-active='activeMenu'
        :collapse='getMenubar.status === 1 || getMenubar.status === 3'
        :class='{ 
            "el-menu-vertical-demo": true,
            "w-64": getMenubar.status === 0 || getMenubar.status === 2, 
            "w-0": getMenubar.status === 3, 
            "w-16": getMenubar.status === 1, 
            "w-full": getSetting.mode === "horizontal" && !getMenubar.isPhone, 
        }'
        :collapse-transition='false'
        :unique-opened='true'
        @select='onOpenChange'
    >
        <menubar-item v-for='v in filterMenubarData' :key='v.path' :index='v.path' :menu-list='v' />
        <!--  -->
    </el-menu>
</template>

上述代码中,通过el-menu构建目录,在el-menu中通过v-bind语法(简写为:)绑定了很多属性,这些属性与TS中的代码相关联,实现动态调整CSS样式的效果,通过v-on语法(简写为@)绑定了select事件,当用户切换侧边栏时,会调用onOpenChange函数:

// /src/layout/components/menubar.vue

// 点击侧边栏,渲染相应的页面
const onOpenChange = (d: any) => {
    router.push({ path: d })
    getMenubar.status === 2 && changeCollapsed()
}

侧别栏中具体的内容有MenubarItem子组件实现,父组件通过v-for语法渲染多个子组件,子组件的template如下:

// /src/layout/components/menubarItem.vue

<template>
    <!-- v-if v-else 判断是否是子侧边栏 -->
    <el-sub-menu v-if='menuList.children && menuList.children.length > 0' :key='menuList.path' :index='menuList.path'>
        <template #title>
            <component :is='UseElIcon(menuList.meta.icon || "el-icon-location")' />
            <span>{{ menuList.meta.title }}</span>
        </template>
        <el-menu-item-group>
            <menubar-item v-for='v in menuList.children' :key='v.path' :index='v.path' :menu-list='v' />
        </el-menu-item-group>
    </el-sub-menu>

    <el-menu-item
        v-else
        :key='menuList.path'
        :index='menuList.path'
    >
        <component :is='UseElIcon(menuList.meta.icon || "el-icon-setting")' />
        <template #title>
            {{ menuList.meta.title }}
        </template>
    </el-menu-item>
</template>

上述代码中通过v-if与v-else实现侧边栏的嵌套渲染,数据通过props对象从父组件中获取,使用v-if与v-else实现嵌套树形结构的方式值得借鉴。

顶部状态栏

顶部状态栏多数元素都是静态的,这里主要关注其中动态的部分,显示当前路由路径的,效果如下图:

eac7c0f25ad75a2441dce9fde5232e34.png

与之相关的template逻辑如下:

// /src/layout/components/navbar.vue

<!-- 面包屑导航 -->
<div class='px-4'>
  <el-breadcrumb separator='/'>
      <transition-group name='breadcrumb'>
          <el-breadcrumb-item key='/' :to='{ path: "/" }'>主页</el-breadcrumb-item>
          <el-breadcrumb-item v-for='v in data.breadcrumbList' :key='v.path' :to='v.path'>{{ v.title }}</el-breadcrumb-item>
      </transition-group>
  </el-breadcrumb>
</div>

上述代码中,通过v-for指令处理data.breadcrumbList中的数据,相关逻辑如下:

// /src/layout/components/navbar.vue

// 面包屑导航
const breadcrumb = (route: RouteLocationNormalizedLoaded) => {
    const fn = () => {
        const breadcrumbList:Array<IBreadcrumbList> = []
        // 工作台与重定向页不显示面包屑导航
        const notShowBreadcrumbList = ['Dashboard', 'RedirectPage'] // 不显示面包屑的导航
        if(route.matched[0] && (notShowBreadcrumbList.includes(route.matched[0].name as string))) return breadcrumbList
        route.matched.forEach(v => {
            const obj:IBreadcrumbList = {
                title: v.meta.title as string,
                path: v.path
            }
            breadcrumbList.push(obj)
        })
        return breadcrumbList
    }
    let data = reactive({
        breadcrumbList: fn()
    })
    // 监控路由
    // route.path => 路由路径
    watch(() => route.path, () => data.breadcrumbList = fn())
    return { data }
}

setup() {
    const { getMenubar, getUserInfo, changeCollapsed, logout, getSetting } = useLayoutStore()
    const route = useRoute()
    return {
        
        ...breadcrumb(route),

    }
}

上述代码主要是路由路径的处理逻辑,将当前路由路径添加到数组中,没看这里的代码,我还不知道路由对象可以这样使用。

顶部标签栏

阅读顶部标签栏代码时,发现比较多的功能,但很多功能都没有效果,只有代码,估计有一些问题,先不纠结这些有问题的代码,将有效果的代码学习一遍则可,所谓顶部标签栏如下:

acd268da439c786fea5aff1fa90545a7.png

我们在侧边栏访问任意页面,都会在这里生成相应的标签,标签多时,还可以通过滚动条横向滚动,相关HTML 如下:

// /src/layout/components/tags.vue

<!-- el-srcollbar实现滚动条 -->
<el-scrollbar ref='scrollbar' wrap-class='scrollbar-wrapper'>
    <div class='layout-tags-container whitespace-nowrap'>
        <span
            v-for='v in tagsList'
            :key='v.path'
            :ref='getTagsDom'
            class='border border-gray-200 px-2 py-1 mx-1 cursor-pointer'
            :class='{"layout-tags-active": v.isActive}'
            @contextmenu.prevent='contextRightMenu(v,$event)'
        >
            <i v-if='v.isActive' class='rounded-full inline-block w-2 h-2 bg-white -ml-1 mr-1' />
            <router-link :to='v.path'>{{ v.title }}</router-link>
            <!-- 如果只剩下一个Tab,不显示关闭 -->
            <el-icon v-if='tagsList.length>1' class='text-xs hover:bg-gray-300 hover:text-white rounded-full leading-3 p-0.5 ml-1 -mr-1' @click='removeTag(v)'><el-icon-close /></el-icon>
        </span>
    </div>
</el-scrollbar>

标签栏的核心就是利用状态管理的形式,将其他地方操作的逻辑同步到当前组件,然后通过watch方法监听变化,如果发生了变化,再修改template中的样式,核心代码如下:

// /src/layout/components/tags.vue

const { getTags } = useLayoutStore()
const { tagsList, cachedViews } = getTags

修改template的逻辑有点繁杂,主要是处理滚动条的逻辑,代码如下:

// /src/layout/components/tags.vue

// 监听标签页导航
watch(
    () => tagsList.length,
    () => nextTick(() => {
        if(!scrollbar.value) return
        scrollbar.value.update()
        nextTick(() => {
            const itemWidth = layoutTagsItem.value.filter(v => v).reduce((acc, v) => {
                const val = v as HTMLElement
                return acc + val.offsetWidth + 6
            }, 0)
            if(!scrollbar.value) return
            const scrollLeft = itemWidth - scrollbar.value.wrap$.offsetWidth + 70
            if(scrollLeft > 0) scrollbar.value.wrap$.scrollLeft = scrollLeft
        })
    })
)

上述代码中通过Vue3提供的nextTick函数实现在DOM更新后再执行nextTick函数中逻辑的操作,nextTick函数接受的是匿名函数,当DOM更新完后,会执行,nextTick函数的原理与JS异步的原理相关,后续单独开文介绍。

上述代码中的逻辑其实就添加了tagsList.length变动时,使用nextTick函数获取tagsList变动后的DOM执行相应的逻辑。

用户在访问旧标签时,会打开旧标签对应的页面,要实现这个,就需要使用缓存,添加标签缓存的逻辑如下:

// /src/layout/components/tags.vue

const { removeAllTagNav, addCachedViews, removeTagNav } = useLayoutStore()
  onMounted(() => {
      addCachedViews({ name: route.name as string, noCache: route.meta.noCache as boolean })
})

// /src/store/modules/layout.ts

// 添加缓存页面
addCachedViews(obj: {name: string, noCache: boolean}):void {
    if(!this.setting.showTags) return // 判断是否开启多标签页
    if(obj.noCache || this.tags.cachedViews.includes(obj.name)) return
    this.tags.cachedViews.push(obj.name)
},

这里的缓存依旧基于状态管理器实现,不多赘述。

主页内容

主页内容核心HTML如下:

// /src/layout/components/content.vue

<router-view v-slot='{ Component }'>
  <transition name='fade-transform' mode='out-in'>  
      <keep-alive :include='setting.showTags ? data.cachedViews : []'>
          <component :is='Component' :key='key' class='page m-3 relative' />
      </keep-alive>
  </transition>
</router-view>

Vue3提供了transition标签实现类似CSS过渡动画的效果,简单而言,通过transition,你不需要写CSS,就可以得到动画效果。

keep-alive同样是Vue3提供的标签,通过keep-alive可以将组件缓存在内存中,避免DOM的重复渲染,通常会使用keep-alive对常用组件或路由进行缓存,这里就是将主页缓存,只要你访问了这个主页,就将其缓存起来,后续通过标签跳转回之前访问过的主页时,就不需要再次渲染,而且与离开时具有一样的数据。

最外层的router-view则会渲染当前路由下的子路由。

结尾

element-plus-admin还有很多细节没有研究,本文抛砖引玉,大家可以自行将代码拉下来学习,后续会深挖一下element-plus-admin碰到的,但自己不熟悉的部分。

我是二两,下篇文章见。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐