Vue3实战之CNode社区 - Day 1

603 阅读2分钟

0x00 前言

Vue3 发布很久了,一直都没机会去接触,最近项目上闲下来了,终于可以尝鲜一下 Vue3 了,体验一下 Composition-API 的魅力。

0x01 基础知识

关于 Vue3 的基础知识,这里就不再提了,教程、文章到处都是,随手找找就有了。

本文会涉及的知识点有:

本次用到的 UI 组件库是大名鼎鼎的 Ant Designe of Vue,构建工具是 Vite

0x02 编码准备

1. 创建项目

$ npm create vite@latest
√ Project name: ... vue3-cnode
√ Select a framework: » vue
√ Select a variant: » vue-ts

Scaffolding project in D:\Workspace\Js\vue3-cnode...

Done. Now run:

  cd vue3-cnode
  npm install
  npm run dev

本次我选择了 ts 的模板,顺便学习一下 ts。

2. 项目架构

├─public
└─src
    ├─assets
    │  ├─images
    │  └─styles
    │      └─global
    ├─components
    ├─entitys
    ├─hooks
    ├─pages
    ├─router
    │  └─routes
    ├─service
    └─store
        └─modules
  • components:存放公共组件。
  • entitys:存放涉及到的数据结构(ts 中的 interface 和 type)。
  • hooks:存放公共的 hooks
  • pages:存放页面组件。
  • router:定义路由。routes 则是按照功能模块对页面进行划分。
  • service:接口请求。
  • store:全局数据仓库。modules 则是按照功能模块对数据进行划分

3. 安装依赖

$ npm install @ant-design/icons-vue ant-design-vue pinia vue-router

antd 选择按需引入的方式,因此需要安装对应的 vite 插件:

$ npm i unplugin-vue-components -D

修改 viee.config.ts :

import { defineConfig } from 'vite'
import path from 'path'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'

// https://vitejs.dev/config/export 
default defineConfig({
  plugins: [
    vue(),
    Components({
      resolvers: [AntDesignVueResolver()],
    }),
  ],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  server: {
    host: '0.0.0.0',
    port: 4000,
  },
})

现在回到 App.vue 试下配置是否生效:

<a-button>Add</a-button>

ok,搞掂。

0x03 开始编码

3.1 页面框架

直接使用 Antd 中的布局组件做三段式的布局:

  • header 为公共头部,后续需要添加网站 Logo 和其他操作(如登录、注册、退出登录等)。
  • content 为内容区域,路由切换的组件将放置在这个位置。
  • footer 为公共底部,用于放置一些申明内容,如版权信息等。
<template>
  <a-layout class="layout">
    <a-layout-header class="app-header">
    </a-layout-header>
    <a-layout-content class="app-content">
      <router-view class="mt-25"></router-view>
    </a-layout-content>
    <a-layout-footer style="text-align: center"> Vue3 × Ant Design × CNode</a-layout-footer>
  </a-layout>
</template>

3.2 首页

首页参照 CNode 社区的首页,分为三大组件:分类组件、帖子列表组件、分页组件。

<template>
  <div :style="{ background: '#fff', padding: '24px', minHeight: '280px' }">
    <topic-tabs :current="tab" @set-tab="setTab" />
    <topic-list :topics="topics" :loading="loading" />
    <a-pagination v-model:current="page" :total="2000" @change="setPage" :disabled="loading" />
  </div>
</template>

<script setup>
import { watchEffect } from 'vue'

import TopicList from '../../components/Topic/list.vue'
import TopicTabs from '../../components/Topic/tabs.vue'

import { usePage } from '../../hooks/usePage'
import { useTab } from '../../hooks/useTab'
import { useTopicList } from '../../hooks/useTopicList'

const { setPage, page } = usePage(1)
const { tab, setTab } = useTab('dev')

const { topics, getTopics, loading } = useTopicList()
watchEffect(() => {
  getTopics(page.value, tab.value)
})
</script>

因为每个小组件之间,都有共享的数据,所以就把这些数据放页面组件中去管理,而组件内只需要关注传入的数据即可,无需关注业务逻辑。

3.2.1 分类组件

<template>
  <div class="fb ai-center">
    <a-checkable-tag v-for="t in tabs" :key="t.tab" :checked="current === t.tab" @change="setTab(t.tab)">
      {{ t.label }}
    </a-checkable-tag>
  </div>
</template>

<script setup lang="ts">
import { TabType, TabTypes } from '../../entitys/Topic'
import { PropType } from 'vue'

interface Tab {
  tab: TabType
  label: string
}

// 定义组件 Props
defineProps({
  current: {
    type: String as PropType<TabType>,
    required: true,
  },
})

// 定义事件
const emit = defineEmits(['setTab'])

// 点击分类触发事件
const setTab = (tab: TabType) => emit('setTab', tab)

const tabs: Tab[] = Object.keys(TabTypes).reduce((all, key) => {
  all.push({
    tab: key,
    label: TabTypes[key],
  } as Tab)
  return all
}, [] as Tab[])

// 默认选中第一个分类
emit('setTab', tabs[0].tab)
</script>

3.2.2 帖子列表组件

帖子组件仅仅是简单的列表展示,无需业务处理逻辑。

列表组件:

<template>
  <a-list item-layout="horizontal" :data-source="topics" :loading="loading">
    <template #renderItem="{ item: topic }">
      <topic-list-item :topic="topic" />
    </template>
  </a-list>
</template>

<script lang="ts">
import { defineComponent, PropType } from 'vue'
import TopicListItem from './item.vue'
import { Topic } from '../../entitys/Topic'

export default defineComponent({
  name: 'topic-list',
  components: { TopicListItem },
  props: {
    topics: {
      type: Array as PropType<Topic[]>,
      required: true,
    },
    loading: {
      type: Boolean,
      default: false,
    },
  },
})
</script>

单个帖子组件:

<template>
  <a-list-item>
    <a-list-item-meta>
      <template #title>
        <a-tag color="#108ee9" v-if="topic.good">精华</a-tag>
        <a-tag color="#108ee9" v-else-if="topic.top">置顶</a-tag>
        <a-tag v-else>{{ TabTypes[topic.tab] }}</a-tag>
        <router-link :to="`/topic/${topic.id}`">{{ topic.title }}</router-link>
      </template>
      <template #avatar>
        <a-avatar :src="topic.author.avatar_url" />
      </template>
      <template #description>
        {{ topic.author.loginname }}
      </template>
    </a-list-item-meta>
  </a-list-item>
</template>

<script lang="ts">
import { defineComponent, PropType } from 'vue'
import { TabTypes, Topic } from '../../entitys/Topic'

export default defineComponent({
  name: 'topic-list-item',
  props: {
    topic: {
      type: Object as PropType<Topic>,
      required: true,
    },
  },
  data() {
    return {
      TabTypes,
    }
  },
})
</script>

<style scoped></style>

3.2.3 分页组件

就是默认的 Antd 的分页组件。

3.2.4 Composition API 应用

由于 Vue3 支持 Composition API,当然要把它用上啦。

3.2.4.1 useTab / usePage

两个都是类似的,属于最简单的应用了,都直接导出一个变量和一个设置变量的调用函数。

import { ref } from 'vue'
import { TabType } from '../entitys/Topic'

export const useTab = (t: TabType) => {
  const tab = ref<TabType>(t || 'all')
  const setTab = (t: TabType) => {
    tab.value = t
  }

  return {
    tab,
    setTab,
  }
}

3.2.4.2 useTopicList

这个比之前的多了一个 loading,这样可以自动在请求的时候添加 Loading 过渡动画。

import { ref } from 'vue'
import { getTopicList } from '../service/topics'
import { TabType, Topic } from '../entitys/Topic'

export const useTopicList = () => {
  const topics = ref<Topic[]>([])
  const loading = ref<boolean>(false)

  const getTopics = (page: number, tab: TabType) => {
    loading.value = true
    return getTopicList(page, tab).then(res => {
      topics.value = res
    }).finally(() => {
      loading.value = false
    })
  }

  return {
    topics,
    getTopics,

    loading,
  }
}

3.2.4.3 watchEffect

本来一开始是把 tab、page、topicList 都糅合在一起了,但是突然发现,Composition API 并不是这么个思想,所以还是把他们给分开了,每个 hook 都管理自己的数据,最终可以通过 watchEffect 副作用的函数将他们关联起来。耦合关系一目了然,也降低了其他 3 个 hook 的耦合性。

watchEffect(() => {
  getTopics(page.value, tab.value)
})

3.3 详情页

<template>
  <div class="topic-detail-page fb jc-sb">
    <div class="topic-details mr-20">
      <template v-if="loading">
        <a-skeleton active avatar :paragraph="{ rows: 4 }" />

        <a-skeleton active avatar :paragraph="{ rows: 4 }" />

        <a-skeleton active avatar :paragraph="{ rows: 4 }" />
      </template>
      <template v-else>
        <topic-detail :detail="detail" />
        <topic-replys :replys="detail.replies" class="mt-10" />
      </template>
    </div>
  </div>
</template>

<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { TopicDetail as ITopicDetail } from '../../entitys/Topic'
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
import { getTopicDetail } from '../../service/topics'
import TopicDetail from '../../components/Topic/detail.vue'
import TopicReplys from '../../components/Topic/replys.vue'

// 获取 route 实例
const route = useRoute()
// 从 route 获取帖子 ID
const topicId = ref(route.params.id)

const detail = ref<ITopicDetail>({
  replies: [],
})
const loading = ref<boolean>(true)

// 获取帖子详情
const reload = () => {
  loading.value = true
  getTopicDetail(topicId.value).then(topic => {
    loading.value = false
    detail.value = reactive<ITopicDetail>(topic)
  })
}

// 挂载的时候获取详情
onMounted(reload)

// 帖子 ID 变更的时候重新获取详情
onBeforeRouteUpdate(to => {
  topicId.value = to.params.id
  reload()
})
</script>
<script lang="ts">
export default {
  name: 'topic-detail',
}
</script>

关于详情中的代码,我这里使用的是 highlight.js 去格式化,只需要引入并在挂载的 $nextTick 中去手动将所有代码格式化即可。

import hljs from 'highlight.js'
import 'highlight.js/styles/stackoverflow-dark.css'

export default defineComponent({
  name: 'topic-detail',
  props: {
    detail: {
      type: Object as PropType<TopicDetail>,
      required: true,
    },
  },
  mounted() {
    this.$nextTick(() => {
      hljs.highlightAll()
    })
  },
})

0x04 暂告一段落

今天先到这吧。接下来就是优化和其他页面的编写。