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 暂告一段落
今天先到这吧。接下来就是优化和其他页面的编写。