ai-要約を取得 文章摘要

Vue3.2+Vite+Typescript 开发双端招聘全过程系列导航

  1. 基础环境配置(一)
  2. 可复用的页面交互逻辑(二) ⇦当前位置🪂
  3. 个人中心模块(三)
  4. 即将闪亮登场..

基于vue实现常见的Tab切换组件

点击设计思路与实现

核心思路:携带不同标签页的id分别发起api请求,根据id值分别得到对应的返回结果,进而渲染出一一对应的数据

<script setup lang="ts">
import { reactive } from 'vue'

// 先准备待渲染的数据
const tabs = [
{
type: 2,
text: '待签约'
},
{
type: 3,
text: '履约中'
},
{
type: 4,
text: '已完成'
},
{
type: 5,
text: '已失效'
}
]

// 用于在 html 结构中为标签添加动态属性
const state = reactive({
type: tabs[0].type
})

// 通过简单的赋值运算、布尔判断,为标签添加 active 类
const setTabList = type => {
if (type === state.type) return
state.type = type
}
</script>

<template>
<div class="contract-tab">
<span
v-for="(item, index) in tabs"
:key="index"
:class="state.type === item.type ? 'active' : ''"
@click="setTabList(item.type)"
>{{ item.text }}<i></i
></span>
</div>
</template>

<style lang="scss" scoped>
.contract-tab {
display: flex;
span {
font-size: 0.75rem;
line-height: 0.75rem;
font-weight: 400;
color: #666666;
flex: 1;
align-items: center;
border-bottom: 1px solid #eeeeee;
padding: 0.59rem 0;
text-align: center;
position: relative;
&.active {
color: #ff9415;
i {
width: 1.63rem;
height: 0.05rem;
background-color: #ff9415;
position: absolute;
bottom: 0px;
left: 50%;
margin-left: -0.815rem;
}
}
}
}
</style>

将 type 值存进 store 中,实现返回上一级时,停在之前的 tab 上

/src/stores/contract.ts

import { defineStore } from 'pinia'

export const contractStore = defineStore({
id: 'contract',
state: () => {
return {
type: 2
}
},
actions: {
setType(type) {
this.type = type
}
}
})

然后,在 ContractPage.vue 组件中

const state = reactive({
type: store.type || tabs[0].type, // 优先从 store 里面取值
list: [],
loading: false,
activeIndex: 0
})

const setTabList = type => {
if (type === state.type) return
state.type = type
store.setType(type)
// console.log(`点击了第${store.type}个标签`)

getContractList()
}

vant组件下拉刷新的 bug 修复

这个直接在代码中注释了
<van-pull-refresh v-model="state.loading" @refresh="getContractList">
<ContractList :contract-list="state.list"></ContractList>
// 新增这个加载组件,并在上方 state 中声明 activeIndex ,默认值为 0
// 在下拉组件的 @refresh 函数中,添加 state.activeIndex++ 的逻辑
// 这样以来,在页面载入后的首次请求中,就会满足设定的条件,显示“加载中..”
// 随后的请求中 activeIndex 不再 === 0 ,就不会再次触发了
<van-loading v-if="state.activeIndex === 0 && state.loading">加载中...</van-loading>
<div v-if="!state.loading && state.list.length == 0" class="wy-no-data">暂无数据</div>
</van-pull-refresh>

按键防抖

点击查看详情
// 原始代码
// directives 是一个函数,用于注册自定义指令到 Vue 应用实例中。
// 参数 app 是 Vue 应用实例,通过它可以调用 app.directive 方法注册全局指令。
export const directives = (app:any) => {
// 注册了一个名为 debounce 的指令,通过 v-debounce 的形式在模板中使用。
app.directive('debounce', {
// mounted:当指令绑定的 DOM 元素挂载到页面时触发。
mounted(el:any,binding:any) {
// 检查传入指令的值是否为一个函数。因为防抖机制需要包裹一个函数回调
// 如果不是函数,直接退出。
if(typeof binding.value !=='function') return
// timer:用于保存定时器 ID,用于防抖逻辑。
// handler:绑定事件的实际执行函数。
el.timer = null
el.handler = function () {
// 防抖逻辑:
// 如果 timer 不为空,清除之前的定时器。
if (el.timer) {
clearTimeout(el.timer)
}
// 使用 setTimeout 创建一个新的定时器,延迟 600ms 后
// 调用 binding.value,即用户传入的回调函数。
el.timer = setTimeout(() => {
binding.value.apply(this, arguments)
},600)
}
// 将 el.handler 绑定为元素的 click 事件处理程序。
// 当用户点击绑定了 v-debounce 的元素时,el.handler 将控制回调函数的触发频率。
el.addEventListener('click',el.handler)
},
// beforeUnmount:当指令绑定的 DOM 元素被移除时触发。
beforeUnmount(el:any, binding:any) {
if(el.timer){
el.timer = null
// 清除定时器,避免内存泄漏。
clearTimeout(el.timer)
}
// 移除绑定的 click 事件监听器。
el.removeEventListener('click',el.handler)
},
})
}

这段代码定义了一个自定义 Vue 指令 v-debounce,用于防止某些事件(比如 click)在短时间内被多次触发。通过引入“防抖”机制(Debounce),仅在事件停止触发一段时间后才执行回调函数。

以下是对这段代码的详细解读,包括其实现逻辑和工作原理:


代码功能

这段代码定义了一个自定义 Vue 指令 v-debounce,用于防止某些事件(比如 click)在短时间内被多次触发。通过引入“防抖”机制(Debounce),仅在事件停止触发一段时间后才执行回调函数。


代码逐段解析

1. 导出函数 directives

export const directives = (app: any) => {
app.directive('debounce', {
...
})
}
  • directives 是一个函数,用于注册自定义指令到 Vue 应用实例中。
  • 参数 app 是 Vue 应用实例,通过它可以调用 app.directive 方法注册全局指令。

2. 注册指令 debounce

  • 注册了一个名为 debounce 的指令,通过 v-debounce 的形式在模板中使用。
  • 该指令的生命周期方法包括:
    • mounted:当指令绑定的 DOM 元素挂载到页面时触发。
    • beforeUnmount:当指令绑定的 DOM 元素被移除时触发。

3. mounted 钩子

