破茧成蝶:从零开始构建独具匠心前端项目框架

本文最后更新于:3 天前

框架构建

安装 | Vue CLI (vuejs.org)

Arco Design Vue

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<BasicLayout />
</template>

<style></style>
<script>
import BasicLayout from "@/layouts/BasicLayout";

export default {
components: { BasicLayout },
};
</script>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<GlobalHeader />
</template>

<script>
import GlobalHeader from "@/components/GlobalHeader";

export default {
name: "BasicLayout",
components: { GlobalHeader },
};
</script>

<style scoped></style>

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<div></div>
</template>

<script>
export default {
name: "GlobalHeader",
};
</script>

<style scoped></style>

image-20240505172201305

路由跳转

router 下的 index.ts:

1
2
3
4
5
6
7
8
9
10
import { createRouter, createWebHistory } from "vue-router";
import { routes } from "@/router/routes";

const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
});

export default router;

router 下的 router.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { RouteRecordRaw } from "vue-router";
import HomeView from "@/views/HomeView.vue";

export const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "首页",
component: HomeView,
},
{
path: "/about",
name: "关于",
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () =>
import(/* webpackChunkName: "about" */ "../views/AboutView.vue"),
},
];

动态导航栏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<a-menu
mode="horizontal"
:selected-keys="selectedKeys"
@menu-item-click="doMenuClick"
>
<a-menu-item
key="0"
:style="{ padding: 0, marginRight: '38px' }"
disabled
>
<div class="title-bar">
<img class="logo" src="../assets/oj-logo.svg" />
<div class="title">OJ</div>
</div>
</a-menu-item>
<a-menu-item v-for="item in routes" :key="item.path">
{{ item.name }}
</a-menu-item>
</a-menu>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { ref } from "vue";
import { useRouter } from "vue-router";
import { routes } from "@/router/routes";

const router = useRouter();
// 默认主页
const selectedKeys = ref(["/"]);

// 路由跳转后,更新选中的菜单项
router.afterEach((to, from, failure) => {
selectedKeys.value = [to.path];
});

// 跳转路由
const doMenuClick = (key: string) => {
router.push({
path: key,
});
};

效果如下:

image-20240505180151544

全局状态管理

vuex/examples/classic/shopping-cart/store/index.js at main · vuejs/vuex (github.com)

开始 | Vuex (vuejs.org)

store 下的 user.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// initial state
import { StoreOptions } from "vuex";

export default {
namespaced: true,
state: () => ({
loginUser: {
userName: "未登录",
},
}),
actions: {
async getLoginUser({ commit, state }, payload) {
commit("updateUser", { userName: "memory" });
},
},
mutations: {
updateUser(state, payload) {
state.loginUser = payload;
},
},
} as StoreOptions<any>;

index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
import { createStore } from "vuex";
import user from "@/store/user";

export default createStore({
state: {},
getters: {},
mutations: {},
actions: {},
modules: {
user,
},
});

main.ts

1
2
3
4
import store from "./store";

createApp(App).use(ArcoVue).use(store).use(router).mount("#app");

store 原理(执行流程)

1
2
3
4
5
6
// 使用 store
setTimeout(() => {
store.dispatch("user/getLoginUser", {
userName: "回忆如初",
});
}, 3000);

这样引入 store,执行 dispatch方法,根据 actions 下的路径,提供参数,执行 mutations 下的方法。

1
2
3
4
5
actions: {
async getLoginUser({ commit, state }, payload) {
commit("updateUser", payload);
},
},
1
2
3
4
5
mutations: {
updateUser(state, payload) {
state.loginUser = payload;
},
},

mutations 改变了 state 的值,根据传入的参数改变了。

我们尝试在页面获取 store 值,并展示:

1
2
3
4
5
<a-col flex="100px">
<div>
{{ store.state.user?.loginUser?.userName ?? "未登录" }}
</div>
</a-col>

效果如下:

image-20240505182741049

另外,action 这里可以写死,不接受参数:

1
2
3
4
5
actions: {
async getLoginUser({ commit, state }, payload) {
commit("updateUser", { userName: "memory" });
},
},

全局权限管理

关键在于这段逻辑,App.vue 下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import router from "@/router";
import store from "@/store";

router.beforeEach((to, from, next) => {
// 仅管理员可见,判断当前用户是否有权限
if (to.meta?.access === "canAdmin") {
if (store.state.user?.loginUser?.role !== "admin") {
next("/noAuth");
return;
}
}
next();
});

设置路由访问权限,必须为管理员可见:

1
2
3
4
5
6
7
8
{
path: "/auth",
name: "管理员可见",
component: AuthView,
meta: {
access: "canAdmin",
},
},

默认用户权限,测试用:

1
2
3
4
5
6
state: () => ({
loginUser: {
userName: "未登录",
role: "admin",
},
}),

image-20240505190305372

我们发现能正常跳转页面,但这样做:

1
2
3
4
5
6
7
// 使用 store
setTimeout(() => {
store.dispatch("user/getLoginUser", {
userName: "回忆如初",
role: "noAdmin",
});
}, 3000);

三秒过后,该用户不是管理员权限,访问一个管理员可见的页面,直接重定向:

image-20240505190310211

隐藏菜单

构造通用的导航栏组件,根据配置控制菜单栏的显隐

1
2
3
4
5
6
7
8
{
path: "/hide",
name: "隐藏页面",
component: noAuthView,
meta: {
hideInMenu: true,
},
},

过滤,仅展示显示在菜单上的路由数组

1
2
3
<a-menu-item v-for="item in visibleRoutes" :key="item.path">
{{ item.name }}
</a-menu-item>
1
2
3
4
5
6
import { routes } from "@/router/routes";

// 展示在菜单上的路由数组
const visibleRoutes = routes.filter((item, index) => {
return !item.meta?.hideInmenu;
});

除了 根据配置权限隐藏菜单,还需要根据用户权限,只有具有相关权限的用户,才能看到该菜单。

检测用户权限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import ACCESS_ENUM from "@/access/accessEnum";

/**
* 检查权限(判断当前登录用户是否具有某个权限)
* @param loginUser 当前登录用户
* @param needAccess 需要有的权限
* @return boolean 有无权限
*/
const checkAccess = (loginUser: any, needAccess = ACCESS_ENUM.NOT_LOGIN) => {
// 获取当前登录用户具有的权限(如果没有 loginUser,则表示未登录)
const loginUserAccess = loginUser?.userRole ?? ACCESS_ENUM.NOT_LOGIN;
if (needAccess === ACCESS_ENUM.NOT_LOGIN) {
return true;
}
// 如果用户登录才能访问
if (needAccess === ACCESS_ENUM.USER) {
// 如果用户没登录,那么表示无权限
if (loginUserAccess === ACCESS_ENUM.NOT_LOGIN) {
return false;
}
}
// 如果需要管理员权限
if (needAccess === ACCESS_ENUM.ADMIN) {
// 如果不为管理员,表示无权限
if (loginUserAccess !== ACCESS_ENUM.ADMIN) {
return false;
}
}
return true;
};

export default checkAccess;

使用计算属性,使得用户信息发生变更时,触发菜单栏的重新渲染,。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 展示在菜单上的路由数组
const visibleRoutes = computed(() => {
return routes.filter((item, index) => {
// 根据配置过滤菜单
if (item.meta?.hideInmenu) {
return false;
}
//根据用户权限过滤菜单
if (
!checkAccess(store.state.user?.loginUser, item.meta?.access as string)
) {
return false;
}

return true;
});
});

全局项目入口

1
2
3
4
5
6
7
const doInit = () => {
console.log("项目全局入口");
};

onMounted(() => {
doInit();
});

根据后端生成接口

后端接口文档:

image-20240201113411843

我们根据后端接口文档,一键生成前端 HTTP 请求接口:

官方文档:

ferdikoomen/openapi-typescript-codegen: NodeJS library that generates Typescript or Javascript clients based on the OpenAPI specification (github.com)

安装:

1
npm install openapi-typescript-codegen --save-dev

执行命令生成代码:

1
openapi --input http://localhost:8121/api/v2/api-docs?group=memory-oj --output ./generated --client axios

image-20240201112520652

如上,执行成功,成功生成 HTTP 请求接口

image-20240117215206638

image-20240117215050598

image-20240117215227942

MarkDown 编辑器

代码编辑器


破茧成蝶:从零开始构建独具匠心前端项目框架
http://example.com/2024/01/17/破茧成蝶:从零开始构建独具匠心前端项目框架/
作者
Memory
发布于
2024年1月17日
更新于
2024年5月6日
许可协议