# 第四章 项目起航 - 准备工作和第一个页面

# 4-1 项目起航 需求分析

一个复杂的 SPA 项目都要包括哪些知识点?

  • 第一,要有数据的展示,这个是所有网站共有的特性,而且最好是有多级复杂数据的展示

  • 第二,要有数据的创建,这就是表单的作用,有展示自然要有创建。在创建中,我们会发散很多问题,比如数据的验证怎样做,文件的上传如何处理,创建和编辑怎样共享单个页面等等。

  • 第三,要有组件的抽象,vue 是组件的世界,组件是最重要的一环,编写组件是最基本的能力,对于一些常用的功能,我们需要高可用性和可定制性的组件,也就是说我们在整个项目中一般不会用到第三方组件,比如 element,都是从零开始,而且会循序渐进,不断抽象。甚至行成自己的一套小组件库。

  • 第四,整体状态数据结构的设计和实现,SPA 一般使用状态工具管理整理状态,并且给多个路由使用,在 vue 中,我们使用 vuex,一个项目的整体数据结构的复杂程度就代表了这个能力的高低,最好是要有多层次的数据结构,相互依赖的关系,还要将数据的获取,结构设计,缓存进行一系列的考量。

  • 第五,权限管理和控制,一个项目需要有用户权限的实现,不仅仅是后端,前端作为一个整体的 SPA 的项目,权限控制也尤为重要,我们需要有权限的获取,权限的持久化,权限的更新,那个路由可访问,哪个需要权限才可以访问。发送异步请求的全局 token 注入,全局拦截,全局信息提示等等和权限相关的内容。

  • 第六,真实的后端API,和后端的交互是整个项目的最重要一环。一些同学在开发项目的时候会使用 mock server,但是由于后端的数据结构常常和最初的文档设计背道而驰,造成最后项目需要再次回炉修改。

页面所有原型图地址: https://whimsical.com/Djb2TcWsLTPeapFdM3NaX

# 文件结构和代码规范

创建项目的过程和之前 vue3 基础知识的过程完全一致

配置 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