# 第四章 项目起航 - 准备工作和第一个页面
# 4-1 项目起航 需求分析
一个复杂的 SPA 项目都要包括哪些知识点?
第一,要有数据的展示,这个是所有网站共有的特性,而且最好是有多级复杂数据的展示
第二,要有数据的创建,这就是表单的作用,有展示自然要有创建。在创建中,我们会发散很多问题,比如数据的验证怎样做,文件的上传如何处理,创建和编辑怎样共享单个页面等等。
第三,要有组件的抽象,vue 是组件的世界,组件是最重要的一环,编写组件是最基本的能力,对于一些常用的功能,我们需要高可用性和可定制性的组件,也就是说我们在整个项目中一般不会用到第三方组件,比如 element,都是从零开始,而且会循序渐进,不断抽象。甚至行成自己的一套小组件库。
第四,整体状态数据结构的设计和实现,SPA 一般使用状态工具管理整理状态,并且给多个路由使用,在 vue 中,我们使用 vuex,一个项目的整体数据结构的复杂程度就代表了这个能力的高低,最好是要有多层次的数据结构,相互依赖的关系,还要将数据的获取,结构设计,缓存进行一系列的考量。
第五,权限管理和控制,一个项目需要有用户权限的实现,不仅仅是后端,前端作为一个整体的 SPA 的项目,权限控制也尤为重要,我们需要有权限的获取,权限的持久化,权限的更新,那个路由可访问,哪个需要权限才可以访问。发送异步请求的全局 token 注入,全局拦截,全局信息提示等等和权限相关的内容。
第六,真实的后端API,和后端的交互是整个项目的最重要一环。一些同学在开发项目的时候会使用 mock server,但是由于后端的数据结构常常和最初的文档设计背道而驰,造成最后项目需要再次回炉修改。
页面所有原型图地址: https://whimsical.com/Djb2TcWsLTPeapFdM3NaX
# 文件结构和代码规范
创建项目的过程和之前 vue3 基础知识的过程完全一致
唯一区别就是在步骤
Pick a linter / formatter config - 我们选择了 ESLint + Standard config
区别就是我们额外添加了 Standard 代码规范。https://standardjs.com/readme-zhcn.html
我们初步确定的项目文件结构
/assets
image.png
logo.png
/components
ColumnList.vue
Dropdown.vue
...
/hooks
useURLloader.ts
...
/views
Home.vue
...
App.vue
main.ts
store.ts
router.ts
...
# 从好用的样式库开始
安装最新版的 Bootstrap
npm install bootstrap@next --save
TIP
注意安装完毕应该至少是 v5.0.0-alpha1 以上版本
Bootstrap V5 文档地址: https://v5.getbootstrap.com/
# ColumnList 组件编码
ColumnList 组件源代码
<template>
<ul>
<li v-for="column in list" :key="column.id">
<img :src="column.avatar" :alt="column.title">
<h5>{{column.title}}</h5>
<p>{{column.description}}</p>
<a href="#">进入专栏</a>
</li>
</ul>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
export interface ColumnProps {
id: number;
title: string;
avatar: string;
description: string;
}
export default defineComponent({
name: 'ColumnList',
props: {
list: {
//这里特别有一点,我们现在的 Array 是没有类型的,只是一个数组,我们希望它是一个 ColomnProps 的数组,那么我们是否可以使用了类型断言直接写成 ColomnProps[],显然是不行的 ,因为 Array 是一个数组的构造函数不是类型,我们可以使用 PropType 这个方法,它接受一个泛型,讲 Array 构造函数返回传入的泛型类型。
type: Array as PropType<ColumnProps[]>,
required: true
}
}
})
</script>
引入 bootstrap
import 'bootstrap/dist/css/bootstrap.min.css'
测试数据
const testData: ColumnProps[] = [
{
id: 1,
title: 'test1的专栏',
description: '这是的test1专栏,有一段非常有意思的简介,可以更新一下欧',
avatar: 'http://vue-maker.oss-cn-hangzhou.aliyuncs.com/vue-marker/5ee22dd58b3c4520912b9470.jpg?x-oss-process=image/resize,m_pad,h_100,w_100'
},
{
id: 2,
title: 'test2的专栏',
description: '这是的test2专栏,有一段非常有意思的简介,可以更新一下欧',
avatar: 'http://vue-maker.oss-cn-hangzhou.aliyuncs.com/vue-marker/5ee22dd58b3c4520912b9470.jpg?x-oss-process=image/resize,m_pad,h_100,w_100'
}
]
# 4-5 ColumnList 组件使用 Bootstrap 美化
Bootstrap 栅格系统文档地址: https://v5.getbootstrap.com/docs/5.0/layout/grid/
Bootstrap card 样式文档地址: https://v5.getbootstrap.com/docs/5.0/components/card/
设置默认的 avatar 图片
setup(props) {
const columnList = computed(() => {
return props.list.map(column => {
if (!column.avatar) {
column.avatar = require('@/assets/column.jpg')
}
return column
})
})
return {
columnList
}
}
修改后的vue template 模版
<div class="row">
<div v-for="column in columnList" :key="column.id" class="col-4 mb-4">
<div class="card h-100 shadow-sm">
<div class="card-body text-center">
<img :src="column.avatar" :alt="column.title" class="rounded-circle border border-light w-25 my-3" >
<h5 class="card-title">{{column.title}}</h5>
<p class="card-text text-left">{{column.description}}</p>
<a href="#" class="btn btn-outline-primary">进入专栏</a>
</div>
</div>
</div>
</div>
# 4-6 GlobalHeader 组件编码
Bootstrap nav 样式文档地址: https://v5.getbootstrap.com/docs/5.0/components/navs/
GlobalHeader 源代码
<template>
<nav class="navbar navbar-dark bg-primary justify-content-between mb-4 px-4">
<a class="navbar-brand" href="#">者也专栏</a>
<ul v-if="!user.isLogin" class="list-inline mb-0">
<li class="list-inline-item"><a href="#" class="btn btn-outline-light my-2">登陆</a></li>
<li class="list-inline-item"><a href="#" class="btn btn-outline-light my-2">注册</a></li>
</ul>
<ul v-else class="list-inline mb-0">
<li class="list-inline-item"><a href="#" class="btn btn-outline-light my-2">你好 {{user.name}}</a></li>
</ul>
</nav>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
export interface UserProps {
isLogin: boolean;
name?: string;
id?: number;
}
export default defineComponent({
name: 'GlobalHeader',
props: {
user: {
type: Object as PropType<UserProps>,
required: true
}
}
})
</script>
# 4-7 Dropdown 组件编码第一部分 - 基本功能
Bootstrap dropdown 样式文档地址: https://v5.getbootstrap.com/docs/5.0/components/dropdowns/
Dropdown 组件编码
<template>
<div class="dropdown">
<a href="#" class="btn btn-outline-light my-2 dropdown-toggle" @click.prevent="toggleOpen">
{{title}}
</a>
<ul class="dropdown-menu" :style="{display: 'block'}" v-if="isOpen">
<li class="dropdown-item">
<a href="#">新建文章</a>
</li>
<li class="dropdown-item">
<a href="#">编辑资料</a>
</li>
</ul>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
name: 'Dropdown',
props: {
title: {
type: String,
required: true
}
},
setup() {
const isOpen = ref(false)
const toggleOpen = () => {
isOpen.value = !isOpen.value
}
return {
isOpen,
toggleOpen
}
}
})
</script>
# 4-8 Dropdown 组件编码第二部分 - 添加 DropdownItem
Vue3 slot 文档地址: https://v3.vuejs.org/guide/component-slots.html#slots
分离出来的 DropdownItem 组件编码
<template>
<li
class="dropdown-option"
:class="{'is-disabled': disabled}"
>
<slot></slot>
</li>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
props: {
disabled: {
type: Boolean,
default: false
}
}
})
</script>
<style>
.dropdown-option.is-disabled * {
color: #6c757d;
pointer-events: none;
background-color: transparent;
}
</style>
# 4-9 Dropdown 组件编码第三部分 - 点击外部区域自动隐藏
composition API 使用 template ref: https://v3.vuejs.org/guide/composition-api-template-refs.html#template-refs
给模版添加 ref 属性
<div class="dropdown" ref="dropdownRef">
const dropdownRef = ref<null | HTMLElement>(null)
const handler = (e: MouseEvent) => {
if (dropdownRef.value) {
if (!dropdownRef.value.contains(e.target as HTMLElement) && isOpen.value) {
isOpen.value = false
}
}
}
onMounted(() => {
document.addEventListener('click', handler)
})
onUnmounted(() => {
document.removeEventListener('click', handler)
})
return {
isOpen,
toggleOpen,
// 返回和 ref 同名的响应式对象,就可以拿到对应的 dom 节点
dropdownRef
}
# 4-10 useClickOutside 第一个自定义函数
import { ref, onMounted, onUnmounted, Ref } from 'vue'
const useClickOutside = (elementRef: Ref<null | HTMLElement>) => {
const isClickOutside = ref(false)
const handler = (e: MouseEvent) => {
if (elementRef.value) {
if (elementRef.value.contains(e.target as HTMLElement)) {
isClickOutside.value = false
} else {
isClickOutside.value = true
}
}
}
onMounted(() => {
document.addEventListener('click', handler)
})
onUnmounted(() => {
document.removeEventListener('click', handler)
})
return isClickOutside
}
export default useClickOutside