Vue3——第 9 篇:组件通信全攻略(下)——Provide / Inject 与事件总线

本篇定位 :在掌握了 Props 和 Emit 的基础通信方式后,我们需要解决更复杂的场景------跨层级组件通信。本篇将深入讲解 provide 和 inject 的依赖注入模式,以及事件总线(mitt)的使用方法,帮助你应对各种复杂的组件间通信需求。读完本篇,你将能够灵活选择和组合不同的通信方式,构建高内聚、低耦合的组件架构。


一、为什么需要跨层级通信?

在 Vue 应用中,组件树往往是深层嵌套的:

复制代码
App.vue
└── Dashboard.vue
    └── UserPanel.vue
        └── UserProfile.vue
            └── Avatar.vue

如果 Avatar 组件需要访问 App 根组件中的用户数据,使用 Props 逐层传递会导致 Props 钻探(Prop Drilling):

vue 复制代码
<!-- App.vue -->
<template>
  <Dashboard :user="user" />
</template>

<!-- Dashboard.vue -->
<template>
  <UserPanel :user="user" />
</template>

<!-- UserPanel.vue -->
<template>
  <UserProfile :user="user" />
</template>

<!-- UserProfile.vue -->
<template>
  <Avatar :user="user" />
</template>

每一层都需要接收并传递 user prop,即使中间组件本身并不需要这个数据。这种模式的问题:

  • 代码冗余:每个中间组件都要定义 prop
  • 维护困难:当数据来源变化时,需要修改所有中间层
  • 耦合度高:中间组件被迫感知上层的数据结构

Provide / Inject 正是为了解决这个问题而生的。

二、Provide / Inject:跨层级依赖注入

2.1 基本概念

provide 和 inject 是一对用于跨层级传递数据的 API,它允许祖先组件向所有子孙组件注入数据,无论层级多深。

API 角色 说明
provide 提供者 在祖先组件中定义,提供数据
inject 消费者 在子孙组件中定义,接收数据
vue 复制代码
<!-- 祖先组件:提供数据 -->
<script setup>
import { provide, ref } from 'vue'

const user = ref({ name: '张三', age: 25 })
const theme = ref('dark')

// 提供数据
provide('user', user)
provide('theme', theme)
</script>

<!-- 任意子孙组件:注入数据 -->
<script setup>
import { inject } from 'vue'

// 注入数据,可以指定默认值
const user = inject('user')
const theme = inject('theme', 'light')  // 如果未找到,使用默认值
</script>

2.2 使用 Symbol 作为 Key

为了避免命名冲突,建议使用 Symbol 作为 provide 的 key:

typescript 复制代码
// keys.ts - 集中管理注入 key
export const USER_KEY = Symbol('user')
export const THEME_KEY = Symbol('theme')
export const TOGGLE_THEME_KEY = Symbol('toggleTheme')
vue 复制代码
<!-- 祖先组件 -->
<script setup>
import { provide, ref } from 'vue'
import { USER_KEY, THEME_KEY, TOGGLE_THEME_KEY } from './keys'

const user = ref({ name: '张三', age: 25 })
const theme = ref('dark')

function toggleTheme() {
  theme.value = theme.value === 'dark' ? 'light' : 'dark'
}

provide(USER_KEY, user)
provide(THEME_KEY, theme)
provide(TOGGLE_THEME_KEY, toggleTheme)
</script>
vue 复制代码
<!-- 子孙组件 -->
<script setup>
import { inject } from 'vue'
import { USER_KEY, THEME_KEY, TOGGLE_THEME_KEY } from './keys'

const user = inject(USER_KEY)
const theme = inject(THEME_KEY)
const toggleTheme = inject(TOGGLE_THEME_KEY)

// 使用注入的数据和方法
function updateUser() {
  if (user) {
    user.value.name = '李四'
  }
}
</script>

2.3 Provide / Inject 的响应式

Provide 的数据默认是响应式的 ,前提是提供的是响应式数据(ref 或 reactive)。

vue 复制代码
<!-- 祖先组件 -->
<script setup>
import { provide, ref, reactive } from 'vue'

// ✅ 提供 ref(响应式)
const count = ref(0)
provide('count', count)

// ✅ 提供 reactive(响应式)
const user = reactive({ name: '张三' })
provide('user', user)

// ❌ 提供普通值(非响应式)
const staticValue = 'hello'
provide('static', staticValue)  // 子组件无法响应式更新
</script>
vue 复制代码
<!-- 子孙组件 -->
<script setup>
import { inject } from 'vue'

const count = inject('count')
const user = inject('user')

// 修改注入的数据(会触发响应式更新)
function increment() {
  count.value++  // ✅ 祖先组件中的 count 也会更新
}

function updateUser() {
  user.name = '李四'  // ✅ 祖先组件中的 user 也会更新
}
</script>

2.4 使用 readonly 保护注入数据

为了防止子组件意外修改祖先提供的数据,可以使用 readonly 包裹:

vue 复制代码
<!-- 祖先组件 -->
<script setup>
import { provide, ref, readonly } from 'vue'

const count = ref(0)

// 提供只读版本
provide('count', readonly(count))

// 提供修改方法
provide('increment', () => {
  count.value++
})
</script>
vue 复制代码
<!-- 子孙组件 -->
<script setup>
import { inject } from 'vue'

const count = inject('count')
const increment = inject('increment')

// count 是只读的,不能直接修改
// count.value++  ❌ 会触发警告

// 通过提供的函数修改
increment()  // ✅ 正确方式
</script>

2.5 在应用根级别 Provide

如果需要在整个应用中提供数据,可以在 main.ts 中调用 app.provide():

typescript 复制代码
// main.ts
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// 全局提供数据,所有组件都可注入
app.provide('globalConfig', {
  apiBase: 'https://api.example.com',
  appVersion: '1.0.0'
})

app.mount('#app')

2.6 深层 Provide / Inject 的注意点

① 只能从祖先到子孙,不能反向

数据流方向是单向的:祖先 → 子孙。孙子组件无法直接向祖先传递数据,需要通过事件或其他方式。

② 响应式数据的修改需要谨慎

虽然子组件可以修改注入的响应式数据,但遵循单向数据流原则更利于维护:

  • 推荐在祖先组件中提供修改方法
  • 子组件通过调用方法来修改数据
  • 避免在子组件中直接修改注入的数据

③ 依赖注入的调试

Vue DevTools 可以查看 provide 和 inject 的数据结构,方便调试。

2.7 Provide / Inject 与 Props 的选择

场景 推荐方式 原因
父子组件直接通信 Props / Emit 清晰、可追踪
跨越多层(祖孙) Provide / Inject 避免 Props 钻探
全局共享状态(如主题、用户) Provide / Inject 简单轻量,不引入 Pinia
大型应用状态管理 Pinia 更规范、有 devtools 支持
同一组件树的多个分支共享 Provide / Inject 范围限定在组件树内

三、事件总线(mitt):任意组件通信

3.1 什么是事件总线

事件总线(Event Bus) 是一种设计模式,它允许任意两个组件之间 直接通信,而不需要父子关系。在 Vue 3 中,Vue 实例不再内置事件总线功能,推荐使用专门的库------mitt。

适用场景:

  • 兄弟组件之间通信
  • 非父子关系的组件通信
  • 跨页面/跨路由的事件通知

3.2 安装与使用 mitt

安装:

bash 复制代码
pnpm add mitt

创建事件总线实例:

typescript 复制代码
// utils/eventBus.ts
import mitt from 'mitt'

// 定义事件类型
type Events = {
  'user-login': { userId: number; name: string }
  'user-logout': void
  'theme-change': 'dark' | 'light'
  'notification': { message: string; type: 'success' | 'error' }
}

// 创建类型安全的事件总线
export const eventBus = mitt<Events>()

// 导出便捷方法
export const { emit, on, off } = eventBus

在组件中使用:

vue 复制代码
<!-- 组件 A:触发事件 -->
<script setup>
import { eventBus } from '@/utils/eventBus'

function login() {
  // 触发事件,传递数据
  eventBus.emit('user-login', { userId: 1, name: '张三' })
}

function logout() {
  eventBus.emit('user-logout')
}
</script>
<template>
  <button @click="login">登录</button>
  <button @click="logout">登出</button>
</template>
vue 复制代码
<!-- 组件 B:监听事件 -->
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { eventBus } from '@/utils/eventBus'

function handleLogin(data: { userId: number; name: string }) {
  console.log('用户登录:', data)
}

function handleLogout() {
  console.log('用户已登出')
}

onMounted(() => {
  // 监听事件
  eventBus.on('user-login', handleLogin)
  eventBus.on('user-logout', handleLogout)
})

onUnmounted(() => {
  // ⚠️ 重要:在组件销毁时移除监听,防止内存泄漏
  eventBus.off('user-login', handleLogin)
  eventBus.off('user-logout', handleLogout)
})
</script>

3.3 mitt 的核心 API

API 签名 说明
on emitter.on(event, handler) 监听事件
off emitter.off(event, handler) 移除监听
emit emitter.emit(event, payload) 触发事件
all emitter.all 获取所有事件监听器(调试用)

一次性监听(mitt 不原生支持,可自行封装):

typescript 复制代码
// 封装一次性监听
function once(emitter: mitt.Emitter, event: string, handler: Function) {
  const wrapper = (payload: any) => {
    handler(payload)
    emitter.off(event, wrapper)
  }
  emitter.on(event, wrapper)
}

// 使用
once(eventBus, 'user-login', (data) => {
  console.log('只执行一次', data)
})

3.4 在组件中自动管理事件监听

为了避免手动管理 onMounted / onUnmounted,可以封装一个组合式函数:

typescript 复制代码
// composables/useEventBus.ts
import { onMounted, onUnmounted } from 'vue'
import { eventBus } from '@/utils/eventBus'

export function useEventBus(
  events: Array<{
    event: string
    handler: (...args: any[]) => void
  }>
) {
  onMounted(() => {
    events.forEach(({ event, handler }) => {
      eventBus.on(event, handler)
    })
  })

  onUnmounted(() => {
    events.forEach(({ event, handler }) => {
      eventBus.off(event, handler)
    })
  })
}
vue 复制代码
<script setup>
import { useEventBus } from '@/composables/useEventBus'

useEventBus([
  { event: 'user-login', handler: (data) => console.log('登录', data) },
  { event: 'theme-change', handler: (theme) => document.body.className = theme }
])
</script>

3.5 事件总线的优缺点

优点 缺点
解耦:组件不需要知道彼此的存在 调试困难:事件流不可追踪,难以定位问题
灵活:任何组件都可以通信 维护成本高:事件名分散,容易冲突或遗忘
简单:API 直观,学习成本低 类型安全弱:虽然可用 TS,但不如 Props/Emit 强
跨组件通信:兄弟、跨层级都适用 滥用会导致混乱:数据流不可预测

最佳实践:

  • 将事件总线用于低频、跨组件的事件通知(如全局消息、主题切换)
  • 避免用事件总线传递大量数据 或频繁触发的事件
  • 建议为事件名定义常量枚举,避免硬编码

四、组件通信方式全面对比

通信方式 适用场景 数据流方向 响应式 复杂度
Props 父子组件传数据 父 → 子 ✅ 低
Emit 子父组件传事件 子 → 父 ❌(事件) 低
v-model 双向绑定 父 ↔ 子 ✅ 低
Provide / Inject 跨层级祖孙通信 祖先 → 子孙 ✅(配合 ref) 中
事件总线(mitt) 任意组件通信 任意方向 ❌(事件) 中
Pinia 全局状态管理 全局 ✅ 高
$refs 直接访问子组件实例 父 → 子 通过实例 中

4.1 选择指南

复制代码
组件间需要通信
    ↓
是否有直接的父子关系?
    ↓ 是                              ↓ 否
是否跨越多层?                         是否为全局共享状态?
    ↓ 是          ↓ 否                   ↓ 是          ↓ 否
Provide/Inject  Props/Emit            Pinia       事件总线

五、综合实战:主题切换与全局通知

让我们结合 Provide / Inject 和事件总线,构建一个具有主题切换和全局通知功能的完整示例。

5.1 项目结构

复制代码
src/
├── main.ts
├── App.vue
├── keys/
│   └── index.ts               # 注入 key 定义
├── composables/
│   ├── useTheme.ts            # 主题组合式函数
│   └── useNotification.ts     # 通知组合式函数
├── components/
│   ├── ThemeToggle.vue        # 主题切换按钮
│   ├── NotificationContainer.vue  # 通知容器
│   └── UserProfile.vue        # 用户个人资料
└── utils/
    └── eventBus.ts            # 事件总线

5.2 事件总线定义

typescript 复制代码
// utils/eventBus.ts
import mitt from 'mitt'

// 定义事件类型
export type Events = {
  'notification': {
    id: string
    message: string
    type: 'info' | 'success' | 'warning' | 'error'
    duration?: number
  }
  'notification:clear': void
  'theme:changed': 'dark' | 'light'
}

export const eventBus = mitt<Events>()
export const { emit, on, off } = eventBus

// 便利方法
export function notify(
  message: string,
  type: Events['notification']['type'] = 'info',
  duration: number = 3000
) {
  emit('notification', {
    id: Date.now().toString(),
    message,
    type,
    duration
  })
}

5.3 Provide / Inject 定义

typescript 复制代码
// keys/index.ts
import { InjectionKey, Ref } from 'vue'

export type Theme = 'dark' | 'light'

export const THEME_KEY = Symbol('theme') as InjectionKey<Ref<Theme>>
export const TOGGLE_THEME_KEY = Symbol('toggleTheme') as InjectionKey<() => void>
export const APP_VERSION_KEY = Symbol('appVersion') as InjectionKey<string>

5.4 主题组合式函数

typescript 复制代码
// composables/useTheme.ts
import { ref, provide, inject, watch } from 'vue'
import { THEME_KEY, TOGGLE_THEME_KEY, type Theme } from '@/keys'
import { eventBus } from '@/utils/eventBus'

export function useTheme() {
  const theme = ref<Theme>('light')

  function toggleTheme() {
    theme.value = theme.value === 'dark' ? 'light' : 'dark'
    // 通过事件总线通知主题变化
    eventBus.emit('theme:changed', theme.value)
  }

  // 应用主题到 DOM
  watch(theme, (newTheme) => {
    document.documentElement.setAttribute('data-theme', newTheme)
  }, { immediate: true })

  return { theme, toggleTheme }
}

// 在根组件中调用,提供主题
export function provideTheme() {
  const { theme, toggleTheme } = useTheme()
  provide(THEME_KEY, theme)
  provide(TOGGLE_THEME_KEY, toggleTheme)
  return { theme, toggleTheme }
}

// 在子组件中注入主题
export function useInjectTheme() {
  const theme = inject(THEME_KEY)
  const toggleTheme = inject(TOGGLE_THEME_KEY)

  if (!theme || !toggleTheme) {
    throw new Error('Theme not provided')
  }

  return { theme, toggleTheme }
}

5.5 通知组合式函数

typescript 复制代码
// composables/useNotification.ts
import { ref, onMounted, onUnmounted } from 'vue'
import { eventBus, type Events } from '@/utils/eventBus'

interface Notification extends Events['notification'] {
  id: string
}

export function useNotification() {
  const notifications = ref<Notification[]>([])

  function addNotification(notification: Notification) {
    notifications.value.push(notification)

    // 自动移除
    if (notification.duration && notification.duration > 0) {
      setTimeout(() => {
        removeNotification(notification.id)
      }, notification.duration)
    }
  }

  function removeNotification(id: string) {
    notifications.value = notifications.value.filter(n => n.id !== id)
  }

  function clearAll() {
    notifications.value = []
  }

  // 监听事件总线
  onMounted(() => {
    eventBus.on('notification', addNotification)
    eventBus.on('notification:clear', clearAll)
  })

  onUnmounted(() => {
    eventBus.off('notification', addNotification)
    eventBus.off('notification:clear', clearAll)
  })

  return {
    notifications,
    addNotification,
    removeNotification,
    clearAll
  }
}

5.6 主组件实现

vue 复制代码
<!-- App.vue -->
<script setup>
import { provideTheme } from '@/composables/useTheme'
import { APP_VERSION_KEY } from '@/keys'
import ThemeToggle from './components/ThemeToggle.vue'
import NotificationContainer from './components/NotificationContainer.vue'
import UserProfile from './components/UserProfile.vue'

// 提供主题
provideTheme()

// 提供应用版本
provide(APP_VERSION_KEY, 'v1.0.0')
</script>

<template>
  <div class="app">
    <header>
      <h1>🚀 Vue 3 组件通信示例</h1>
      <ThemeToggle />
    </header>
    <main>
      <UserProfile />
    </main>
    <NotificationContainer />
  </div>
</template>

<style>
[data-theme="dark"] {
  --bg: #1a202c;
  --text: #e2e8f0;
  --card-bg: #2d3748;
  --border: #4a5568;
}
[data-theme="light"] {
  --bg: #f7fafc;
  --text: #1a202c;
  --card-bg: #ffffff;
  --border: #e2e8f0;
}
.app {
  min-height: 100vh;
  background: var(--bg);
  color: var(--text);
  transition: background 0.3s, color 0.3s;
}
header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20px 40px;
  background: var(--card-bg);
  border-bottom: 1px solid var(--border);
}
main {
  padding: 40px;
  max-width: 800px;
  margin: 0 auto;
}
</style>

5.7 主题切换组件

vue 复制代码
<!-- components/ThemeToggle.vue -->
<script setup>
import { useInjectTheme } from '@/composables/useTheme'
import { notify } from '@/utils/eventBus'

const { theme, toggleTheme } = useInjectTheme()

function handleToggle() {
  toggleTheme()
  notify(`切换至 ${theme.value === 'dark' ? '暗色' : '亮色'} 主题`, 'success', 2000)
}
</script>

<template>
  <button @click="handleToggle" class="theme-toggle">
    <span v-if="theme === 'dark'">🌙</span>
    <span v-else>☀️</span>
    {{ theme === 'dark' ? '暗色' : '亮色' }}
  </button>
</template>

<style scoped>
.theme-toggle {
  padding: 8px 16px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--bg);
  color: var(--text);
  cursor: pointer;
  transition: all 0.2s;
}
.theme-toggle:hover {
  opacity: 0.8;
}
</style>

5.8 通知容器组件

vue 复制代码
<!-- components/NotificationContainer.vue -->
<script setup>
import { useNotification } from '@/composables/useNotification'
import { computed } from 'vue'

const { notifications, removeNotification } = useNotification()

// 按类型获取样式
function getTypeStyles(type: string) {
  const map = {
    info: { bg: '#ebf8ff', color: '#2b6cb0', border: '#bee3f8' },
    success: { bg: '#f0fff4', color: '#276749', border: '#c6f6d5' },
    warning: { bg: '#fffff0', color: '#975a16', border: '#f6e05e' },
    error: { bg: '#fff5f5', color: '#9b2c2c', border: '#fed7d7' }
  }
  return map[type as keyof typeof map] || map.info
}
</script>

<template>
  <div class="notification-container">
    <div
      v-for="notif in notifications"
      :key="notif.id"
      class="notification"
      :style="{
        background: getTypeStyles(notif.type).bg,
        color: getTypeStyles(notif.type).color,
        borderColor: getTypeStyles(notif.type).border
      }"
    >
      <span class="icon">
        {{ notif.type === 'info' ? 'ℹ️' : notif.type === 'success' ? '✅' : notif.type === 'warning' ? '⚠️' : '❌' }}
      </span>
      <span class="message">{{ notif.message }}</span>
      <button @click="removeNotification(notif.id)" class="close-btn">×</button>
    </div>
  </div>
</template>

<style scoped>
.notification-container {
  position: fixed;
  top: 20px;
  right: 20px;
  z-index: 1000;
  display: flex;
  flex-direction: column;
  gap: 10px;
  max-width: 400px;
}
.notification {
  padding: 12px 16px;
  border: 2px solid;
  border-radius: 8px;
  display: flex;
  align-items: center;
  gap: 10px;
  animation: slideIn 0.3s ease;
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.message { flex: 1; font-size: 14px; }
.close-btn {
  background: none;
  border: none;
  font-size: 20px;
  cursor: pointer;
  opacity: 0.6;
}
.close-btn:hover { opacity: 1; }
@keyframes slideIn {
  from { transform: translateX(100%); opacity: 0; }
  to { transform: translateX(0); opacity: 1; }
}
</style>

5.9 用户资料组件

vue 复制代码
<!-- components/UserProfile.vue -->
<script setup>
import { inject } from 'vue'
import { APP_VERSION_KEY } from '@/keys'
import { notify } from '@/utils/eventBus'

const appVersion = inject(APP_VERSION_KEY)

function handleSave() {
  // 模拟保存操作
  notify('用户资料已保存!', 'success', 3000)
}

function handleError() {
  notify('保存失败,请重试', 'error', 3000)
}

function handleWarning() {
  notify('部分字段未填写完整', 'warning', 3000)
}
</script>

<template>
  <div class="user-profile">
    <div class="profile-header">
      <h2>👤 用户资料</h2>
      <span class="version">版本:{{ appVersion }}</span>
    </div>
    <div class="profile-body">
      <div class="info-row">
        <label>姓名</label>
        <input type="text" placeholder="请输入姓名" />
      </div>
      <div class="info-row">
        <label>邮箱</label>
        <input type="email" placeholder="请输入邮箱" />
      </div>
      <div class="actions">
        <button @click="handleSave" class="btn-save">💾 保存</button>
        <button @click="handleWarning" class="btn-warn">⚠️ 警告</button>
        <button @click="handleError" class="btn-error">❌ 错误</button>
      </div>
    </div>
  </div>
</template>

<style scoped>
.user-profile {
  background: var(--card-bg);
  border-radius: 12px;
  padding: 24px;
  border: 1px solid var(--border);
}
.profile-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}
.version {
  font-size: 12px;
  color: var(--text);
  opacity: 0.6;
}
.info-row {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-bottom: 16px;
}
.info-row label {
  font-size: 14px;
  font-weight: 500;
}
.info-row input {
  padding: 10px 14px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg);
  color: var(--text);
  font-size: 14px;
}
.actions {
  display: flex;
  gap: 10px;
  margin-top: 8px;
}
.actions button {
  padding: 8px 20px;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-weight: 500;
  transition: opacity 0.2s;
}
.actions button:hover { opacity: 0.8; }
.btn-save { background: #4299e1; color: white; }
.btn-warn { background: #ecc94b; color: #744210; }
.btn-error { background: #fc8181; color: white; }
</style>

六、本篇小结

知识点 核心内容 关键要点
Provide / Inject 跨层级依赖注入 使用 Symbol 作为 key 避免冲突
响应式 Provide 提供 ref/reactive 数据 配合 readonly 保护数据
事件总线(mitt) 任意组件通信 需要在 onUnmounted 中移除监听
选择策略 按场景选择合适方式 Props/Emit → Provide → 事件总线 → Pinia
组合式函数 封装 Provide/Inject 逻辑 提高代码复用性

七、思考与练习

  1. 基础练习 :使用 Provide / Inject 在根组件提供一个 locale 状态('zh' / 'en'),并在深层组件中显示当前语言和切换按钮。

  2. 实践任务 :使用 mitt 实现一个全局的 loading 状态管理,任何组件都可以触发 showLoading 和 hideLoading 事件,并在全局统一显示加载状态。

  3. 陷阱排查:以下代码有什么问题?

    vue 复制代码
    <!-- 祖先组件 -->
    <script setup>
    provide('count', 0)
    function increment() { count++ }
    </script>
    
    <!-- 子孙组件 -->
    <script setup>
    const count = inject('count')
    function update() { count++ }
    </script>
  4. 深度思考:Provide / Inject 和 Pinia 都可以用于共享状态,它们分别适合什么场景?在什么情况下你会选择 Provide / Inject 而不是引入 Pinia?

  5. 扩展任务 :为通知系统添加 id 自动生成和 duration 默认值,并实现通知的批量清除功能。

下篇预告

第 10 篇我们将学习插槽(Slot),这是实现组件高度可定制化的核心工具:

  • 默认插槽与具名插槽
  • 作用域插槽------子组件向父组件传递数据
  • 动态插槽名
  • 插槽的高级模式与最佳实践

准备好让你的组件变得更加强大和灵活!🎨

相关推荐
lichenyang4531 小时前
技术穿刺怎么做?以企业知识库问答为例,验证风险、方案和投入
前端·后端
PedroQue991 小时前
uni-app x工程化实战:从页面堆到可维护架构
前端·uni-app
开开心心_Every1 小时前
免费压缩软件WinRAR无弹窗、广告干扰
java·开发语言·前端·spring·智能手机·word·winrar
特立独行的猫A1 小时前
用仓颉语言写桌面应用:cj-tauri 框架与 AI 笔记软件 cjnote 实战
前端
特立独行的猫A2 小时前
我为什么推荐用仓颉语言的cj-tauri 写桌面应用(附 cjnote 最小的markdown笔记AI软件)
前端
纸片人2 小时前
牛一越界,大屏 3 秒飞到它身边:我用真实卫星数据 + Three.js 做了个 3D 智慧牧场
前端
cc_瀚海知行2 小时前
工具调用 6|助手什么时候该停下来?
java·服务器·前端·人工智能·python·ai
溪语流沙2 小时前
【Web全栈进阶】Nginx反向代理 + HTTPS:正式上线
前端·vue.js·python·nginx·https·fastapi