Vue3.2+Vite+Typescript 开发双端招聘全过程系列导航
- 基础环境配置(一)
- 可复用的页面交互逻辑(二) ⇦当前位置🪂
- 个人中心模块(三)
- 即将闪亮登场..
基于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, list: [], loading: false, activeIndex: 0 })
const setTabList = type => { if (type === state.type) return state.type = type store.setType(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>
|
按键防抖
点击查看详情
export const directives = (app:any) => { app.directive('debounce', { mounted(el:any,binding:any) { if(typeof binding.value !=='function') return el.timer = null el.handler = function () { if (el.timer) { clearTimeout(el.timer) } el.timer = setTimeout(() => { binding.value.apply(this, arguments) },600) } el.addEventListener('click',el.handler) }, beforeUnmount(el:any, binding:any) { if(el.timer){ el.timer = null clearTimeout(el.timer) } 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
:绑定事件的实际执行函数。
防抖逻辑:
- 如果
timer
不为空,清除之前的定时器。 - 使用
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 移除时:
- 清除定时器,避免内存泄漏。
- 移除绑定的
click
事件监听器。
核心逻辑
- 防抖机制:
- 用户频繁触发事件时,函数不会立即执行,而是等用户停止操作一段时间后才执行最后一次触发的回调函数。
- 防止短时间内多次调用带来的性能问题或逻辑错误。
- 生命周期管理:
- 使用 Vue 指令的生命周期(
mounted
和 beforeUnmount
)确保指令的逻辑正确注册和清理,避免内存泄漏或事件重复绑定的问题。
import type { DirectiveBinding, ObjectDirective } from 'vue'
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' import './assets/css/style.css' import { createPinia } from 'pinia' import App from './App.vue' import router from './router'
import '@/utils/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'
const showFooter = ref(true) let lastScrollTop = 0
let scrollTimeout: NodeJS.Timeout | null = null
function handleScroll() { const scrollTop = document.documentElement.scrollTop
if (scrollTop > lastScrollTop) { showFooter.value = false } else if (scrollTop < lastScrollTop) { 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 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标签写好一个小红点
<span v-if="item.is_show"></span>
span { float: right; color: #999999; font-size: 0.7rem; font-weight: 100; }
|
泛型声明&类型断言
点击查看详情
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
调用中获得的响应数据的类型。
解释过程
- 泛型传递:函数
systemList
定义了一个类型参数 T
,这意味着你在调用该函数时,可以指定 T
的实际类型。 - 请求返回类型:
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 }); console.log(result); }
|
在这个例子中:
T
被指定为 Message[]
,即返回的数据应该是一个 Message
类型的数组。systemList<Message[]>
返回的结果会是一个 Promise<Message[]>
,这意味着 systemList
将返回一个包含 Message
类型数组的异步响应。
总结:
systemList<T>
通过泛型 T
使得你能够灵活地指定返回数据的类型。Promise<T>
表示 systemList
函数返回的是一个 Promise
,它最终会解析为类型为 T
的数据。T
是占位符,代表你调用 systemList
时传入的类型,它决定了返回数据的结构。
下一个小节会展开进行详细补充..
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();
|
3. 使用动态类型推断
systemMessageList: [] as Awaited<ReturnType<typeof systemList>>['data'],
|
优点:
- 自动同步:类型动态依赖于函数的返回值,接口变化时无需手动更新。
- 灵活性强:适用于复杂的场景或第三方接口返回的数据类型。
缺点:
- 可读性较低:对初学者或不熟悉 TypeScript 工具类型的开发者可能不直观。
- 依赖链较深:如果
systemList
定义不明确或返回类型未定义,可能导致类型推断失败。
适用场景:
- 类型需要依赖外部函数或接口定义,可能会发生变动。
- 项目较大且维护团队熟悉 TypeScript 高级语法。
最佳实践建议
综合考虑,方式 2(通过接口/类型定义引用) 通常是最佳实践,原因如下:
- 清晰可读: 开发者可以一眼看到接口的结构,而不需要跳转到其他文件。
- 复用性高: 如果其他地方也需要使用该类型,可以直接复用。
- 灵活性适中: 中央化管理类型定义,便于维护和修改。
但如果你的项目有如下特点,方式 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
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),即当没有新数据时,服务器会保持请求连接,直到有新数据时再返回响应。
轮询步骤概览:
- 启动定时器:设置一个
setInterval
定时器,每隔一段时间请求接口。 - 请求消息:调用 API 获取聊天消息。
- 更新页面:将新获取的消息显示到页面上。
- 清除定时器:在适当的时候清除定时器,如页面离开时。
优点与缺点:
- 优点:
- 实现简单,易于上手。
- 对于实时性要求不高的场景,可以较为平滑地展示数据更新。
- 缺点:
- 消耗服务器和客户端资源,频繁的请求会导致性能问题。
- 不如 WebSocket 等实时技术那么高效。
如果对实时性要求较高,建议使用 WebSocket 或长轮询技术来代替轮询。
二次总结 provide / inject 依赖注入
点击查看详情
父组件提供需要注入的方法,然后使用 provide 传递
const wordsChange = (value: string) => { state.value = value }
provide('popup', { wordsChange })
|
子组件接受,接受起来还是有点麻烦,请看
interface PopupContext { wordsChange: (value: string) => void }
const { wordsChange } = inject<PopupContext>('popup')!
|
在 Vue 中,provide
和 inject
是基于依赖注入的机制。provide
用来在父组件中提供数据或方法,而 inject
用来在子组件中获取这些数据或方法。尽管父组件通过 provide
提供了数据或方法,并且在子组件中可以通过 inject
获取,但 Vue 并没有自动推断出类型的继承关系,因此需要显式地在子组件中声明类型。
为什么不能直接继承类型?
类型推导的局限性:Vue 的 provide
和 inject
本身并没有像其他框架那样提供类型的自动传递机制。虽然在 JavaScript 中,父子组件的依赖关系通过 provide
和 inject
实现,但这并不意味着 TypeScript 能够自动推断并传递类型。因为 inject
返回的是 unknown
类型,TypeScript 无法直接推断出父组件提供的内容的类型。
provide 与 inject 的类型声明不具备继承性:在 TypeScript 中,provide
和 inject
的实现机制并没有直接关联父子组件的类型。在父组件通过 provide
提供数据或方法时,并没有为这些提供的值赋予 TypeScript 类型上下文,子组件需要显式地声明类型才能获取类型推导。
const value = inject<PopupContext>('popup');
|
即使父组件已经定义了 PopupContext
类型,inject
不会自动继承父组件的类型。这是因为 Vue 设计上的一个特性:inject
必须显式地提供类型信息来让 TypeScript 确保安全的类型推导。
解决方案
为了避免重复声明类型,你可以显式地为 inject
提供类型,这样可以确保 inject
获取到正确的类型。这也是 TypeScript 提供的机制,它要求在进行依赖注入时明确类型,确保类型安全。
代码示例
1. 父组件通过 provide 提供类型
父组件通过 provide
提供 wordsChange
函数,并且使用共享的类型:
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')!;
const handleChange = (value: string) => { wordsChange(value); };
return { handleChange }; } };
|
总结
- 类型推导的局限性:Vue 的
provide
和 inject
没有内置的类型继承机制,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" /> <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) 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(() => { 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" /> <van-icon name="smile-o" @click="emojiClick" /> <span>发送</span> </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('提示内容');
|
找到原因啦!!
请往下看..
点击查看详情
底部快捷短语组件显示/隐藏撑大父盒子使其高度渐变过渡的终极解决方案
点击查看详情
首先为两个盒子增加过渡效果
.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('点击了常用语') if (inputArea.value && messageList.value) { 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) } else { state.worksVisible = !state.worksVisible inputArea.value.style.maxHeight = '290px' messageList.value.style.height = 'calc(100vh - 320px)' state.emojiVisible = false scrollToBottom(150) } } else { console.warn('inputArea.value is null') } }
const emojiClick = () => { console.log('emojiClick') if (state.worksVisible) { if (inputArea.value && messageList.value) { inputArea.value.style.maxHeight = '210px' messageList.value.style.height = 'calc(100vh - 200px - 2.6rem)' setTimeout(() => { state.worksVisible = !state.worksVisible console.log('延迟关闭常用语面板') }, 200) 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) } 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('点击了常用语'); 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; scrollToBottom(150); }
setTimeout(() => { state.worksVisible = !isVisible; console.log(isVisible ? '延迟关闭常用语面板' : '打开常用语面板'); }, isVisible ? 300 : 0); };
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) { inputArea.value.style.maxHeight = '210px'; messageList.value.style.height = 'calc(100vh - 200px - 2.6rem)';
setTimeout(() => { state.worksVisible = false; state.emojiVisible = !isEmojiVisible; console.log('延迟关闭常用语面板并切换 emoji 状态'); }, 200); } else { if (isEmojiVisible) { inputArea.value.style.maxHeight = '2.6rem'; messageList.value.style.height = 'calc(100vh - 46px - 2.6rem)';
setTimeout(() => { state.emojiVisible = false; console.log('延迟关闭 emoji 面板'); }, 300); } else { inputArea.value.style.maxHeight = '210px'; messageList.value.style.height = 'calc(100vh - 200px - 2.6rem)'; scrollToBottom(200);
state.emojiVisible = true; console.log('打开 emoji 面板'); } } };
|
声明一个 adjustHeights
函数,作为通用工具函数,用于 emojiClick
和 worksClick
中动态调整高度,能够进一步优化代码结构并减少重复逻辑。
以下是优化后的版本:
抽离 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) { adjustHeights('210px');
setTimeout(() => { state.worksVisible = false; state.emojiVisible = !isEmojiVisible; console.log('延迟关闭常用语面板并切换 emoji 状态'); }, 200); } else { if (isEmojiVisible) { adjustHeights('2.6rem');
setTimeout(() => { state.emojiVisible = false; console.log('延迟关闭 emoji 面板'); }, 300); } else { 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; console.log('打开常用语面板'); } };
|
优化的核心点
- 复用 adjustHeights 函数:
- 高度调整逻辑完全抽离,通过传递不同的高度参数实现动态变化。
- 减少重复代码:
emojiClick
和 worksClick
都调用 adjustHeights
,避免重复设置 inputArea
和 messageList
的样式。
- 保持功能独立性:
- 状态切换逻辑保留在各自的函数中,而高度调整作为通用逻辑单独处理。
使用效果
- 高度调整逻辑简单统一,便于维护。
- 每个函数的主要逻辑清晰,专注于状态切换。
- 如果未来需要修改高度规则或增加日志信息,只需修改
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 || '' })
watch(() => text, (newText) => { state.value = newText || '' })
|
通过 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" /> <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) { if (delay > 0) { setTimeout(() => { if (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) || '任务' 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[] = [] for (let i = 0; i < res.length; i++) { city.push({ name: res[i].name, children: res[i].children || [] }) 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') } },
|
Vue3.2+Vite+Typescript 开发(二)