mounted(el: any, binding: any) {
if (typeof binding.value !== 'function') return
  • 参数说明:

    • el:指令绑定的 DOM 元素。
    • binding:包含指令相关信息的对象,例如 binding.value 是传入指令的值。
  • 逻辑

    • 检查传入指令的值是否为一个函数。因为防抖机制需要包裹一个函数回调,如果不是函数,直接退出。

el.timer = null
el.handler = function () {
if (el.timer) {
clearTimeout(el.timer)
}
el.timer = setTimeout(() => {
binding.value.apply(this, arguments)
}, 600)
}
  • 功能

    • 给el添加两个属性:

      • timer:用于保存定时器 ID,用于防抖逻辑。
      • handler:绑定事件的实际执行函数。
    • 防抖逻辑:

      1. 如果 timer 不为空,清除之前的定时器。
      2. 使用 setTimeout 创建一个新的定时器,延迟 600ms 后调用 binding.value,即用户传入的回调函数。

el.addEventListener('click', el.handler)
  • el.handler 绑定为元素的 click 事件处理程序。
  • 当用户点击绑定了 v-debounce 的元素时,el.handler 将控制回调函数的触发频率。

4. beforeUnmount 钩子

beforeUnmount(el: any, binding: any) {
if (el.timer) {
el.timer = null
clearTimeout(el.timer)
}
el.removeEventListener('click', el.handler)
}
  • 功能

    • 在指令绑定的元素即将从 DOM 移除时:
      1. 清除定时器,避免内存泄漏。
      2. 移除绑定的 click 事件监听器。

核心逻辑

  1. 防抖机制
    • 用户频繁触发事件时,函数不会立即执行,而是等用户停止操作一段时间后才执行最后一次触发的回调函数。
    • 防止短时间内多次调用带来的性能问题或逻辑错误。
  2. 生命周期管理
    • 使用 Vue 指令的生命周期(mountedbeforeUnmount)确保指令的逻辑正确注册和清理,避免内存泄漏或事件重复绑定的问题。
// 严格类型声明的代码
import type { DirectiveBinding, ObjectDirective } from 'vue'

// 自定义类型,扩展 HTMLElement
interface DebouncedHTMLElement extends HTMLElement {
timer?: NodeJS.Timeout | null
handler?: (...args: unknown[]) => void
}

// 定义自定义指令
export const directives = (app: {
directive: (name: string, options: ObjectDirective) => void
}) => {
app.directive('debounce', {
mounted(el: DebouncedHTMLElement, binding: DirectiveBinding) {
if (typeof binding.value !== 'function') {
console.error('v-debounce expects a function as its value')
return
}

el.timer = null

// 定义防抖处理函数
el.handler = function (...args: unknown[]) {
if (el.timer) {
clearTimeout(el.timer)
}
el.timer = setTimeout(() => {
binding.value.apply(this, args)
}, 600)
}

// 添加事件监听
el.addEventListener('click', el.handler)
},
beforeUnmount(el: DebouncedHTMLElement) {
if (el.timer) {
clearTimeout(el.timer)
el.timer = null
}

if (el.handler) {
el.removeEventListener('click', el.handler)
}
}
})
}

最后要在main.ts中注册

import { createApp } from 'vue'
import 'vant/lib/index.css' // 引入 vant 全局样式
import './assets/css/style.css' // 引入自定义的全局样式, 放在vant后面, 避免被 vant ui 样式覆盖
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

// 组合式 setup api 中, 可直接按需引入, 而无需进行注册
// import { Button, NavBar, Tabbar, TabbarItem, Checkbox, Toast, Icon } from 'vant'

import '@/utils/rem' // 引入 rem 适配方案
import { directives } from '@/utils/common' // 引入防抖

const app = createApp(App)
directives(app)

app.use(createPinia())
app.use(router)
app.mount('#app')

底部 tabbar 的显示和隐藏逻辑设计与实现

点击查看详情

保持根组件的简洁,将 vant 中的组件进行二次封装后,写在app.vue 中

<script setup lang="ts">
import ScrollFooter from './components/ScrollFooter.vue'
</script>

<template>
<RouterView />
<ScrollFooter></ScrollFooter>
</template>

<style scoped></style>

/src/components/ScrollFooter.vue

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
// 控制 FooterTabbar 的显示与隐藏
const showFooter = ref(true)
let lastScrollTop = 0 // 上一次的滚动位置
// eslint-disable-next-line no-undef
let scrollTimeout: NodeJS.Timeout | null = null // 用于监听停止滚动的定时器

// 滚动事件逻辑
function handleScroll() {
const scrollTop = document.documentElement.scrollTop

if (scrollTop > lastScrollTop) {
// 向下滚动,隐藏 Footer
showFooter.value = false
} else if (scrollTop < lastScrollTop) {
// 向上滚动,显示 Footer
showFooter.value = true
}

// 设置停止滚动后的逻辑
if (scrollTimeout) clearTimeout(scrollTimeout)
scrollTimeout = setTimeout(() => {
showFooter.value = true // 停止滚动后归位
}, 900)

lastScrollTop = scrollTop
}

// 监听滚动事件
onMounted(() => {
window.addEventListener('scroll', handleScroll)
})

onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
if (scrollTimeout) clearTimeout(scrollTimeout)
})
</script>

<template>
<transition name="footer-transition">
<!-- 动态显示 FooterTabbar -->
<FooterTabbar
v-show="
showFooter &&
!$route.path.startsWith('/login') &&
!$route.path.startsWith('/task/TaskDetails') &&
!$route.path.startsWith('/contract/ContractDetails') &&
!$route.path.startsWith('/contract/ContractProgress')
"
/>
</transition>
</template>

<style scoped>
/* 过渡动画 */
.footer-transition-enter-active,
.footer-transition-leave-active {
transition:
transform 0.7s ease,
opacity 0.7s ease;
}

/* 隐藏时向下滑动并变透明 */
.footer-transition-enter-from,
.footer-transition-leave-to {
transform: translateY(100%);
opacity: 0;
}

/* 显示时恢复正常位置 */
.footer-transition-enter-to,
.footer-transition-leave-from {
transform: translateY(0);
opacity: 1;
}
</style>

新消息通知红点技术方案设计与实现

点击查看详情

消息模块中的 index.vue

<script setup lang="ts">
import MessageList from '@/components/list/MessageList.vue'
import { messageStore } from '@/stores/message'
const store = messageStore()
store.getSystemMessageList()
</script>

<template>
// 组件化思想
<MessageList :message-list="store.systemNewMessage"></MessageList>
</template>

<style scoped></style>

/src/components/list/MessageList.vue

const { messageList } = defineProps<{
messageList
}>()

defineProps 泛型参数 <{ messageList }>

  • 泛型定义了 props 的类型,帮助 TypeScript 提供更好的类型推断。
  • 在这个例子中,泛型 { messageList } 表示组件的 props 中应该有一个名为 messageList 的属性。
  • 未指定 messageList 的具体类型,所以默认会是 any 类型。

消息红点的实现方法

现在样式中适用span标签写好一个小红点

// html 结构
// 根据返回值,使用 v-if 条件加载即可
<span v-if="item.is_show"></span>

// css 样式
span {
float: right;
color: #999999;
font-size: 0.7rem;
font-weight: 100;
}

泛型声明&类型断言

点击查看详情
// api 接口中 使用泛型声明
// 消息列表接口
export function systemList<T>(data): Promise<{ data: T }> {
return request({
url: '/user/inform/list',
method: 'get',
params: data
})
}

具体来说:

1. systemList<T>(data) 的泛型声明

export function systemList<T>(data): Promise<{ data: T }> { 
return request({
url: '/user/inform/list',
method: 'get',
params: data
});
}
  • systemList<T>(data) 中的 <T> 是一个 泛型类型参数,它允许你在调用 systemList 函数时指定一个具体的类型。T 是一个占位符,它代表了你希望 systemList 返回的响应数据的类型。

2. Promise<T> 的含义

Promise<T> 是一个 泛型类型,用于描述异步操作的结果类型。在这个例子中,Promise<T> 表示 systemList 函数返回的 Promise 对象,而该 Promise 最终会解析为类型为 T 的数据。

两者的关系

  • systemList 函数的返回值是一个 Promise<T>,即一个异步操作的结果,最终将会返回类型为 T 的数据。
  • 你在调用 systemList 时,传入的 T 决定了你希望从 request 调用中获得的响应数据的类型。

解释过程

  1. 泛型传递:函数 systemList 定义了一个类型参数 T,这意味着你在调用该函数时,可以指定 T 的实际类型。
  2. 请求返回类型Promise<T> 表示异步操作的返回类型。当你执行 request 请求并获得响应时,返回的数据会被视作类型 T

示例:

假设你希望从接口 /user/inform/list 获取的响应数据是一个包含多个消息对象的数组,你可以在调用 systemList 时指定 T 的类型。

例如:

interface Message {
id: number;
title: string;
content: string;
}

async function getMessages() {
const result = await systemList<Message[]>({ type: 0 }); // T 被指定为 Message[]
console.log(result); // 这里 result 的类型是 Message[]
}

在这个例子中:

  • T 被指定为 Message[],即返回的数据应该是一个 Message 类型的数组。
  • systemList<Message[]> 返回的结果会是一个 Promise<Message[]>,这意味着 systemList 将返回一个包含 Message 类型数组的异步响应。

总结:

  • systemList<T> 通过泛型 T 使得你能够灵活地指定返回数据的类型。
  • Promise<T> 表示 systemList 函数返回的是一个 Promise,它最终会解析为类型为 T 的数据。
  • T 是占位符,代表你调用 systemList 时传入的类型,它决定了返回数据的结构。

下一个小节会展开进行详细补充..

// 在 stores 模块中
// /src/stores/message.ts

import { defineStore } from 'pinia'
import { systemList } from '@/api/message'

interface messageList {
title
create_time
content
id
}

export const messageStore = defineStore({
id: 'message',
state: () => {
return {
// 使用类型断言进行数据初始化
systemMessageList: [] as messageList[],
systemNewMessage: [] as messageList[]
}
},
actions: {
async getSystemMessageList() {
const res = await systemList<messageList[]>({
type: 0,
is_informtype: 2
})
if (res) {
this.systemMessageList = res.data
if (this.systemMessageList[0]) this.systemNewMessage = [this.systemMessageList[0]]
}
}
}
})

三种常见的类型声明方式

点击查看详情

在不同场景下,选择适合的方式非常重要。以下是三种常见的方式,以及它们的优缺点和适用场景:


1. 显式声明类型

systemMessageList: [] as { title: string; create_time: string; content: string; id: number }[],

优点:

  • 清晰直观:类型信息一目了然。
  • 独立性强:无需依赖其他函数或工具类型,直接定义。

缺点:

  • 重复定义:如果接口结构发生变化,需要在多个地方手动同步更新。
  • 繁琐:对于复杂的数据结构,显式声明会变得冗长。

适用场景:

  • 类型简单,接口结构不容易变动。
  • 需要快速确认类型定义且与外部逻辑无关的场景。

2. 通过接口/类型定义引用

interface MessageList {
title: string;
create_time: string;
content: string;
id: number;
}

systemMessageList: [] as MessageList[],

优点:

  • 中央化管理:类型定义可以复用,减少重复劳动。
  • 可读性高:通过语义化的接口名称,提升代码可读性。

缺点:

  • 需要提前声明类型,可能额外引入复杂性。
  • 如果类型与接口定义脱钩,可能需要手动更新。

适用场景:

  • 类型需要在多个地方复用时。
  • 项目中存在清晰的类型管理系统(如一个专门的 types.ts 文件)。

这里详细说明

// 目标: 明确接口的返回数据的属性和类型。
interface ChatMessageListResponse {
data: Array<{
id: string;
message: string;
sender: string;
timestamp: string;
}>;
success: boolean;
}

export function chatMessageList<T = ChatMessageListResponse>(): Promise<T> {
return request({
url: '/it_chat/my/message/list',
method: 'get'
});
}

T = ChatMessageListResponse 的含义

在 TypeScript 中,T = ChatMessageListResponse 为泛型 T 提供了一个默认值。如果在调用 chatMessageList 函数时,未显式指定类型,T 将自动被推断为 ChatMessageListResponse

Promise

Promise<T> 是一个内置泛型类型,用于描述 异步操作的返回结果类型。具体来说,它表示的是一个 Promise 对象,该对象最终会以 resolve 的形式返回一个类型为 T 的值。例如:

async function getData(): Promise<string> {
return "Hello, world!";
}

const result = await getData(); // result 推断为 string 类型

// T 是一个占位符类型,允许在调用函数时动态地指定返回值的类型。

3. 使用动态类型推断

systemMessageList: [] as Awaited<ReturnType<typeof systemList>>['data'],

优点:

  • 自动同步:类型动态依赖于函数的返回值,接口变化时无需手动更新。
  • 灵活性强:适用于复杂的场景或第三方接口返回的数据类型。

缺点:

  • 可读性较低:对初学者或不熟悉 TypeScript 工具类型的开发者可能不直观。
  • 依赖链较深:如果 systemList 定义不明确或返回类型未定义,可能导致类型推断失败。

适用场景:

  • 类型需要依赖外部函数或接口定义,可能会发生变动。
  • 项目较大且维护团队熟悉 TypeScript 高级语法。

最佳实践建议

综合考虑,方式 2(通过接口/类型定义引用) 通常是最佳实践,原因如下:

  1. 清晰可读: 开发者可以一眼看到接口的结构,而不需要跳转到其他文件。
  2. 复用性高: 如果其他地方也需要使用该类型,可以直接复用。
  3. 灵活性适中: 中央化管理类型定义,便于维护和修改。

但如果你的项目有如下特点,方式 3(动态类型推断) 是更好的选择:

  • 快速迭代:接口变动频繁,动态推断可以减少重复工作。
  • 外部接口较复杂:推断机制可以简化复杂的类型管理。

推荐代码

// 定义接口
interface MessageList {
title: string;
create_time: string;
content: string;
id: number;
}

// 使用接口定义类型
systemMessageList: [] as MessageList[],

这样既能保证代码清晰,又具有一定的扩展性,是大多数场景下的最佳实践!

一种较为轻便的、仅针对小型项目的泛型声明方法

点击查看详情

双招项目

/src/api/contract

import request from '../utils/request'
// 合约列表接口

interface contractResponse {
records
msg
}

export function contractList(data): Promise<contractResponse> {
return request({
url: '/task/myContractAllList',
method: 'get',
params: data
})
}

interface contractDetailResponse {
records
}

// 合约详情接口
export function contractDetail(data): Promise<contractDetailResponse> {
return request({
url: '/task/contractAllList',
method: 'get',
params: data
})
}

// 合约签约/拒绝接口
export function contractOpreation<T>(data): Promise<T> {
return request({
url: '/contract/isContract',
method: 'put',
data
})
}

如上,合约签约/拒绝接口,请求函数的返回值类型是一个泛型 Promise<T>,没有显式指定具体的类型 T

当调用时,可以通过显式泛型参数指定返回数据的类型。例如:

 const putContractOpreation = async type => {
const res = await contractOpreation<{ meg }>({
is_contract_type: type,
contract_id: contractId
})
console.log(res.meg, '这里是确认签约的返回数据')

showToast(res.meg)
}

再如:/src/api/message.ts

import request from '@/utils/request'

// 消息列表接口
export function systemList<T>(data): Promise<{ data: T }> {
return request({
url: '/user/inform/list',
method: 'get',
params: data
})
}

// 消息详情
export function systemDetails<T>(data): Promise<T> {
return request({
url: '/user/inform/lookover',
method: 'get',
params: data
})
}

// 对话列表接口
export function chatMessageList<T>(): Promise<T> {
return request({
url: '/it_chat/my/message/list',
method: 'get'
})
}

以上请求函数的返回值类型是一个泛型 Promise<T>,没有显式指定具体的类型 T,然后接下来在函数的 调用位置,进行显示声明:

/src/stores/message.ts

import { defineStore } from 'pinia'
import { systemList, chatMessageList } from '@/api/message'

interface messageList {
title
create_time
content
id
specific_time
}

export const messageStore = defineStore({
id: 'message',
state: () => {
return {
systemMessageList: [] as messageList[], // 使用类型断言初始化
systemNewMessage: [] as messageList[],
messageList: []
}
},
actions: {
async getSystemMessageList() {
const res = await systemList<messageList[]>({ // 就在这里,进行了显示类型声明
type: 0,
is_informtype: 2
})
if (res) {
this.systemMessageList = res.data
if (this.systemMessageList[0]) this.systemNewMessage = [this.systemMessageList[0]]
}
},
async getChatMessageList() {
const res = await chatMessageList<{ data }>() // 这里也算是显示声明了数据结构
//(尽管这里只声明了结构,缺少数据类型声明)
if (res) {
this.messageList = res.data
}
}
}
})

轮循方式实现对话消息实时展示

点击查看详情

1. 创建定时器

使用 setInterval 来定时请求后端接口,获取新的对话消息。通常,轮询时间间隔设置为几秒钟(例如:每 3 秒请求一次)。

/src/views/message/MessageTalk/[things_id]/[receive_id].vue

// 首先,在 state 中定义一个 定时器id
const state = reactive({
list: [] as chatMessageContentResponseItem[],
loading: false,
value: '',
taskName: '',
// 确保定时器就仅此一个
creacteSetInterval: null as ReturnType<typeof setInterval> | null
})

2. 定义接口请求方法

创建一个请求方法,用于向后端获取最新的聊天消息。

const getChatMessageContent = async () => {
state.loading = true

const res = await chatMessageContent<chatMessageContentResponse>({
receive_id: receiveId,
things_id: taskId,
things_type: 0
})

if (res.data) {
state.list = res.data
state.taskName = (res.data[0] && res.data[0].taskName) || '任务'
} else {
showToast(res.msg)
}
state.loading = false
}
getChatMessageContent()

3. 更新界面

首先,当然是创建一个定时器

const stopSetInterval = () => {
// 有就清除
if (state.creacteSetInterval) {
clearInterval(state.creacteSetInterval)
state.creacteSetInterval = null
}
}

const createInterval = () => {
// 有就清除
stopSetInterval()
state.creacteSetInterval = setInterval(() => {
getChatMessageContent()
}, 5000)
}
createInterval() // 停止轮循

4. 停止轮询

如果用户离开聊天界面或其他需要停止轮询的场景,可以通过 clearInterval 来清除定时器,停止继续请求数据。

clearInterval(interval);  // 停止轮循

5. 考虑优化

  • 停止轮询:在用户离开页面、切换频道或进入非活跃状态时,可以停止轮询,以减少不必要的请求。
  • 请求优化:可以在请求时增加分页或根据时间戳获取新消息,避免每次请求获取所有消息。
  • 长轮询:如果每次轮询的数据量较大,可以采用长轮询(Long Polling),即当没有新数据时,服务器会保持请求连接,直到有新数据时再返回响应。

轮询步骤概览:

  1. 启动定时器:设置一个 setInterval 定时器,每隔一段时间请求接口。
  2. 请求消息:调用 API 获取聊天消息。
  3. 更新页面:将新获取的消息显示到页面上。
  4. 清除定时器:在适当的时候清除定时器,如页面离开时。

优点与缺点:

  • 优点
    • 实现简单,易于上手。
    • 对于实时性要求不高的场景,可以较为平滑地展示数据更新。
  • 缺点
    • 消耗服务器和客户端资源,频繁的请求会导致性能问题。
    • 不如 WebSocket 等实时技术那么高效。

如果对实时性要求较高,建议使用 WebSocket 或长轮询技术来代替轮询。

二次总结 provide / inject 依赖注入

点击查看详情

父组件提供需要注入的方法,然后使用 provide 传递

// src/message/MessageTalk
const wordsChange = (value: string) => {
state.value = value
// state.worksVisible = false
// 这里先注释,后面使用其他方法关闭快捷短语展示框
}

provide('popup', {
wordsChange
})

子组件接受,接受起来还是有点麻烦,请看

// 首先要声明要接受的函数的类型
interface PopupContext {
wordsChange: (value: string) => void
}

const { wordsChange } = inject<PopupContext>('popup')! // 非空断言

// 这样,就可以子组件内部调用啦!

在 Vue 中,provideinject 是基于依赖注入的机制。provide 用来在父组件中提供数据或方法,而 inject 用来在子组件中获取这些数据或方法。尽管父组件通过 provide 提供了数据或方法,并且在子组件中可以通过 inject 获取,但 Vue 并没有自动推断出类型的继承关系,因此需要显式地在子组件中声明类型。

为什么不能直接继承类型?

  1. 类型推导的局限性:Vue 的 provideinject 本身并没有像其他框架那样提供类型的自动传递机制。虽然在 JavaScript 中,父子组件的依赖关系通过 provideinject 实现,但这并不意味着 TypeScript 能够自动推断并传递类型。因为 inject 返回的是 unknown 类型,TypeScript 无法直接推断出父组件提供的内容的类型。

  2. provide 与 inject 的类型声明不具备继承性:在 TypeScript 中,provideinject 的实现机制并没有直接关联父子组件的类型。在父组件通过 provide 提供数据或方法时,并没有为这些提供的值赋予 TypeScript 类型上下文,子组件需要显式地声明类型才能获取类型推导。

    const value = inject<PopupContext>('popup');

    即使父组件已经定义了 PopupContext 类型,inject 不会自动继承父组件的类型。这是因为 Vue 设计上的一个特性:inject 必须显式地提供类型信息来让 TypeScript 确保安全的类型推导。

解决方案

为了避免重复声明类型,你可以显式地为 inject 提供类型,这样可以确保 inject 获取到正确的类型。这也是 TypeScript 提供的机制,它要求在进行依赖注入时明确类型,确保类型安全。

代码示例

1. 父组件通过 provide 提供类型

父组件通过 provide 提供 wordsChange 函数,并且使用共享的类型:

// types.ts
export interface PopupContext {
wordsChange: (value: string) => void;
}

// 父组件
import { PopupContext } from './types';

const wordsChange: PopupContext['wordsChange'] = (value: string) => {
state.value = value;
state.worksVisible = false;
};

provide('popup', { wordsChange });

2. 子组件通过 inject 获取并使用类型

子组件通过 inject 获取 wordsChange 函数,并显式声明类型:

// 子组件
import { inject } from 'vue';
import { PopupContext } from './types';

export default {
setup() {
// 显式声明类型
const { wordsChange } = inject<PopupContext>('popup')!; // 使用非空断言

// 使用 wordsChange
const handleChange = (value: string) => {
wordsChange(value);
};

return {
handleChange
};
}
};

总结

  • 类型推导的局限性:Vue 的 provideinject 没有内置的类型继承机制,inject 返回的是 unknown 类型,因此无法自动推导父组件提供的数据类型。
  • 显式声明类型:为了确保类型安全,子组件需要显式地声明 inject 的类型,特别是当 provide 提供的是复杂的数据或方法时。
  • 避免重复声明:可以通过创建共享类型文件,避免在父子组件中重复声明相同的类型,从而提高代码的可维护性。

在目标区域以外点击所触发的效果设计与实现

点击查看详情

ref 用来创建一个响应式的引用,可以绑定到 DOM 元素或组件实例。

const inputArea = ref<HTMLElement | null>(null)

在标签结构中,为父盒子添加属性:ref=”inputArea”

<div ref="inputArea" :class="['talk-bottom', { 'talk-visible': state.worksVisible }]">
<div class="talk-input">
<span @click="worksClick">常用语</span>
<input v-model="state.value" type="text" />
<!-- 可以用 i 标签选择器找到它 -->
<van-icon name="smile-o" @click="emojiClick" />
<span>发送</span>
</div>
<TalkWords v-show="state.worksVisible" ref="inputArea"></TalkWords>
</div>

src/message/MessageTalk

const inputArea = ref<HTMLElement | null>(null)

const handleClickOutside = (event: MouseEvent) => {
console.log('事件目标:', event.target)
console.log('inputArea的类型:', inputArea.value?.constructor.name) // 确认 inputArea 的类型
if (inputArea.value && inputArea.value instanceof HTMLElement) {
const isOutside = !inputArea.value.contains(event.target as Node)
console.log('是否在 inputArea 外部:', isOutside)
if (isOutside) {
console.log('点击了以外的区域')
state.worksVisible = false
}
}
}

onMounted(() => {
// 重点认识 nextTick(),见下方
nextTick(() => {
document.addEventListener('click', handleClickOutside)
})
})

onBeforeUnmount(() => {
stopSetInterval()
document.removeEventListener('click', handleClickOutside)
})

代码解析

1. onMounted

  • 触发时机onMounted 是 Vue 的生命周期钩子,在组件被挂载到 DOM 上后触发。
  • 作用:用来执行需要依赖 DOM 的操作,例如事件监听、初始化动画、或操作原生 DOM 元素。

2. nextTick

  • 定义nextTick 是 Vue 提供的一个异步方法,用于在 DOM 更新完成并渲染到视图后,执行回调函数。
  • 必要性:
    • Vue 的 DOM 更新是异步的。当组件数据发生变化时,Vue 会在下一个事件循环中批量更新 DOM。
    • 如果你在 DOM 更新前执行操作,可能会遇到 DOM 尚未更新的问题。
  • 在此代码中的作用:
    • 确保 document.addEventListener('click', handleClickOutside) 在组件挂载完成且 DOM 渲染后执行。
    • 虽然 onMounted 保证了组件已经挂载到 DOM 上,但某些情况下(如递归组件或复杂渲染逻辑),需要等待 DOM 完全渲染完成,才可以安全地操作。

3. document.addEventListener(‘click’, handleClickOutside)

  • 作用:为整个文档添加一个全局的点击事件监听器。
  • 逻辑:
    • 在任意位置点击时,触发 handleClickOutside 方法。
    • 这个方法通常用于检测用户是否点击了组件外部区域,比如关闭弹窗、隐藏下拉菜单等。

纯 css 方法,为盒子高度变化添加过渡效果

为父盒子的高度变化添加了过渡效果

点击查看详情

为父盒子添加动态类

<!-- 为目标盒子添加动态类 -->
<div ref="inputArea" :class="['talk-bottom', { 'talk-visible': state.worksVisible }]">
<div class="talk-input">
<span @click="worksClick">常用语</span>
<input v-model="state.value" type="text" />
<!-- 可以用 i 标签选择器找到它 -->
<van-icon name="smile-o" @click="emojiClick" />
<span>发送</span>
</div>
<!-- v-if="state.worksVisible" 最初的state.worksVisible,用于控制 -->
<!-- 该弹组件的显示隐藏 -->
<!-- 添加过渡效果后:为父组件设置了最大高度和初始高度 -->
<!-- 不再使用 v-if 控制该组件的显示隐藏 -->
<!-- 即,TalkWords 组件进入聊天界面后就被加载 -->
<!-- 只不过高度被限制了 -->
<!-- 由于组件内部的逻辑设计:仅在组件被加载时调用 快捷短语 api 请求函数 -->
<!-- 因此在短语列表的更新上,后续开发要特别留意 -->
<!-- 因为此时,点击常用语按钮,已不再触发常用语列表更新 -->
<!-- 实测到这里,这个v-show完全没有比要加,加了反而控制台提示警告 -->
<!-- 实测到这里,v-if 或 v-show 还是要加,用于快捷短语和标签组件的互斥 -->
<!-- 通过将 TalkWords 中的标签全部归集到一个标准 html 根标签中,例如 div 标签中,来消除有关警告 -->

<TalkWords v-show="state.worksVisible"></TalkWords>
</div>

<style lang="scss" scoped>
// 在原有样式基础上
.talk-bottom {
position: fixed;
bottom: 0;
left: 0;
width: 100vw;
background: #ffffff;
text-align: center;
z-index: 1;
border-top: 1px solid #eeeeee;
}

// 增加如下样式
.talk-bottom {
overflow: hidden; /* 隐藏内容超出部分 */
max-height: 2.6rem; /* 初始的最大高度(盒子本身的高度) */
transition: max-height 0.3s ease; /* 高度变化的平滑过渡 */
}

.talk-bottom.talk-visible {
max-height: 350px; /* 当显示时,设置较大的最大高度 */
}
</style>

ps:这种方法,导致了出现未知原因的组件内 showToast 提示框被其他样式覆盖,奋斗了三个多小时也未找到解决办法,暂时放弃 showToast,使用其他方法,showDialog

其实,反过来也不难理解,toast 本身就是渐变效果,根父组件添加的渐变效果难免冲突!

pps:最便捷、直接、高效的方法,其实就是看文档,不然也不会直到 20024/12/3号深夜才从文档得知,函调方式按需引入的提示框方法,都是通过 show~ 的方式来调用!!

引入

通过以下方式来全局注册组件,更多注册方式请参考组件注册

import { createApp } from 'vue';
import { Toast } from 'vant';

const app = createApp();
app.use(Toast);

函数调用

为了便于使用 Toast,Vant 提供了一系列辅助函数,通过辅助函数可以快速唤起全局的 Toast 组件。

比如使用 showToast 函数,调用后会直接在页面中渲染对应的轻提示。

import { showToast } from 'vant';

showToast('提示内容');

找到原因啦!!

请往下看..

vant4 页面使用Popup,导致Toast样式变成白底

点击查看详情

详见:周凯

  • vant4 的 Popup组件使用,会导致Toast组件样式发生变化,背景色变成白底

    原因是:单独使用 Toast 组件,Toast 样式顶掉了Popup样式;如果使用了Popup组件,就会重新多添加一次Popup样式,就导致后添加的Popup样式顶掉了Toast样式,就造成样式冲突,Toast就变成白底了

  • 解决方法:

    • 在 基础css文件中,或者最外层css文件,强制 !important 样式为默认样式

      /* vant4的 Toast 和 Popup 样式冲突,会导致 Toast 变成白底 */
      .van-popup.van-toast{
      background: var(--van-toast-background) !important;
      box-sizing: content-box !important;
      /* 下面3条css,影响不大 */
      transition: all var(--van-duration-fast) !important;
      width: var(--van-toast-default-width) !important;
      max-width: var(--van-toast-max-width) !important;
      }
      .van-popup.van-toast .van-toast__icon{
      font-size: var(--van-toast-icon-size) !important;
      }

底部快捷短语组件显示/隐藏撑大父盒子使其高度渐变过渡的终极解决方案

点击查看详情

首先为两个盒子增加过渡效果

.talk-page {
width: 100%;
background: #f3f3f3;
// 需要减去顶部 bar 的高度和ui稿件中底部组件的高度
height: calc(100vh - 40px - 2.6rem);
overflow: auto;
transition: height 200ms ease;
}

.talk-bottom {
overflow: hidden; /* 隐藏内容超出部分 */
max-height: 2.6rem; /* 初始的最大高度(较小的高度) */
transition: max-height 0.3s ease; /* 高度变化的平滑过渡 */
}

然后,通过在dom加载结束后,根据子组件显示所需要的高度,直接设置 max-height 的值的方式,来实现任意高度变化时的过渡效果,例如:

原始写法:

const worksClick = () => {
console.log('点击了常用语')
// 确定此时 dom 已加载
if (inputArea.value && messageList.value) {
// 添加空值检查,确保 inputArea.value 不为 null
if (state.worksVisible) {
// 如果已经展开,设置高度收起并延迟关闭
inputArea.value.style.maxHeight = '2.6rem'
messageList.value.style.height = 'calc(100vh - 40px - 2.6rem)'
setTimeout(() => {
state.worksVisible = !state.worksVisible
console.log('延迟关闭常用语面板')
}, 300) // 延迟 300 毫秒
} else {
// 如果未展开,立即打开并设置高度
state.worksVisible = !state.worksVisible
inputArea.value.style.maxHeight = '290px'
messageList.value.style.height = 'calc(100vh - 320px)'
state.emojiVisible = false // 确保 emoji 面板关闭
scrollToBottom(150)
}
} else {
console.warn('inputArea.value is null')
}
}

const emojiClick = () => {
console.log('emojiClick')
// 这里增加一个判断,判断快捷短语弹框是否开启
if (state.worksVisible) {
if (inputArea.value && messageList.value) {
// 使 300px - 210px 这一段高度变化附带过渡效果
inputArea.value.style.maxHeight = '210px'
messageList.value.style.height = 'calc(100vh - 200px - 2.6rem)'
setTimeout(() => {
state.worksVisible = !state.worksVisible
console.log('延迟关闭常用语面板')
}, 200) // 延迟 300 毫秒
state.emojiVisible = !state.emojiVisible
}
} else {
if (inputArea.value && messageList.value) {
if (state.emojiVisible) {
// 如果已经展开,设置高度收起并延迟关闭
inputArea.value.style.maxHeight = '2.6rem'
messageList.value.style.height = 'calc(100vh - 46px - 2.6rem)'
setTimeout(() => {
state.emojiVisible = !state.emojiVisible
console.log('延迟关闭常用语面板')
}, 300) // 延迟 300 毫秒
} else {
inputArea.value.style.maxHeight = '210px'
messageList.value.style.height = 'calc(100vh - 200px - 2.6rem)'
scrollToBottom(200)
state.emojiVisible = !state.emojiVisible
console.log('触发了吗?')
}
}
}
}

以下是精简写法:

const worksClick = () => {
console.log('点击了常用语');

// 确保 DOM 元素已加载
if (!inputArea.value || !messageList.value) {
console.warn('inputArea.value 或 messageList.value 为 null');
return;
}

// 更新状态和样式
const isVisible = state.worksVisible;
inputArea.value.style.maxHeight = isVisible ? '2.6rem' : '290px';
messageList.value.style.height = isVisible
? 'calc(100vh - 40px - 2.6rem)'
: 'calc(100vh - 320px)';

if (!isVisible) {
state.emojiVisible = false; // 确保 emoji 面板关闭
scrollToBottom(150);
}

// 延迟切换状态
setTimeout(() => {
state.worksVisible = !isVisible;
console.log(isVisible ? '延迟关闭常用语面板' : '打开常用语面板');
}, isVisible ? 300 : 0);
};

const emojiClick = () => {
console.log('emojiClick');

// 确保 DOM 元素已加载
if (!inputArea.value || !messageList.value) {
console.warn('inputArea.value 或 messageList.value 为 null');
return;
}

const isEmojiVisible = state.emojiVisible;
const isWorksVisible = state.worksVisible;

if (isWorksVisible) {
// 如果常用语面板开启,调整为 emoji 面板高度
inputArea.value.style.maxHeight = '210px';
messageList.value.style.height = 'calc(100vh - 200px - 2.6rem)';

setTimeout(() => {
state.worksVisible = false; // 关闭常用语面板
state.emojiVisible = !isEmojiVisible; // 切换 emoji 面板状态
console.log('延迟关闭常用语面板并切换 emoji 状态');
}, 200);
} else {
if (isEmojiVisible) {
// 如果 emoji 面板已开启,收起面板
inputArea.value.style.maxHeight = '2.6rem';
messageList.value.style.height = 'calc(100vh - 46px - 2.6rem)';

setTimeout(() => {
state.emojiVisible = false;
console.log('延迟关闭 emoji 面板');
}, 300);
} else {
// 如果 emoji 面板未开启,展开面板
inputArea.value.style.maxHeight = '210px';
messageList.value.style.height = 'calc(100vh - 200px - 2.6rem)';
scrollToBottom(200);

state.emojiVisible = true;
console.log('打开 emoji 面板');
}
}
};

声明一个 adjustHeights 函数,作为通用工具函数,用于 emojiClickworksClick 中动态调整高度,能够进一步优化代码结构并减少重复逻辑。

以下是优化后的版本:

抽离 adjustHeights

const adjustHeights = (inputHeight: string) => {
if (inputArea.value && messageList.value) {
inputArea.value.style.maxHeight = inputHeight;
messageList.value.style.height = `calc(100vh - 40px - ${inputHeight})`;
} else {
console.warn('inputArea.value 或 messageList.value 为 null');
}
};

优化后的 emojiClick

const emojiClick = () => {
console.log('emojiClick');

if (!inputArea.value || !messageList.value) {
console.warn('inputArea.value 或 messageList.value 为 null');
return;
}

const isEmojiVisible = state.emojiVisible;
const isWorksVisible = state.worksVisible;

if (isWorksVisible) {
// 如果常用语面板开启,调整为 emoji 面板高度
adjustHeights('210px');

setTimeout(() => {
state.worksVisible = false; // 关闭常用语面板
state.emojiVisible = !isEmojiVisible; // 切换 emoji 面板状态
console.log('延迟关闭常用语面板并切换 emoji 状态');
}, 200);
} else {
if (isEmojiVisible) {
// 如果 emoji 面板已开启,收起面板
adjustHeights('2.6rem');

setTimeout(() => {
state.emojiVisible = false;
console.log('延迟关闭 emoji 面板');
}, 300);
} else {
// 如果 emoji 面板未开启,展开面板
adjustHeights('210px');
scrollToBottom(200);

state.emojiVisible = true;
console.log('打开 emoji 面板');
}
}
};

