13.2 开发全局公共组件
定义在 views 文件夹下的组件是页面组件,其他的则是公共组件。在入口文件中加载的第一个组件是 App.vue,该组件是项目的根组件,用于定义页面结构和加载路由。
13.2.1 开发根组件 App.vue
根组件 App.vue 是所有页面都会加载的组件,主要定义页面结构。分析掘金的页面布局,可以看出是经典的上下布局结构,那么我们也将 App.vue 中的模版编写为上下结构。
(1)编写组件模版部分,一个简单的上下结构代码如下:
<template>
<div id="root-layout">
<div id="header-layout">
<!-- 头部组件区域 -->
</div>
<div id="main-layout">
<!-- 路由区域 -->
</div>
</div>
</template>
(2)定义一个表示头部高度的 CSS 变量,后续会在多处使用。如下:
// styles/variable.css
:root {
--header-height: 60px;
}
(3)在 styles/main.less 中添加根组件样式,此时会用到上一步添加的 CSS 变量,如下:
#root-layout {
overflow: auto;
#header-layout {
height: var(--header-height);
position: fixed;
left: 0;
right: 0;
top: 0px;
background: #fff;
}
#main-layout {
max-width: 1200px;
margin: var(--header-height) auto 0px auto;
}
}
(4)在模版中的“路由区域”引入路由视图组件 RouterView。
掘金的头部区域固定不变,头部以下的区域会根据页面切换变化,很明显这是一个路由区域。那么在这里添加一个 RouterView 组件,就可以将匹配到的路由渲染到这里,如下:
<script setup lang="ts">
import { RouterView } from "vue-router";
</script>
<template>
<div id="root-layout">
...
<div id="main-layout">
<RouterView />
</div>
</div>
</template>
(5)创建公共头部组件 components/cus-header/index.vue 并导入,在头部组件区域添加该组件。代码如下:
<script setup lang="ts">
import CusHeader from "@/components/cus-header/index.vue";
</script>
<template>
<div id="root-layout">
<div id="header-layout">
<CusHeader />
</div>
...
</div>
</template>
此时根组件的基本代码已经编写完成,接着我们编写公共头部组件代码。
13.2.2 开发头部组件
公共头部组件存放在 components/cus-header 文件夹中,因为头部组件中包含的内容比较多,所以划分出 3 个子组件,分别是菜单子组件、消息弹出框子组件和用户弹出框子组件。
下面介绍的子组件都存放在 components/cus-header 文件夹下。
1. 菜单子组件
头部菜单的主要作用是切换页面(也就是切换路由),在该组件上只需要切换首页和沸点页面。首页组件已经创建,现在创建沸点页面组件并添加菜单。
(1)创建沸点页面组件 views/shortmsg/index.vue。
组件创建后,在 router/routes.ts 文件中添加一个新路由,并动态导入该组件:
{
path: '/shortmsg',
name: 'shortmsg',
component: () => import('@/views/shortmsg/index.vue'),
}
(2)创建菜单子组件 menus.vue,使用 RouterLink 组件添加两个菜单并跳转到指定路由:
<template>
<div class="header-menu">
<RouterLink to="/">首页</RouterLink>
<RouterLink to="/shortmsg">沸点</RouterLink>
</div>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
(3)添加组件样式,使菜单与路由匹配时显示蓝色,并添加鼠标滑过的动画。代码如下:
<style lang="less">
.header-menu {
height: var(--header-height);
line-height: var(--header-height);
a {
color: #515767;
font-size: 1.167rem;
position: relative;
&:hover {
font-weight: 500;
&::after {
position: absolute;
content: "";
left: 0.5rem;
right: 0.5rem;
bottom: 0;
height: 2px;
background: var(--el-color-primary);
}
}
&.router-link-active {
color: #1e80ff;
font-weight: 500;
}
}
}
</style>
路由匹配时会自动添加类名 “router-link-active”,修饰该类名就可以添加选中菜单的样式。
2. 消息弹出框子组件
消息组件会展示当前用户未读消息的数量,并用小红点表示。该组件需要请求未读消息接口,界面如图所示:
(1)创建消息子组件 message.vue,并编写组件的模版代码如下:
<div class="header-message">
<el-popover
placement="bottom-end"
:width="144"
:show-arrow="false"
trigger="hover"
transition="none"
:hide-after="50"
popper-class="header-message-popover"
>
<template #reference>
<el-badge
:value="msgInfo.total"
:hidden="msgInfo.total == 0"
class="total-badge"
>
<span class="icon-wrap">
<el-icon :size="25"><BellFilled /></el-icon>
</span>
</el-badge>
</template>
<div class="btn-wrap">
<el-button text>
<span>评论</span>
<el-badge :value="msgInfo.comment" :hidden="msgInfo.comment == 0" />
</el-button>
<el-button text>
<span>赞和收藏</span>
<el-badge :value="msgInfo.praise" :hidden="msgInfo.praise == 0" />
</el-button>
<el-button text>
<span>新增粉丝</span>
<el-badge :value="msgInfo.follow" :hidden="msgInfo.follow == 0" />
</el-button>
</div>
</el-popover>
</div>
(2)根据模版中的结构定义,编写样式如下:
<style lang="less">
.header-message {
margin: 0 14px;
.icon-wrap {
color: #909090;
padding: 4px 12px 4px 4px;
}
}
.header-message-popover {
padding: 8px;
.btn-wrap {
display: flex;
flex-direction: column;
align-items: stretch;
.el-button > span {
width: 100%;
display: flex;
justify-content: space-between;
}
button {
margin: 0;
padding: 20px 12px;
}
}
}
</style>
(3)创建文件 stores/message/index.ts 表示消息 Store,并定义消息状态 msgInfo 和获取消息的方法 getMessage() 如下:
import { defineStore } from 'pinia'
import request from '@/request'
const mesgStore = defineStore('message', {
state: () => ({
msgInfo: {
comment: 0,
praise: 0,
follow: 0,
total: 0,
} as MessageType,
}),
actions: {
getMessage: async () => {
try {
let res = await request.get('/messages/lists')
this.msgInfo = res;
} catch (error) {
console.log(error)
}
},
},
})
export default mesgStore
在方法 getMessage() 中通过请求实例调用获取消息接口,将返回数据赋值给 msgInfo 状态。
(4)在状态文件的同级目录下创建类型文件 type.d.ts,定义使用到的类型 MessageType,其结构与接口返回结构相符,代码如下:
interface MessageType {
comment: number;
praise: number;
follow: number;
total: number;
}
(5)创建消息组件的 JS 代码,并导入消息状态,在组件初始化时调用 getMessage() 方法获取数据,代码如下:
<script setup lang="ts">
import { onMounted } from "vue";
import { RouterLink } from "vue-router";
import { messageStore } from "@/stores";
import { BellFilled } from "@element-plus/icons-vue";
const { msgInfo, getMessage } = messageStore();
onMounted(() => {
getMessage();
});
</script>
3. 用户弹出框子组件
当用户登录后,头部组件的最右侧会显示用户头像,点击头像会出现一个弹出框展示用户信息和快捷按钮,界面如图所示。
(1)创建用户弹出框子组件 user.vue,编写弹出框中的模版代码如下:
<div class="header-userava">
<div class="user-wrap fx">
<el-avatar :size="48" :src="user_info.avatar">
<img src="@/assets/avatar.png" />
</el-avatar>
<router-link :to="'/user/' + user_info._id">
<div class="rcolum">
<div class="name">{{ user_info.username }}</div>
<div class="jue fx">掘力值:<span>{{ user_info.jue_power }}</span></div>
</div>
</router-link>
</div>
<el-divider />
<div class="preview fx">
<div class="item">
<b>{{ user_info.follow_num }}</b>
<div class="label">关注</div>
</div>
<div class="item">
<b>{{ user_info.good_num }}</b>
<div class="label">赞过</div>
</div>
<div class="item">
<b>{{ user_info.fans_num }}</b>
<div class="label">粉丝</div>
</div>
</div>
<el-divider />
<div class="btn-wrap">
<el-button text @click="toRoute('/user/' + user_info._id)"
>个人主页</el-button
>
<el-button text @click="toRoute('/setting/user')">用户设置</el-button>
</div>
<el-divider />
<el-button text style="width: 100%" @click="toLogout">退出登录</el-button>
</div>
上方代码中,“@/assets/avatar.png” 是一个默认头像图,当头像不存在时默认展示该图片。状态 user_info 存储在用户 Store 中,表示当前登录的用户数据,
(2)编写组件 JS 代码,导入 user_info 状态并添加模版中用到的 toRoute() 方法和 toLogout() 方法,代码如下:
<script setup lang="ts">
import { RouterLink } from "vue-router";
import { userStore } from "@/stores";
import { ElMessageBox } from "element-plus";
import router from "@/router";
const { user_info } = userStore();
const toRoute = (path: string) => {
router.push(path);
};
const toLogout = () => {
ElMessageBox.confirm("确认退出登录?", "操作提醒", {
confirmButtonText: "确认",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
localStorage.removeItem("token");
localStorage.removeItem("user_info");
location.href = "/";
});
};
</script>
当退出登录时,清除本地存储当中的 token 和 user_info,此时应用就成了未登录状态。
(3)添加组件样式代码,修饰用户弹出框的样式,如下:
<style lang="less">
.header-user-popover {
.user-wrap {
.rcolum {
color: #444;
margin-left: 12px;
.name {
font-size: 16px;
margin-top: 2px;
}
.jue {
font-size: 13px;
}
}
}
.el-divider {
border-color: #f0f0f0;
margin: 10px 0;
}
.preview {
.item {
flex: 1;
text-align: center;
b {
color: #252933;
font-size: 16px;
}
}
}
}
</style>
4. 头部入口组件
头部入口组件就是要把上面的几个子组件组合起来,形成一个完整的公共头部组件并导出,同时添加 Logo 和创作按钮等基础功能。
(1)创建组件文件 index.vue,编写模版代码如下:
<header>
<div class="inner-row">
<img class="logo" src="@/assets/logo.svg" @click="toHome" />
<Menus></Menus>
</div>
<div class="inner-row">
<Search style="margin-right: 26px"></Search>
<el-popover
placement="bottom-end"
:width="100"
:show-arrow="false"
trigger="hover"
transition="none"
:hide-after="50"
ref="popover"
popper-class="header-message-popover"
>
<template #reference>
<el-button type="primary" :icon="Plus">开始创作</el-button>
</template>
<div class="btn-wrap">
<el-button text @click="toRoute('/operate/create')">写文章</el-button>
<el-button text @click="toRoute('/shortmsg')">发沸点</el-button>
<el-button text @click="toRoute('/setting/drafts')">草稿箱</el-button>
</div>
</el-popover>
<template v-if="ustore.user_info">
<Message></Message>
<UserAva></UserAva>
</template>
<template v-else>
<el-button @click="showLogin">登录/注册</el-button>
</template>
</div>
</header>
上方代码中组合了其他的子组件,并且添加了“开始创作”按钮和“登录/注册”按钮。通过用户 Store 中的 user_info 状态判断用户是否已登录,如未登录则不渲染消息子组件和用户子组件,只展示一个登录按钮。
(2)编写组件 JS 部分,导入模版中使用到的子组件和用户 Store,并添加 toRoute() 方法跳转页面,代码如下:
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import Menus from "./menus.vue";
import Search from "./search.vue";
import Message from "./message.vue";
import UserAva from "./user.vue";
import { userStore } from "@/stores";
const ustore = userStore();
const router = useRouter();
const toRoute = (url: string) => {
router.push(url);
};
</script>
(3)编写样式,将子组件按照左右分布的方式排版,代码如下:
<style lang="less">
header {
height: 100%;
padding: 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
.inner-row {
display: flex;
align-items: center;
.logo {
height: 22px;
margin-right: 12px;
}
}
}
</style>
13.2.3 开发登录组件
在掘金的设计中,登录入口不是一个页面而是一个弹框,这意味着用户无论登录与否都可以进入首页。但如果用户触发了需要登录的按钮、或者调用接口时返回 401 状态码,页面会阻止默认行为并弹出登录框提示用户登录。
因此,登录组件不同于其他组件,它需要通过一个外部状态来控制登录框是否显示。当项目中的任意地方需要弹出登录框时,通过修改该状态来实现。
(1)创建登录组件 components/cus-login/index.vue,编写模版代码如下:
<section class="login-modal">
<el-dialog v-model="visible" :show-close="false" width="26%">
<template #header="{ close }">
<h4 class="title">登录掘金畅享更多权益</h4>
<el-button link circle @click="toClose(close)">
<el-icon :size="20" color="#888"><Close /></el-icon>
</el-button>
</template>
<div class="form-wrap">
<div class="form-item">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</div>
<div class="form-item">
<el-input
type="password"
v-model="form.password"
placeholder="请输入密码"
/>
</div>
<div class="form-item button">
<el-button type="primary" :loading="loading" @click="toLogin"
>登录 / 注册</el-button
>
</div>
<div class="footer">表示同意 <a>用户协议</a> 和 <a>隐私政策</a></div>
</div>
</el-dialog>
</section>
模版代码中提供了手机号和密码输入框,点击登录按钮后要请求登录接口,执行登录逻辑。
(2)为组件编写对应的样式,使其接近掘金的登录框风格,主要代码如下:
<style lang="less">
.login-modal {
.title {
font-size: 20px;
}
h4 {
color: #252933;
}
.form-wrap {
.form-item {
margin-bottom: 20px;
input {
height: 38px;
}
&.button {
margin: 30px 0 30px 0;
button {
width: 100%;
height: 40px;
}
}
}
}
.footer {
text-align: center;
}
}
</style>
(3)编写组件的 JS 代码,登录逻辑和其他引入的资源都在这:
<script lang="ts" setup>
import { ref } from "vue";
import { Close } from "@element-plus/icons-vue";
import { ElMessage, ElDialog } from "element-plus";
import { userStore } from "@/stores";
// 禁用点击遮罩层关闭弹框
ElDialog.props.closeOnClickModal.default = false;
const lostore = userStore();
const visible = ref(false);
const loading = ref(false);
const form = ref({
phone: "",
password: "",
});
const toLogin = () => {
let { phone, password } = form.value;
if (!phone && !password) {
return ElMessage.error("帐号密码不为空");
}
loading.value = true;
lostore.login(form.value, (bool) => {
loading.value = false;
visible.value = false;
console.log(bool);
});
};
const toClose = (close: Function) => {
lostore.need_login = false;
close();
};
defineExpose({
visible,
});
</script>
上方代码中,变量 visible 用于控制弹框显示,我们通过 defineExpose() 方法将该状态抛了出去,这样就可以在组件外部(父组件)中修改该状态。
代码中最关键的是导入了 userStore 这个 Store。该仓库中定义了 need_login 状态表示是否需要登录,定义了 login 方法执行具体的登录逻辑。下面我们创建该仓库。
(4)创建文件 stores/user/index.ts 表示用户 Store,代码如下:
import { defineStore } from 'pinia'
import { ElMessage } from 'element-plus'
import request from '@/request'
const userStore = defineStore('user', {
state: () => ({
need_login: false,
user_info: null as UserInfoType | null,
}),
actions: {
showLogin() {
this.need_login = true
},
setUserInfo(info: UserInfoType) {
this.user_info = info
},
async login(form: any, fun: (bool: boolean) => void) {
try {
let res: any = await request.post('/users/login', form)
if (res.code != 200) {
fun(false)
return ElMessage.error(res.message)
}
localStorage.setItem('token', res.token)
this.getUser('self')
fun(true)
// debugger
} catch (error) {
fun(false)
console.log(error)
}
},
},
})
export default userStore
上方代码中定义了 need_login 和 user_info 两个状态,分别表示是否需要登录和用户信息。还定义了 login() 方法调用登录接口,登录成功后会将 token 存储在 localStorage 中。
此处还需要定义一个获取用户信息的 getUser() 方法,定义如下:
async getUser(id: string, fun?: (data: any) => void) {
try {
let res: any = await request.get('/users/info/' + id)
if (id == 'self') {
this.setUserInfo(res)
}
if (fun) fun(res)
} catch (error) {
console.log(error)
}
},
(5)在根组件 App.vue 中注册登录组件,并监听仓库状态控制登录弹框显示。
在模版中注册登录组件,并添加一个 ref 引用,如下:
<div id="main-layout">
<RouterView />
<CusLogin ref="L" />
</div>
在 JS 代码中导入用户 Store,通过 watch 监听 need_login 状态变为 true 时显示登录框:
<script setup lang="ts">
import { userStore } from "@/stores";
const ustore = userStore();
const L = ref(null);
const need_login = computed(() => ustore.need_login);
watch(need_login, (val) => {
if (val) {
L.value.visible = true;
}
});
</script>
(6)在全局请求库 request/index.ts 中拦截到 401 状态码时,弹出登录框。部分代码如下:
import { ElMessage } from 'element-plus'
import { userStore } from '@/stores'
...
if (response.status === 401) {
ElMessage.error('登录已过期,请重新登录')
localStorage.removeItem('token')
userStore().showLogin()
}
如果要在其他按钮的点击方法中弹出登录框,方法与上方的代码一致。
(7)实现用户信息持久化,刷新页面时重新为 user_info 赋值。
还是在根组件 App.vue 中,添加一个 onMounted() 生命周期函数,该函数只会在页面刷新后执行一次,可以获取本地存储中的用户信息并重新赋值,代码如下:
import { onMounted } from 'vue'
import { userStore } from '@/stores'
...
onMounted(() => {
let uinfo = localStorage.user_info
if (uinfo) {
lostore.setUserInfo(JSON.parse(uinfo))
}
})
至此,登录组件和相关的登录功能已经开发完成。在页面中登录弹框如图所示:
13.2.4 开发编辑器组件
掘金的文章编辑器特别好用,幸运的是,该编辑器是一个开源项目,并且有 Vue3 版本的组件,因此我们可以快速集成,下面是步骤。
(1)安装编辑器组件 @bytemd/vue-next 以及用到的插件,执行命令如下:
$ yarn add @bytemd/vue-next @bytemd/plugin-gfm @bytemd/plugin-highlight @bytemd/plugin-medium-zoom @bytemd/plugin-mermaid
上方命令中安装了许多插件,这些插件用于拓展编辑器的功能。
(2)创建编辑器组件 components/cus-editior/index.vue,编写模版代码如下:
<div class="cus-editor-comp">
<Editor
:value="props.modelValue"
:plugins="plugins"
:locale="zhHans"
@change="handleChange"
/>
</div>
上方代码中的 Editor 是 @bytemd/vue-next 导出的组件,使用时传入编辑内容和修改内容的方法,且支持传入语言选项和插件。
(3)创建组件 JS 代码,导入组件并定义相关的属性和方法。
因为在外部使用该组件时需要用 v-model 指令,所以要定义 v-model 指令对应的 props(modelValue) 和 event(update:modelValue),代码如下:
<script lang="ts" setup>
import { Editor } from "@bytemd/vue-next";
import "bytemd/dist/index.min.css";
const props = defineProps<{
modelValue: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", ctx: string): void;
}>();
const handleChange = (ctx: string) => {
emit("update:modelValue", ctx);
};
</script>
此外还要加载编辑器需要的插件列表,引入汉化语言配置,代码如下:
import gfm from "@bytemd/plugin-gfm";
import hig from "@bytemd/plugin-highlight";
import zoom from "@bytemd/plugin-medium-zoom";
import ig from "@bytemd/plugin-mermaid";
import zhHans from "bytemd/lib/locales/zh_Hans.json"; // 汉化
const plugins = [gfm(), hig(), zoom(), ig()];
(4)创建一个单独的文件 index.less 定义 Markdown 内容的样式,并在组件的样式中导入,如下:
<style lang="less">
@import "./index.less";
.cus-editor-comp {
.cus-markdown-style();
}
</style>
最终编辑器的界面如图所示: