Skip to content

13.2 开发全局公共组件

定义在 views 文件夹下的组件是页面组件,其他的则是公共组件。在入口文件中加载的第一个组件是 App.vue,该组件是项目的根组件,用于定义页面结构和加载路由。

13.2.1 开发根组件 App.vue

根组件 App.vue 是所有页面都会加载的组件,主要定义页面结构。分析掘金的页面布局,可以看出是经典的上下布局结构,那么我们也将 App.vue 中的模版编写为上下结构。

(1)编写组件模版部分,一个简单的上下结构代码如下:

vue
<template>
  <div id="root-layout">
    <div id="header-layout">
      <!-- 头部组件区域 -->
    </div>
    <div id="main-layout">
      <!-- 路由区域 -->
    </div>
  </div>
</template>

(2)定义一个表示头部高度的 CSS 变量,后续会在多处使用。如下:

less
// styles/variable.css
:root {
  --header-height: 60px;
}

(3)在 styles/main.less 中添加根组件样式,此时会用到上一步添加的 CSS 变量,如下:

less
#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 组件,就可以将匹配到的路由渲染到这里,如下:

vue
<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 并导入,在头部组件区域添加该组件。代码如下:

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 文件中添加一个新路由,并动态导入该组件:

js
{
  path: '/shortmsg',
  name: 'shortmsg',
  component: () => import('@/views/shortmsg/index.vue'),
}

(2)创建菜单子组件 menus.vue,使用 RouterLink 组件添加两个菜单并跳转到指定路由:

js
<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)添加组件样式,使菜单与路由匹配时显示蓝色,并添加鼠标滑过的动画。代码如下:

vue
<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,并编写组件的模版代码如下:

js
<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)根据模版中的结构定义,编写样式如下:

vue
<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() 如下:

js
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,其结构与接口返回结构相符,代码如下:

ts
interface MessageType {
  comment: number;
  praise: number;
  follow: number;
  total: number;
}

(5)创建消息组件的 JS 代码,并导入消息状态,在组件初始化时调用 getMessage() 方法获取数据,代码如下:

vue
<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,编写弹出框中的模版代码如下:

html
<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() 方法,代码如下:

vue
<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)添加组件样式代码,修饰用户弹出框的样式,如下:

vue
<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,编写模版代码如下:

js
<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() 方法跳转页面,代码如下:

vue
<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)编写样式,将子组件按照左右分布的方式排版,代码如下:

vue
<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,编写模版代码如下:

html
<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)为组件编写对应的样式,使其接近掘金的登录框风格,主要代码如下:

vue
<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 代码,登录逻辑和其他引入的资源都在这:

vue
<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,代码如下:

js
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() 方法,定义如下:

js
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 引用,如下:

js
<div id="main-layout">
  <RouterView />
  <CusLogin ref="L" />
</div>

在 JS 代码中导入用户 Store,通过 watch 监听 need_login 状态变为 true 时显示登录框:

vue
<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 状态码时,弹出登录框。部分代码如下:

js
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() 生命周期函数,该函数只会在页面刷新后执行一次,可以获取本地存储中的用户信息并重新赋值,代码如下:

js
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 以及用到的插件,执行命令如下:

sh
$ yarn add @bytemd/vue-next @bytemd/plugin-gfm @bytemd/plugin-highlight @bytemd/plugin-medium-zoom @bytemd/plugin-mermaid

上方命令中安装了许多插件,这些插件用于拓展编辑器的功能。

(2)创建编辑器组件 components/cus-editior/index.vue,编写模版代码如下:

html
<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),代码如下:

vue
<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>

此外还要加载编辑器需要的插件列表,引入汉化语言配置,代码如下:

js
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 内容的样式,并在组件的样式中导入,如下:

vue
<style lang="less">
@import "./index.less";
.cus-editor-comp {
  .cus-markdown-style();
}
</style>

最终编辑器的界面如图所示: