ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

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

2022-09-13 20:32:24  阅读:473  来源: 互联网

标签:知乎 const ts state vue return id store


目录

第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 开发流程

  1. UI 划分出组件的层级
  2. 创建应用的静态版本(解耦)
  • 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 函数
  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 尝试父子通讯

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

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

7.2 状态管理工具的原则

  • 一个类似 object 的全局数据结构 -- 称之为 store
  • 只能调用一些特殊的方法来实现数据修改

8 Vuex 简介和安装

  • vuex 的核心是 Storestore 包含应用中大多数状态 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 接口文档需要包括的点

  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 登录 -- 持久化登录状态

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

  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

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有