其他分享
首页 > 其他分享> > ③ vue+ts 实现 模拟知乎后台

③ vue+ts 实现 模拟知乎后台

作者:互联网

目录

第1章 项目起航

1 项目起航 需求分析

1.1 完美的 vue 实践项目是怎样的

1.2 需求

组件需求

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 代码规范

3 样式解决方案简介和分析

从好用的样式库开始

4 设计图拆分和组件属性分析

4.1 开发流程

  1. UI 划分出组件的层级
  2. 创建应用的静态版本(解耦)

4.2 组件属性分析

<ColumnList list={{columns}} />

interface ColumnProps {
  id: number;
  avatar: string;
  title: string;
  description: string;
}

5 ColumnList 组件编码

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
    }
  }
})
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
    }
  }
})
<li class="dropdown-option" :class="{'is-disabled': disabled}">
  <slot></slot>
</li>
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 表单

2 ValidateInput

2.1 简单的实现

<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 函数
  1. 组件内数据
const inputRef = reactive({
   val: '',
   error: false,
   message: ''
})
  1. 校验函数
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' />
  1. vue2
<my-component :value='val' @input='val = arguments[0]' />
  1. vue3 compile 以后的结果
h(Com, {
  modelValue: val,
  'onUpdate:modelValue': value => (val = value)
})
组件支持 v-model
  1. 创建名为 modelValueprops 属性
props: {
  modelValue: String
},
  1. 更新时触发 update:modelValue 事件
const updateValue = (e: KeyboardEvent) => {
   const targetValue = (e.target as HTMLInputElement).value
   inputRef.val = targetValue
   context.emit('update:modelValue', targetValue)
 }

2.4 使用 $attrs 支持默认属性

propattribute 将自动添加到根节点的 attribute

如何使得属性添加到指定元素上?
  1. 禁用组件根元素继承属性:inheritAttrs: false

  2. 借用 $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 尝试父子通讯

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-routervuex

1 什么是 SPA(Single Page Application) 应用?

优点

  1. 速度快,第一次下载完成静态文件,跳转不需要再次下载
  2. 体验好,整个交互趋于无缝,更倾向于原生应用
  3. 为前后端分离提供了实践场所

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>

3 vue-router 安装和使用

3.1 安装

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 全局对象的弊端

  1. 数据不是响应式的
  2. 数据修改无法追踪
  3. 不符合组件开发的原则

7.2 状态管理工具的原则

8 Vuex 简介和安装

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 前后端分离开发的数据交互

1.2 前后端分离的开发模式

1.3 优点

2 RESTful API 设计理念

https://api.examples.com/teams
https://api.examples.com/players

2.1 HTTP 动词

2.2 常见状态码

3 使用 swagger 在线文档查看接口详情

3.1 接口文档需要包括的点

  1. endponits 是具体的路径,或者说是网址
  2. 使用什么样的 methodget post put patch 或者 delete
  3. 发送请求要有什么样的参数,参数是在 url 上的 query 还是 body 里面的复杂信息
  4. 请求返回的格式是什么样的
### 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 使用 asyncawait 改造异步请求

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 的运行机制

验证存在

  1. 服务器创建对应的 session 数据并保存
  2. 接收到请求,服务器使用 cookie 中的信息查看服务器中是否存在该 session 数据

验证正确

  1. 服务器使用 jwt 算法生成对应 token
  2. 接收到请求,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 登录 -- 持久化登录状态

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

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 上传组件需求分析

  1. 点击上传图片,支持 jpg 或者 png 格式 -- beforeUpload
  2. 正在上传 -- uploading
  3. 上传之后的图片展示 -- 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
  1. 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)
    }
  },
}
  1. 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 实现分析

  1. 第一次发送请求
const size = 5
dispatch.fetchColumns({ page: 1, size: 5 })
  1. 数据返回后
currentPage = 1
totalPage = Math.ceil(total / size)
  1. 点击加载更多按钮
dispatch.fetchColumns({ page: currentPage + 1, size: 5 })
  1. 数据返回后
currentPage++
  1. 一直到相等以后,隐藏加载更多按钮
currentPage === totalPage
  1. 自定义函数

// 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