③ vue+ts 实现 模拟知乎后台
作者:互联网
目录1.1 完美的
2.2
5
6
7
8
第2章 表单的世界 - 完成自定义
2
2. 接收传入的
3.
2.3 支持
组件支持
2.4 使用
3
3.1 使用插槽
3.3 寻找外援
第3章 初步使用
1 什么是
3
4
5
5.2
6 添加
8
9
9.1 怎样读取
9.2 怎样触发
10 使用
12
2
2.1
3 使用
4
5 使用
6 使用
7 使用
8
8.2 使用
1. 使用
1 登录 -- 获取
2
3 登录 --
6 创建
6.3 使用
7
7.1 创建
3
1.
5 创建文章页面实现
2.1 改进
2.2 改进
1. 使用
2. 使用
3
3.1
1.
2. 数据结构的优化 --
3. 项目代码
1.
2. 添加
3. 项目代码
2
3
4
5
- 第1章 项目起航
- 第2章 表单的世界 - 完成自定义
Form
组件 - 第3章 初步使用
vue-router
和vuex
- 第4章 前后端结合 - 项目整合后端接口
- 第5章 通行凭证 - 权限管理
- 第6章 上传组件
- 第7章 编辑和删除文章
- 第8章 持续优化
第1章 项目起航
1 项目起航 需求分析
1.1 完美的 vue
实践项目是怎样的
- 数据的展示 -- 最好是有多级复杂数据的展示
- 数据的创建 -- 验证 | 上传 | 创建 & 编辑共享
- 组件的抽象 -- 循序渐进的组件开发
- 整体状态数据结构的设计和实现
- 权限管理和控制
- 真实的后端 api
1.2 需求
组件需求
Dropdown
组件Message
组件Modal
组件- 上传组件
Form
组件:之间验证 + 内置规则
2 文件结构和代码规范
2.1 文件结构
/assets
image.png
logo.png
/components
Dropdown.vue
Message.vue
...
/hooks
useURLLoader.ts
...
/views
Home.vue
...
App.vue
main.ts
store.ts
router.ts
...
2.2 esLint
代码规范
esLint + Standard config
3 样式解决方案简介和分析
从好用的样式库开始
- 安装最新版的 bootstrap:
npm install bootstrap@next --save
4 设计图拆分和组件属性分析
4.1 开发流程
UI
划分出组件的层级- 创建应用的静态版本(解耦)
Header.vue
-- 公用组件Intro.vue
专栏组件ColumnList.vue
专栏列表组件loadMore.vue
组件
4.2 组件属性分析
<ColumnList list={{columns}} />
interface ColumnProps {
id: number;
avatar: string;
title: string;
description: string;
}
5 ColumnList
组件编码
- 使用
PropType
接收泛型
import { defineComponent, PropType } from 'vue'
export interface ColumnProps {
id: number;
avatar: string;
title: string;
description: string;
}
export default defineComponent({
name: 'ColumnList',
props: {
list: {
// 类型断言 -- 使用 PropType 接收泛型
type: Array as PropType<ColumnProps[]>,
required: true
}
}
})
- 使用
computed
重组数据
setup (props) {
const columnList = computed(() => {
return props.list.map(column => {
if (!column.avatar) {
column.avatar = require('@/assets/logo.png')
}
return column
})
})
return { columnList }
}
6 GlobalHeader
组件编码
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
}
}
})
7 Dropdown
组件
- 基本逻辑
<div class="dropdown">
<a href="#" class="btn btn-outline-light my-2 dropdown-toggle" @click.prevent="toggleOpen">{{ title }}</a>
<ul class="dropdown-menu" v-if="isOpen" :style="{display: 'block'}">
<slot></slot><!-- 添加dropdown-item自定义内容 -->
</ul>
</div>
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
}
}
})
- 组件添加
DropdownItem
<li class="dropdown-option" :class="{'is-disabled': disabled}">
<slot></slot>
</li>
- 组件点击外部区域自动隐藏 --
dropdownRef
setup () {
const isOpen = ref(false)
const dropdownRef = ref<null | HTMLElement>(null)
const toggleOpen = () => {
isOpen.value = !isOpen.value
}
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, dropdownRef
}
}
8 useClickOutside
第一个自定义函数
hooks > useClickOutside.ts
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
Dropdown.vue
const isClickOutside = useClickOutside(dropdownRef)
watch(isClickOutside, () => {
if (isOpen.value && isClickOutside.value) {
isOpen.value = false
}
})
第2章 表单的世界 - 完成自定义 Form
组件
1 表单
-
原型图类型: 数据展示 + 表单
-
数据验证规则
form - item
- 单独验证提示
- 提交时验证
2 ValidateInput
2.1 简单的实现
- 通过
@blur
绑定事件 -- 失焦时触发校验
<input
type="email" class="form-control" id="exampleInputEmail1"
v-model="emailRef.val"
@blur="validateEmail"
>
- 校验函数编写
const emailRef = reactive({
val: '',
error: false,
message: ''
})
const validateEmail = () => {
if (emailRef.val.trim() === '') {
emailRef.error = true
emailRef.message = 'can not be empty'
} else if (!emailReg.test(emailRef.val)) {
emailRef.error = true
emailRef.message = 'should be valid email'
}
}
2.2 抽象验证规则
1. 定义接口限制类型
interface RuleProp {
type: 'required' | 'email';
message: string;
}
export type RulesProp = RuleProp[]
2. 接收传入的 rules
props: {
rules: Array as PropType<RulesProp>
},
3. setup
函数
- 组件内数据
const inputRef = reactive({
val: '',
error: false,
message: ''
})
- 校验函数
const validateInput = () => {
if (props.rules) {
const allPassed = props.rules.every(rule => {
let passed = true
inputRef.message = rule.message
switch (rule.type) {
case 'required':
passed = (inputRef.val.trim() !== '')
break
case 'email':
passed = emailReg.test(inputRef.val)
break
default:
break
}
return passed
})
inputRef.error = !allPassed
}
}
2.3 支持 v-model
v-model
语法糖
<my-component v-model='val' />
- vue2
<my-component :value='val' @input='val = arguments[0]' />
- vue3 compile 以后的结果
h(Com, {
modelValue: val,
'onUpdate:modelValue': value => (val = value)
})
组件支持 v-model
- 创建名为
modelValue
的props
属性
props: {
modelValue: String
},
- 更新时触发
update:modelValue
事件
const updateValue = (e: KeyboardEvent) => {
const targetValue = (e.target as HTMLInputElement).value
inputRef.val = targetValue
context.emit('update:modelValue', targetValue)
}
2.4 使用 $attrs
支持默认属性
非
prop
的attribute
将自动添加到根节点的attribute
中
如何使得属性添加到指定元素上?
-
禁用组件根元素继承属性:
inheritAttrs: false
-
借用
$attrs
指定标签
- 父组件
<validate-input
:rules="emailRules" v-model="emailVal"
placeholder="请输入邮箱地址"
type="text"
></validate-input>
- 子组件
<input
class="form-control"
:class="{'is-invalid': inputRef.error}"
:value="inputRef.val"
@blur="validateInput"
@input="updateValue"
v-bind="$attrs"
>
<span v-if="inputRef.error" class="invalid-feedback">
{{inputRef.message}}
</span>
3 ValidateForm
组件需求分析
3.1 使用插槽 slot
- 子组件
<form class="validate-form-container">
<slot></slot>
<div class="submit-area" @click.prevent="submitForm">
<slot name="submit">
<button type="submit" class="btn btn-primary">提交</button>
</slot>
</div>
</form>
- 父组件
<validate-form @form-submit="onFormSubmit">
<template #submit>
<span class="btn btn-danger">submit</span>
</template>
</validate-form>
3.2 尝试父子通讯
ref
(做不到父传子)this.$on
+this.$emit
(vue3不支持)
3.3 寻找外援 mitt
- 父组件
import { defineComponent, onUnmounted } from 'vue'
import mitt from 'mitt'
export const emitter = mitt()
export default defineComponent({
setup (props, context) {
const callback = (test: string) => {}
emitter.on('form-item-created', callback)
onUnmounted(() => {
emitter.off('form-item-created', callback)
})
}
})
- 子组件
import { emitter } from './ValidateForm.vue'
onMounted(() => {
return emitter.emit('form-item-created', inputRef.val)
})
3.4 大功告成
- 父组件
import { defineComponent, onUnmounted } from 'vue'
import mitt from 'mitt'
type ValidateFunc = () => boolean
export const emitter = mitt()
export default defineComponent({
emits: ['form-submit'],
setup (props, context) {
let funcArr: ValidateFunc[] = []
const submitForm = () => {
const result = funcArr.map(func => func()).every(result => result)
context.emit('form-submit', result)
}
const callback = (func: ValidateFunc) => {
funcArr.push(func)
}
emitter.on('form-item-created', callback)
onUnmounted(() => {
emitter.off('form-item-created', callback)
funcArr = []
})
return {
submitForm
}
}
})
- 子组件
const validateInput = () => {
if (props.rules) {
const allPassed = props.rules.every(rule => {
let passed = true
inputRef.message = rule.message
switch (rule.type) {
case 'required':
passed = (inputRef.val.trim() !== '')
break
case 'email':
passed = emailReg.test(inputRef.val)
break
default:
break
}
return passed
})
inputRef.error = !allPassed
return allPassed
}
return true
}
onMounted(() => {
return emitter.emit('form-item-created', validateInput)
})
第3章 初步使用 vue-router
和 vuex
1 什么是 SPA(Single Page Application)
应用?
优点
- 速度快,第一次下载完成静态文件,跳转不需要再次下载
- 体验好,整个交互趋于无缝,更倾向于原生应用
- 为前后端分离提供了实践场所
2 添加路由页面基础结构
<div id="app">
<h1>Hello App!</h1>
<p>
<router-link to="/">Go to Home</router-link>
<router-link to="/about">Go to About</router-link>
</p>
<router-view></router-view>
</div>
<router-view></router-view>
- 声明式跳转
<router-link :to='...'></router-link>
- 编程式跳转
click事件
3 vue-router
安装和使用
3.1 安装
npm i vue-router -S
3.2 使用
main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).use(router).mount('#app')
4 vue-router
配置路由
router.ts
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'Home',
component: () => import(/* webpackChunkName: 'home' */ './views/Home.vue')
},
{
path: '/login',
name: 'Login',
component: () => import(/* webpackChunkName: 'login' */ './views/Login.vue')
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router
5 vue-router
添加路由
5.1 动态路由
router.ts
{
path: '/column/:id',
name: 'Column',
component: () => import(/* webpackChunkName: 'login' */ './views/ColumnDetail.vue')
}
columnList.vue
<router-link :to="`/column/${column.id}`" class="btn btn-outline-primary">进入专栏</router-link>
5.2 router
跳转
Login.vue
import { useRouter } from 'vue-router'
// ...
const onFormSubmit = (result: boolean) => {
if (result) {
router.push({ name: 'Column', params: { id: 1 } })
}
}
6 添加 columnDetail
页面
import { testData, testPosts } from '../testDate'
setup () {
const route = useRoute()
const currentId = +route.params.id
const column = testData.find(c => c.id === currentId)
const list = testPosts.filter(post => post.columnId === currentId)
return {
route,
column,
list
}
}
7 状态管理工具是什么
7.1 全局对象的弊端
- 数据不是响应式的
- 数据修改无法追踪
- 不符合组件开发的原则
7.2 状态管理工具的原则
- 一个类似
object
的全局数据结构 -- 称之为store
- 只能调用一些特殊的方法来实现数据修改
8 Vuex
简介和安装
vuex
的核心是Store
,store
包含应用中大多数状态State
9 Vuex
整合当前应用
store.ts
import { createStore } from 'vuex'
import { testData, testPosts, ColumnProps, PostProps } from '../testDate'
interface UserProps {
isLogin: boolean;
name?: string;
id?: number;
}
export interface GlobalDataProps {
columns: ColumnProps[];
posts: PostProps[];
user: UserProps;
}
const store = createStore<GlobalDataProps>({
state: {
columns: testData,
posts: testPosts,
user: { isLogin: false }
},
mutations: {
login (state) {
state.user = { ...state.user, isLogin: true, name: 'zou' }
}
}
})
export default store
9.1 怎样读取 vuex
中的数据
Home.vue
// <column-list :list="list"></column-list>
import { defineComponent, computed } from 'vue'
import { useStore } from 'vuex'
import { GlobalDataProps } from '../store'
export default defineComponent({
setup () {
const store = useStore<GlobalDataProps>()
const list = computed(() => store.state.columns)
return {
list
}
}
})
9.2 怎样触发 vuex
中的 mutation
Login.vue
import { useStore } from 'vuex'
import { useRouter } from 'vue-router'
// ...
setup () {
const router = useRouter()
const store = useStore()
const onFormSubmit = (result: boolean) => {
if (result) {
router.push({ name: 'Home' })
store.commit('login')
}
}
}
10 使用 Vuex getters
store.ts
getters: {
getColumnsById: state => (id: number) => {
return state.columns.find(c => c.id === id)
},
getPostsByCid: state => (cid: number) => {
return state.posts.filter(post => post.columnId === cid)
}
}
ColumnDetail.vue
const column = computed(() => store.getters.getColumnsById(currentId))
const list = computed(() => store.getters.getPostsByCid(currentId))
11 添加新建文章页面
const onFormSubmit = (result: boolean) => {
if (result) {
const { columnId } = store.state.user
if (columnId) {
const newPost: PostProps = {
id: new Date().getTime(),
title: titleVal.value,
content: contentVal.value,
columnId,
createdAt: new Date().toLocaleString()
}
store.commit('createPost', newPost)
router.push({ name: 'Column', params: { id: columnId } })
}
}
}
12 Vue router
添加路由守卫
12.1 前置守卫
router.beforeEach((to, from, next) => {
if (to.name !== 'Login' && !store.state.user.isLogin) {
next({ name: 'Login' })
} else {
next()
}
})
12.2 使用元信息完成权限管理
const routes: Array<RouteRecordRaw> = [
// ...
{
path: '/login',
name: 'Login',
component: () => import('./views/Login.vue'),
meta: { redirectAlreadyLogin: true }
},
// ...
{
path: '/create',
name: 'CreatePost',
component: () => import('./views/CreatePost.vue'),
meta: { requireLogin: true }
}
]
router.beforeEach((to, from, next) => {
if (to.meta.requireLogin && !store.state.user.isLogin) {
next({ name: 'Login' })
} else if (to.meta.redirectAlreadyLogin && store.state.user.isLogin) {
next('/')
} else {
next()
}
})
第4章 前后端结合 - 项目整合后端接口
1 前后端分离开发是什么
1.1 前后端分离开发的数据交互
- 前端 --
ajax
--> 后端 --json
--> 前端
1.2 前后端分离的开发模式
1.3 优点
- 为优质产品打造精益团队
- 提升开发效率
- 完美应对复杂多变的前端需求
- 增强代码可维护性
2 RESTful API
设计理念
https://api.examples.com/teams
https://api.examples.com/players
2.1 HTTP
动词
GET(SELECT)
:从服务器取出资源(一项或多项)POST(CREATE)
:在服务器新建一个资源PUT(UPDATE)
:在服务器更新资源PATCH(UPDATE)
:在服务器更新资源DELETE(DELETE)
:从服务器删除资源
2.2 常见状态码
200 OK - [GET]
:服务器成功返回用户请求的数据201 CREATED - [POST/PUT/PATCH]
:用户新建或修改数据成功204 NO CONTENT - [DELETE]
:用户删除数据成功401 Unauthorized - [*]
:表示用户没有权限(令牌、用户名、密码错误)403 Forbidden - [*]
: 表示用户得到授权(与401错误相对),但是访问是被禁止的404 NOT FOUND - [*]
:用户发出的请求针对的是不存在的记录,服务器没有进行操作
3 使用 swagger
在线文档查看接口详情
3.1 接口文档需要包括的点
endponits
是具体的路径,或者说是网址- 使用什么样的
method
,get
post
put
patch
或者delete
- 发送请求要有什么样的参数,参数是在
url
上的query
还是body
里面的复杂信息 - 请求返回的格式是什么样的
### endpoints
GET /teams/${ID}/players
### parameters
{
name: 'ID',
desc: '当前球队的 ID',
type: 'string'
}
### responses
**200响应**
{
"code": 0,
"data": [
{
"createdAt": "2020-06-05 16:45:22",
"description": "有一段非常有意思的简介,可以更新一下欧",
"name": "洛杉矶湖人",
"_id": "5eda0622acb0d2280c10385e"
},
{
"createdAt": "2020-06-05 16:45:22",
"description": "有一段非常有意思的简介,可以更新一下欧",
"name": "金州勇士",
"_id": "5eda0544ce65c327d718e57b"
}
],
"msg": "请求成功"
}
**401响应**
4 axios
的基本用法
import axios from 'axios'
axios.defaults.baseURL = '/api'
axios.interceptors.request.use(config => {
config.params = { ...config.params }
return config
})
5 使用 vuex action
发送异步请求
store.ts
actions: {
fetchColumns (context) {
axios.get('/columns').then(resp => {
context.commit('fetchColumns', resp.data)
})
}
}
Home.vue
setup () {
const store = useStore<GlobalDataProps>()
onMounted(() => {
store.dispatch('fetchColumns')
})
}
6 使用 async
和 await
改造异步请求
export interface GlobalDataProps {
columns: ColumnProps[];
posts: PostProps[];
user: UserProps;
loading: boolean;
token: string;
}
const getAndCommit = async (url: string, mutationName: string, commit: Commit) => {
const { data } = await axios.get(url)
commit(mutationName, data)
}
7 使用 axios
拦截器添加 loading
效果
main.ts
axios.interceptors.request.use(config => {
store.commit('setLoading', true)
return config
})
axios.interceptors.response.use(config => {
store.commit('setLoading', false)
return config
})
store.ts
export interface GlobalDataProps {
columns: ColumnProps[];
posts: PostProps[];
user: UserProps;
loading: boolean;
token: string;
}
const store = createStore<GlobalDataProps>({
state: {
columns: [],
posts: [],
user: { isLogin: false, name: 'zou', columnId: 1 },
loading: false,
token: ''
},
mutations: {
setLoading (state, status) {
state.loading = status
}
}
})
8 Loader
组件编码
8.1 基本实现
import { defineComponent } from 'vue'
export default defineComponent({
props: {
text: {
type: String
},
background: {
type: String
}
}
})
8.2 使用 Teleport
进行改造
1. 使用 <teleport>
包裹组件
<teleport to="#back">
</teleport>
2. 全局生成节点
setup () {
const node = document.createElement('div')
node.id = 'back'
document.body.appendChild(node)
onUnmounted(() => {
document.body.removeChild(node)
})
}
第5章 通行凭证 - 权限管理
1 登录 -- 获取 token
store.ts
export interface GlobalDataProps {
columns: ColumnProps[];
posts: PostProps[];
user: UserProps;
loading: boolean;
token: string;
}
const store = createStore<GlobalDataProps>({
state: {
columns: [],
posts: [],
user: { isLogin: false, name: 'zou', columnId: 1 },
loading: false,
token: ''
},
mutations: {
login (state, rawData) {
state.token = rawData.data.token
}
},
actions: {
login ({ commit }, payload) {
return postAndCommit('/user/login', 'login', commit, payload)
}
}
})
Login.vue
const onFormSubmit = (result: boolean) => {
if (result) {
const payload = {
email: emailVal.value,
password: passwordVal.value
}
store.dispatch('login', payload).then(data => {
router.push({ name: 'Home' })
})
}
}
2 jwt
的运行机制
验证存在
- 服务器创建对应的
session
数据并保存 - 接收到请求,服务器使用
cookie
中的信息查看服务器中是否存在该session
数据
验证正确
- 服务器使用
jwt
算法生成对应token
- 接收到请求,
jwt
反向验证对应的token
是否正确
3 登录 -- axios
设置通用 header
store.ts
mutations: {
fetchCurrentUser (state, rawData) {
state.user = { isLogin: true, ...rawData.data }
},
login (state, rawData) {
const { token } = rawData.data
state.token = token
axios.defaults.headers.common.Authorization = `Bearer ${token}`
}
},
actions: {
fetchCurrentUser ({ commit }) {
return getAndCommit('/user/current', 'fetchCurrentUser', commit)
},
login ({ commit }, payload) {
return postAndCommit('/user/login', 'login', commit, payload)
},
loginAndFetch ({ dispatch }, loginData) {
return dispatch('login', loginData).then(() => {
return dispatch('fetchCurrentUser')
})
}
}
4 登录 -- 持久化登录状态
localStorage.setItem('token', token)
store.ts
const store = createStore<GlobalDataProps>({
state: {
columns: [],
posts: [],
user: { isLogin: false },
loading: false,
token: localStorage.getItem('token') || ''
},
mutations: {
login (state, rawData) {
const { token } = rawData.data
state.token = token
localStorage.setItem('token', token)
axios.defaults.headers.common.Authorization = `Bearer ${token}`
}
}
})
App.vue
setup () {
const store = useStore<GlobalDataProps>()
const currentUser = computed(() => store.state.user)
const token = computed(() => store.state.token)
onMounted(() => {
if (!currentUser.value.isLogin && token.value) {
axios.defaults.headers.common.Authorization = `Bearer ${token.value}`
store.dispatch('fetchCurrentUser')
}
})
return {
currentUser
}
}
5 通用错误处理
store.ts
const store = createStore<GlobalDataProps>({
state: {
columns: [],
posts: [],
user: { isLogin: false },
loading: false,
token: localStorage.getItem('token') || '',
error: { status: false }
},
mutations: {
setError (state, e: GlobalErrorProps) {
state.error = e
}
}
})
main.ts
axios.interceptors.response.use(config => {
store.commit('setLoading', false)
return config
}, e => {
const { error } = e.response.data
store.commit('setError', { status: true, message: error })
store.commit('setLoading', false)
return Promise.reject(error)
})
axios.interceptors.response.use(config => {
store.commit('setLoading', false)
return config
}, e => {
const { error } = e.response.data
store.commit('setError', { status: true, message: error })
store.commit('setLoading', false)
return Promise.reject(error)
})
App.vue
setup () {
const store = useStore<GlobalDataProps>()
const error = computed(() => store.state.error)
return {
error
}
}
6 创建 Message
组件
6.1 全局生成节点 -- 解耦
hooks > useDOMCreate.ts
import { onUnmounted } from 'vue'
function useDOMCreate (nodeId: string) {
const node = document.createElement('div')
node.id = nodeId
document.body.appendChild(node)
onUnmounted(() => {
document.body.removeChild(node)
})
}
export default useDOMCreate
6.2 基本实现
<template>
<div v-if="isVisible" class="alert message-info fixed-top w-50 mx-auto d-flex justify-content-between mt-2" :class="classObject">
<span>{{ message }}</span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close" @click.prevent="hide"></button>
</div>
</template>
<script lang='ts'>
import { defineComponent, PropType, ref } from 'vue'
export type MessageType = 'success' | 'error' | 'default'
export default defineComponent({
props: {
message: String,
type: {
type: String as PropType<MessageType>,
default: 'default'
}
}
emits: ['close-message'],
setup (props, context) {
const isVisible = ref(true)
const classObject = {
'alert-success': props.type === 'success',
'alert-danger': props.type === 'error',
'alert-primary': props.type === 'default'
}
const hide = () => {
isVisible.value = false
context.emit('close-message', true)
}
return {
isVisible,
classObject,
hide
}
}
})
</script>
6.3 使用 Teleport
进行改造
<template>
<teleport to="#message">
</teleport>
</template>
<script lang='ts'>
import useDOMCreate from '../hooks/useDOMCreate'
export default defineComponent({
setup (props, context) {
useDOMCreate('message')
}
})
</script>
7 Message
组件改进为函数调用形式
- 难点:怎样用函数的形式创建组件?
7.1 创建 createMessage
函数
components > createMessage.ts
createApp(组件实例, 组件props)
import { createApp } from 'vue'
import Message from './Message.vue'
export type MessageType = 'success' | 'error' | 'default'
const createMessage = (message: string, type: MessageType, timeout = 2000) => {
// 1. 仿照生成app实例
const messageInstance = createApp(Message, {
message,
type
})
const mountNode = document.createElement('div')
document.body.appendChild(mountNode)
// 2. 仿照app实例挂载
messageInstance.mount(mountNode)
setTimeout(() => {
messageInstance.unmount()
document.body.removeChild(mountNode)
}, timeout)
}
export default createMessage
7.2 使用
App.vue
import createMessage from './components/createMessage'
// ... createMessage(message, 'error') ...
Login.vue
createMessage('登录成功 2秒后跳转首页', 'success')
第6章 上传组件
1 上传组件需求分析
- 点击上传图片,支持
jpg
或者png
格式 --beforeUpload
- 正在上传 --
uploading
- 上传之后的图片展示 --
fileUploaded | uploadedError
<upload
action=""
beforeUpload=""
@uploading=""
@fileUploaded=""
@uploadedError=""
>
<Button />
<template #uploaded></template>
<template #loading></template>
</upload>
2 上传文件的实现
<input type="file" name="file" @change.prevent="handleFileChange" />
import axios from 'axios'
export default defineComponent({
setup () {
const handleFileChange = async (e: Event) => {
const target = e.target as HTMLInputElement
const files = target.files
if (files) {
const uploadedFile = files[0]
const formData = new FormData()
formData.append(uploadedFile.name, uploadedFile)
const resp: any = await axios.post('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
}
}
})
3 Uploader
组件
<div class="file-upload">
<button class="btn btn-primary" @click.prevent="triggerUpload">
<span v-if="fileStatus === 'loading'">正在上传...</span>
<span v-else-if="fileStatus === 'success'">上传成功</span>
<span v-else>点击上传</span>
</button>
<input type="file" class="file-input d-none" ref="fileInput" @change.prevent="handleFileChange" />
</div>
import { defineComponent, ref } from 'vue'
import axios from 'axios'
type UploadStatus = 'ready' | 'loading' | 'success' | 'error'
export default defineComponent({
name: 'Uploader',
props: {
action: {
type: String,
required: true
}
},
setup (props) {
const fileInput = ref<null | HTMLInputElement>(null)
const fileStatus = ref<UploadStatus>('ready')
const triggerUpload = () => {
if (fileInput.value) {
fileInput.value.click()
}
}
const handleFileChange = async (e: Event) => {
const currentTarget = e.target as HTMLInputElement
if (currentTarget.files) {
fileStatus.value = 'loading'
const files = Array.from(currentTarget.files)
const formData = new FormData()
formData.append('file', files[0])
try {
await axios.post(props.action, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
fileStatus.value = 'success'
} catch (error) {
fileStatus.value = 'error'
}
if (fileInput.value) {
fileInput.value.value = ''
}
}
}
return {
fileInput,
fileStatus,
triggerUpload,
handleFileChange
}
}
})
3.1 自定义事件
Uploader.vue
export default defineComponent({
name: 'Uploader',
props: {
// ...
beforeUpload: {
type: Function as PropType<CheckFunction>
}
},
emits: ['file-uploaded', 'file-uploaded-error'],
setup (props, context) {
// ...
const handleFileChange = async (e: Event) => {
// ...
try {
// ...
fileStatus.value = 'success'
context.emit('file-uploaded', resp.data)
} catch (error) {
fileStatus.value = 'error'
context.emit('file-uploaded-error', { error })
}
if (fileInput.value) {
fileInput.value.value = ''
}
}
}
}
})
Home.vue
<Uploader action="/upload" :beforeUpload="beforeUpload" @file-uploaded="onFileUploaded" />
import { ResponseType, ImageProps } from '../store'
export default defineComponent({
name: 'Home',
components: {
ColumnList,
Uploader
},
setup () {
const beforeUpload = (file: File) => {
const isJPG = file.type === 'image/jpeg'
if (!isJPG) {
createMessage('上传图片只能是 JPG 格式!', 'error')
}
return isJPG
}
const onFileUploaded = (rawData: ResponseType<ImageProps>) => {
createMessage(`上传图片ID ${rawData.data._id}`, 'success')
}
return {
beforeUpload,
onFileUploaded
}
}
})
3.2 自定义模版
1. slot
- 定义
<slot v-if="fileStatus === 'loading'" name="loading">
<button class="btn btn-primary" disabled>正在上传...</button>
</slot>
<slot v-else-if="fileStatus === 'success'" name="uploaded">
<button class="btn btn-primary">上传成功</button>
</slot>
<slot v-else name="default">
<button class="btn btn-primary">点击上传</button>
</slot>
- 使用
<uploader action="/upload" :beforeUpload="beforeUpload" @file-uploaded="onFileUploaded">
<h2>点击上传</h2>
<template #loading>
<div class="spinner-border" role="status">
<span class="sr-only"></span>
</div>
</template>
</uploader>
2. 自定义数据
- 定义
<slot v-if="fileStatus === 'loading'" name="loading">
<button class="btn btn-primary" disabled>正在上传...</button>
</slot>
<slot v-else-if="fileStatus === 'success'" name="uploaded" :uploadedData="uploadedData">
<button class="btn btn-primary">上传成功</button>
</slot>
<slot v-else name="default">
<button class="btn btn-primary">点击上传</button>
</slot>
const uploadedData = ref()
// success 时
uploadedData.value = resp.data
- 使用
<uploader action="/upload" :beforeUpload="beforeUpload" @file-uploaded="onFileUploaded">
<template #uploaded="dataProps">
<img :src="dataProps.uploadedData.data.url" width="200" alt="">
</template>
</uploader>
4 改进路由验证系统
router.beforeEach((to, from, next) => {
const { user, token } = store.state
const { requiredLogin, redirectAlreadyLogin } = to.meta
if (!user.isLogin) {
if (token) {
axios.defaults.headers.common.Authorization = `Bearer ${token}`
store.dispatch('fetchCurrentUser').then(() => {
if (redirectAlreadyLogin) {
next('/')
} else {
next()
}
}).catch(e => {
console.error(e)
localStorage.removeItem('token')
next('login')
})
} else {
if (requiredLogin) {
next('login')
} else {
next()
}
}
} else {
if (redirectAlreadyLogin) {
next('/')
} else {
next()
}
}
})
5 创建文章页面实现 Uploader
自定义样式
<uploader
action="/upload"
class="d-flex align-items-center justify-content-center bg-light text-secondary w-100 my-4"
>
<h2>点击上传头图</h2>
<template #loading>
<div class="d-flex">
<div class="spinner-border text-secondary" role="status">
<span class="sr-only"></span>
</div>
<h2>正在上传</h2>
</div>
</template>
<template #uploaded='dataProps'>
<img :src="dataProps.uploadedData.data.url" alt="">
</template>
</uploader>
.create-post-page .file-upload-container {
height: 200px;
cursor: pointer;
}
.create-post-page .file-upload-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
6 创建文章最后流程
6.1 上传前检测图片格式、图片大小
- 检测功能
helper.ts
interface CheckCondition {
format?: string[];
size?: number;
}
type ErrorType = 'size' | 'format' | null
export function beforUploadCheck (file: File, condition: CheckCondition) {
const { format, size } = condition
const isValidFormat = format ? format.includes(file.type) : true
const isValidSize = size ? (file.size / 1024 / 1024 < size) : true
let error: ErrorType = null
if (!isValidFormat) {
error = 'format'
}
if (!isValidSize) {
error = 'size'
}
return {
passed: isValidFormat && isValidSize,
error
}
}
- 应用
<uploader
action="/upload"
:beforeUpload="uploadCheck"
class="d-flex align-items-center justify-content-center bg-light text-secondary w-100 my-4"
>
</uploader>
const uploadCheck = (file: File) => {
const condition = {
format: ['image/jpeg', 'image/png'],
size: 0.5
}
const result = beforUploadCheck(file, condition)
const { passed, error } = result
if (error === 'format') {
createMessage('上传图片只能是 JPG 格式!', 'error')
}
if (error === 'size') {
createMessage('上传图片大小不能超过 0.5Mb', 'error')
}
return passed
}
6.2 文件上传 + 创建文章
1. 文件上传
<uploader
action="/upload"
:beforeUpload="uploadCheck"
@file-uploaded="handleFileUploaded"
class="d-flex align-items-center justify-content-center bg-light text-secondary w-100 my-4"
>
<!-- ... -->
const handleFileUploaded = (rawData: ResponseType<ImageProps>) => {
if (rawData.data._id) {
imageId = rawData.data._id
}
}
2. 创建文章
store.ts
mutations: {
createPost (state, newPost) {
state.posts.push(newPost)
}
},
actions: {
createPost ({ commit }, payload) {
return postAndCommit('/posts', 'createPost', commit, payload)
}
}
createPost.vue
const onFormSubmit = (result: boolean) => {
if (result) {
const { column, _id } = store.state.user
if (column) {
const newPost: PostProps = {
_id: new Date().getTime() + '',
title: titleVal.value,
content: contentVal.value,
column: column + '',
createdAt: new Date().toLocaleString(),
author: _id
}
// 以下为新增的内容
if (imageId) {
newPost.image = imageId
}
store.dispatch('createPost', newPost).then(() => {
createMessage('发表成功,2s后跳转到文章', 'success', 2000)
setTimeout(() => {
router.push({ name: 'Column', params: { id: column } })
}, 2000)
})
}
}
}
第7章 编辑和删除文章
1 添加编辑和删除区域
PostDetail.vue
<div v-if="showEditArea" class="btn-group mt-5">
<router-link
type="button"
class="btn btn-success"
:to="{ name: 'CreatePost', query: { id: currentPost._id }}"
>编辑</router-link>
<button type="button" class="btn btn-danger">删除</button>
</div>
const showEditArea = computed(() => {
const { isLogin, _id } = store.state.user
if (currentPost.value?.author && isLogin) {
const postAuthor = currentPost.value.author as UserProps
return postAuthor._id === _id
} else {
return false
}
})
2 修改文章编码 -- 回显
CreatePost.vue
setup() {
const route = useRoute()
const isEditMode = !!route.query.id
onMounted(() => {
if (isEditMode) {
store.dispatch('fetchPost', route.query.id).then((rawData: ResponseType<PostProps>) => {
const currentPost = rawData.data
if (currentPost.image) {
uploadedData.value = { data: currentPost.image }
}
})
}
})
return { uploadedData }
}
<uploader
action="/upload"
:beforeUpload="uploadCheck"
@file-uploaded="handleFileUploaded"
:uploaded="uploadedData" // 新增传参
class="d-flex align-items-center justify-content-center bg-light text-secondary w-100 my-4"
>
2.1 改进 Uploader
组件
props: {
uploaded: {
type: Object
}
},
setup (props, context) {
const uploadedData = ref(props.uploaded)
watch(() => props.uploaded, newValue => {
if (newValue) {
fileStatus.value = 'success'
uploadedData.value = newValue
}
})
}
2.2 改进 ValidateInput
组件
1. 使用 watch
监听数据更新
setup (props, context) {
const inputRef = reactive({
val: props.modelValue || '',
error: false,
message: ''
})
watch(() => props.modelValue, newValue => {
inputRef.val = newValue || ''
})
const updateValue = (e: KeyboardEvent) => {
const targetValue = (e.target as HTMLInputElement).value
inputRef.val = targetValue
context.emit('update:modelValue', targetValue)
}
}
<input
v-if="tag !== 'textarea'"
class="form-control"
:class="{'is-invalid': inputRef.error}"
:value="inputRef.val"
@blur="validateInput"
@input="updateValue"
v-bind="$attrs"
>
使用
watch
监听数据更新 -> 但是输入框@input
事件触发时也会触发 -> 使用v-model + computed
2. 使用 v-model + computed
setup (props, context) {
const inputRef = reactive({
val: computed({
get: () => props.modelValue || '',
set: val => {
context.emit('update: modelValue', val)
}
}),
error: false,
message: ''
})
}
<input
v-if="tag !== 'textarea'"
class="form-control"
:class="{'is-invalid': inputRef.error}"
v-model="inputRef.val"
@blur="validateInput"
v-bind="$attrs"
>
2.3 完成编辑功能
1. 重构发送请求函数
store.ts
import axios, { AxiosRequestConfig } from 'axios'
const asyncAndCommit = async (url: string, mutationName: string, commit: Commit, config: AxiosRequestConfig = { method: 'get' }) => {
const { data } = await axios(url, config)
commit(mutationName, data)
return data
}
const store = createStore<GlobalDataProps>({
mutations: {
updatePost (state, { data }) {
state.posts = state.posts.map(post => {
if (post._id === data._id) {
return data
} else {
return post
}
})
}
},
actions: {
updatePost ({ commit }, { id, payload }) {
return asyncAndCommit(`/posts/${id}`, 'updatePost', commit, {
method: 'patch',
data: payload
})
}
}
})
export default store
2. 发送请求
CreatePost.vue
setup () {
const route = useRoute()
const isEditMode = !!route.query.id
const onFormSubmit = (result: boolean) => {
// ...
const actionName = isEditMode ? 'updatePost' : 'createPost'
const sendData = isEditMode ? {
id: route.query.id,
payload: newPost
} : newPost
store.dispatch(actionName, sendData).then(() => {
// ...
})
}
}
<h4>{{ isEditMode ? '编辑文章' : '新建文章'}}</h4>
<span class="btn btn-primary btn-large">{{ isEditMode ? '更新文章' : '发表文章'}}</span>
3 Modal
组件编码
3.1 Modal
组件
export default defineComponent({
name: 'Modal',
props: {
title: String,
visible: {
type: Boolean,
default: false
}
},
emits: ['modal-on-close', 'modal-on-confirm'],
setup (props, context) {
useDOMCreate('modal')
const onClose = () => {
context.emit('modal-on-close')
}
const onConfirm = () => {
context.emit('modal-on-confirm')
}
return {
onClose,
onConfirm
}
}
})
<teleport to="#modal">
...
</teleport>
3.2 父组件
<modal
title="删除文章"
:visible="modalIsVisible"
@modal-on-close="modalIsVisible = false"
@modal-on-confirm="modalIsVisible = false"
>
<p>确定要删除这篇文章吗?</p>
</modal>
const modalIsVisible = ref(false)
4 完成删除文章功能
const hideAndDelete = () => {
modalIsVisible.value = false
store.dispatch('deletePost', currentId).then((rawData:ResponseType<PostProps>) => {
createMessage('删除成功,2s后跳转到专栏首页', 'success', 2000)
setTimeout(() => {
router.push({ name: 'column', params: { id: rawData.data.column } })
}, 2000)
})
}
<button type="button" class="btn btn-danger" @click="modalIsVisible = true">删除</button>
第8章 持续优化
1 可以优化的两个点
1.1 解决结构冗余
1. flattern
:数组 -> 哈希map
const columns = [
{ id: '1', posts: [{ id: '1_1' }] },
{ id: '2', posts: [{ id: '2_1' }, { id: '2_2' }] }
]
=>
const columns = {
'1': { ...column },
'2': { ...column }
}
const posts = {
'1_1': { id: '1_1', cid: '1'},
'2_1': { id: '2_1', cid: '2'}
}
2. 数据结构的优化 -- state: arr -> obj
interface TestProps {
_id: string;
name: string;
}
const testData: TestProps[] = [{ _id: '1', name: 'a' }, { _id: '2', name: 'b' }]
const testData2: {[key: string]: TestProps} = {
1: { _id: '1', name: 'a' },
2: { _id: '2', name: 'b' }
}
// 01 添加泛型 <T>
// 02 约束泛型 T extends { _id?: string }
export const arrToObj = <T extends { _id?: string }>(arr: Array<T>) => {
return arr.reduce((prev, current) => {
if(current._id) {
prev[current._id] = current
}
return prev
}, {} as { [key: string]: T })
// 使用类型断言 {} as { [key: string]: T }
}
const result = arrToObj(testData)
export const objToArr = <T>(obj: { [key: string]: T }) => {
return Object.keys(obj).map(key => obj[key])
}
const result2 = objToArr(testData2)
3. 项目代码 store.ts
export interface ColumnProps {
_id: string;
title: string;
avatar?: ImageProps;
description: string;
}
export interface PostProps {
_id: string;
title: string;
excerpt?: string;
content?: string;
image?: ImageProps | string;
createdAt: string;
column: string;
author?: string | UserProps;
}
// 使用泛型
interface ListProps<P> {
[id: string]: P;
}
export interface GlobalErrorProps {
status: boolean;
message?: string;
}
export interface GlobalDataProps {
columns: ListProps<ColumnProps>;
posts: ListProps<PostProps>;
user: UserProps;
loading: boolean;
token: string;
error: GlobalErrorProps;
}
1.2 解决重复请求问题 -- 缓存已经存在的数据
1. flag
标识
const columns = {
data: 之前的多条数据,
isLoaded: true
}
2. 添加 flag
判断是否已经请求过
interface GlobalColums {
data: ListProps<ColumnProps>;
isLoaded: boolean;
}
/columns
columns.isLoaded
/columns/{id}
columns.data[id]
interface GlobalColums {
data: ListProps<ColumnProps>;
loadedColumns: string[];
}
/columns/{cid}/posts
posts.loadedColumns.includes(cid)
/posts/{id}
posts.data[id]
3. 项目代码 store.ts
columns
相关
state: {
columns: { data: {}, isLoaded: false },
},
mutations: {
fetchColumns (state, rawData) {
state.columns.data = arrToObj(rawData.data.list)
state.columns.isLoaded = true
},
},
actions: {
fetchColumns ({ state, commit }) {
if (!state.columns.isLoaded) {
return asyncAndCommit('/columns', 'fetchColumns', commit)
}
},
fetchColumn ({ state, commit }, cid) {
if (!state.columns.data[cid]) {
return asyncAndCommit(`/columns/${cid}`, 'fetchColumn', commit)
}
},
}
posts
相关
const asyncAndCommit = async (
url: string,
mutationName: string,
commit: Commit,
config: AxiosRequestConfig = { method: 'get' },
extraData?: any
) => {
const { data } = await axios(url, config)
if (extraData) {
commit(mutationName, { data, extraData })
} else {
commit(mutationName, data)
}
return data
}
state: {
posts: { data: {}, loadedColumns: [] },
},
mutations: {
fetchPosts (state, { data: rawData, extraData: columnId }) {
state.posts.data = { ...state.posts.data, ...arrToObj(rawData.data.list) }
state.posts.loadedColumns.push(columnId)
},
},
actions: {
fetchPosts ({ state, commit }, cid) {
if (!state.posts.loadedColumns.includes(cid)) {
return asyncAndCommit(`/columns/${cid}/posts`, 'fetchPosts', commit, { method: 'get' }, cid)
}
},
fetchPost ({ state, commit }, pid) {
const currentPost = state.posts.data[pid]
if (!currentPost || !currentPost.content) {
return asyncAndCommit(`/posts/${pid}`, 'fetchPost', commit)
} else {
// 兼容调用时的异步模式
return Promise.resolve({ data: currentPost })
}
},
}
2 useLoadMore
实现分析
- 第一次发送请求
const size = 5
dispatch.fetchColumns({ page: 1, size: 5 })
- 数据返回后
currentPage = 1
totalPage = Math.ceil(total / size)
- 点击加载更多按钮
dispatch.fetchColumns({ page: currentPage + 1, size: 5 })
- 数据返回后
currentPage++
- 一直到相等以后,隐藏加载更多按钮
currentPage === totalPage
- 自定义函数
// 1 确定它的参数
function useLoadMore(actionName, params) {
// 3 确定函数实现
const loadMorePage = () => {
store.dispatch()
// ....
}
const isLastPage = computed(() => {
Math.ceil(total / pageSize) === currentPage
})
// 2 确定它的返回
// 需要返回一个函数,让用户去在它想要的逻辑中加载
// 然后还有一个变量,指示是否是最后一页
// 有了这两个返回,用户就可以把界面和逻辑完全解耦
return {
loadMorePage,
isLastPage
}
}
3 useLoadMore
编码
import { useStore } from 'vuex'
import { ref, computed, ComputedRef } from 'vue'
interface LoadParams {
currentPage: number,
pageSize: number
}
const useLoadMore = (actionName: string, total: ComputedRef<number>,
params: LoadParams = { currentPage: 2, pageSize: 5 }) => {
const store = useStore()
const currentPage = ref(params.currentPage)
const requestParams = computed(() => ({
currentPage: currentPage.value,
pageSize: params.pageSize
}))
const loadMorePage = () => {
store.dispatch(actionName, requestParams).then(() => {
currentPage.value++
})
}
const isLastPage = computed(() => {
return Math.ceil(total.value / params.pageSize) < currentPage.value
})
return {
loadMorePage,
isLastPage,
currentPage
}
}
export default useLoadMore
4 useLoadMore
在首页实践
<button
v-if="!isLastPage"
class="btn btn-outline-primary mt-2 mb-5 mx-auto d-block w-25"
@click="loadMorePage"
>加载更多</button>
setup () {
const total = computed(() => store.state.columns.total)
const { loadMorePage, isLastPage } = useLoadMore('fetchColumns', total, { pageSize: 3, currentPage: 2 })
return {
loadMorePage,
isLastPage
}
}
5 useLoadMore
支持数据缓存 解决方案分析
interface GlobalColumnsProps {
data: ListProps<ColumnProps>;
total: number;
currentPage: number;
}
// loadedColumns: [1, 2, 3] =>
[{ columnId: 1, currentPage: 3, total: 50 },
{ columnId: 2, currentPage: 4, total: 40 }]
// arrToObj =>
{
1: { columnId: 1, currentPage: 3, total: 50 },
2: { columnId: 2, currentPage: 4, total: 40 }
}
export interface GlobalPostsProps {
data: ListProps<PostProps>;
loadedColumns: ListProps<{ total?: number; currentPage?: number }>
}
6 实现分页缓存逻辑
store.ts
interface GlobalColumns {
data: ListProps<ColumnProps>;
currentPage: number;
total: number;
}
export interface GlobalDataProps {
columns: GlobalColumns;
posts: GlobalProps;
user: UserProps;
loading: boolean;
token: string;
error: GlobalErrorProps;
}
state: {
columns: { data: {}, currentPage: 0, total: 0 },
},
mutations: {
fetchColumns (state, rawData) {
const { data } = state.columns
const { list, count, currentPage } = rawData.data
state.columns = {
data: { ...data, ...arrToObj(list) },
total: count,
currentPage: currentPage * 1
}
},
},
actions: {
fetchColumns ({ state, commit }, params = {}) {
const { currentPage = 1, pageSize = 6 } = params
if (state.columns.currentPage < currentPage) {
return asyncAndCommit(`/columns?currentPage=${currentPage}&pageSize=${pageSize}`, 'fetchColumns', commit)
}
},
}
Home.vue
const { loadMorePage, isLastPage } =
useLoadMore('fetchColumns', total,
{ pageSize: 3, currentPage: currentPage.value ? currentPage.value + 1 : 2 })
标签:知乎,const,ts,state,vue,return,id,store 来源: https://www.cnblogs.com/pleaseAnswer/p/16690701.html