优化后的 worksClick

const worksClick = () => {
console.log('点击了常用语');

if (!inputArea.value || !messageList.value) {
console.warn('inputArea.value 或 messageList.value 为 null');
return;
}

const isWorksVisible = state.worksVisible;

if (isWorksVisible) {
// 如果常用语面板已开启,收起面板
adjustHeights('2.6rem');

setTimeout(() => {
state.worksVisible = false;
console.log('延迟关闭常用语面板');
}, 300);
} else {
// 如果常用语面板未开启,展开面板
adjustHeights('290px');
scrollToBottom(150);

state.worksVisible = true;
state.emojiVisible = false; // 确保 emoji 面板关闭
console.log('打开常用语面板');
}
};

优化的核心点

  1. 复用 adjustHeights 函数
    • 高度调整逻辑完全抽离,通过传递不同的高度参数实现动态变化。
  2. 减少重复代码
    • emojiClickworksClick 都调用 adjustHeights,避免重复设置 inputAreamessageList 的样式。
  3. 保持功能独立性
    • 状态切换逻辑保留在各自的函数中,而高度调整作为通用逻辑单独处理。

使用效果

  • 高度调整逻辑简单统一,便于维护。
  • 每个函数的主要逻辑清晰,专注于状态切换。
  • 如果未来需要修改高度规则或增加日志信息,只需修改 adjustHeights 函数即可。

这个版本不仅简洁高效,还增强了可读性和扩展性!

通过 defineProps() 接收父组件属性的响应式问题

点击查看详情

公共组件通过 defineProps<fatherProps>() 接收父组件的 :text 属性,并将其初始化为 state.value。但是当 :text 属性发生变化时,state.value 并不会自动更新,因为 state.value 是在组件初始化时通过 text 赋值的,而后续的 props 变化不会触发对 state.value 的更新。

这是因为 Vue 的 props 是响应式的,而直接赋值给 state.value 则打断了这种响应式连接。

问题的根源在于 TalkWordsAdd 组件的 state.value 初始化逻辑。组件的 text 值通过 defineProps 传入,但 state.value 仅在组件首次加载时使用 text 初始化。后续父组件传递新的 text 时,state.value 不会更新。

解决方案

为了确保输入框中的值能够正确地随着 :text 的更新而变化,可以使用一个 watch 监听 props.text 的变化,并同步更新 state.value

修改代码如下:

import { inject, reactive, watch } from 'vue'

// 省略前面的代码

const state = reactive({
value: text || '' // 初始化
})

// 监听 props 的 text 变化
watch(() => text, (newText) => {
state.value = newText || '' // 同步更新 state.value
})

通过 watch,每当父组件传递的 text 属性发生变化时,state.value 都会更新,从而确保输入框内的内容正确回显。

对话消息组件-对话框消息渲染后,立即触发滚动至最底部

点击查看详情

首先,为父盒子添加ref=”message”

<template>
<van-nav-bar :title="state.task_name" left-arrow @click-left="leftBack" />
<div ref="messageList" class="talk-page">
<dl>
<dt
v-for="(item, index) in state.list"
:key="index"
:class="item.receive_id == receiveId ? 'active' : ''"
>
<h5>{{ item.create_time }}</h5>
<div>
<img v-if="item.receive_id == receiveId" :src="item.senderPhoto" />
<img v-else :src="item.receivePhoto" />
<p>{{ item.text }}</p>
</div>
</dt>
</dl>
</div>
<div ref="inputArea" :class="['talk-bottom']">
<div class="talk-input">
<span @click="worksClick">常用语</span>
<input v-model="state.value" type="text" />
<!-- 可以用 i 标签选择器找到它 -->
<van-icon name="smile-o" @click="emojiClick" />
<span @click="sentMessage">发送</span>
</div>
<TalkWords v-show="state.worksVisible"></TalkWords>
<TalkEmoji v-show="state.emojiVisible"></TalkEmoji>
</div>
</template>

通过 ref 获取 DOM 元素。

然后,定义一个滚动函数

const scrollToBottom = (delay: number = 0) => {
if (messageList.value) {
// 确保 messageList.value 不为 null 或 undefined
if (delay > 0) {
setTimeout(() => {
if (messageList.value) {
// 再次确认 messageList.value 存在
messageList.value.scrollTop = messageList.value.scrollHeight
console.log('延迟执行滚动')
}
}, delay)
} else {
messageList.value.scrollTop = messageList.value.scrollHeight
console.log('确保执行了滚动')
}
}
}

在需要的位置调用滚动函数

watch(
() => state.list,
() => {
scrollToBottom()
}
)

const getChatMessageContent = async () => {
state.loading = true
// 在这里传入对泛型进行替换
const res = await chatMessageContent<chatMessageContentResponse>({
receive_id: receiveId,
things_id: taskId,
things_type: 0
})
if (res.data) {
state.list = res.data
state.task_name = (res.data[0] && res.data[0].task_name) || '任务'
// 等待 dom 加载
await nextTick()
scrollToBottom()
} else {
showToast(res.msg)
}
state.loading = false
}

解决一个类型声明问题

原始代码:

async getCityList() {
const res = await cityList<City[]>()
if (res) {
this.cityList = res
const city: City[] = [] // 明确指定 city 的类型
for (let i = 0; i < res.length; i++) {
city.push({
name: res[i].name,
children: res[i].children || [] // 如果 children 为 undefined,则使用空数组
})
if (res[i].children && res[i].children?.length) {
for (let j = 0; j < res[i].children.length; j++) {
city[i].children.push({
name: res[i].children[j].name || ''
})
}
}
}
this.areaList = city
}
}

改进后的代码:

interface City {
name: string
children
key
}

interface Area {
text: string
children
value
}

async getCityList() {
const res: City[] = await cityList()
if (res) {
this.cityList = res
const city: Area[] = []
for (let i = 0; i < res.length; i++) {
city.push({
text: res[i].name,
value: res[i].key,
children: []
})
if (res[i].children && res[i].children?.length) {
for (let j = 0; j < res[i].children.length; j++) {
city[i].children.push({
text: res[i].children[j].name,
value: res[i].children[j].key
})
}
}
}
this.areaList = city
console.log(city, 'city')
}
},