普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月24日首页

大师助我,electron-hiprint 源码梳理

作者 Ankkaya
2026年3月24日 10:01

前言

上篇我们通过微信小程序云打印,添加了默认值属性,但只支持浏览器打印,如果要支持静默打印(本地打印)和远程打印,还要修改打印助手

源码解析

首先在我们项目内找到静默打印调用位置

  printSocket.emit('render-print', {
    template: hiprintTemplate.value.getJson(),
    data: printData.value,
  })

electron-hiprint搜索 render-print 标识的消息监听,有两个地方监听该消息,区别是一个监听客户端,一个监听中转服务器

他们都调用了RENDER_WINDOW.webContents.send("print", data);,可以问问 codex 具体执行,分析过程很具体,这是最后的总结

继续追问 hiprint.PrintTemplate 和 data.data 生成 html 的具体实现,红色部分很关键,打印模板赋值过程实际还是用的vue-plugin-hiprint逻辑,它是以插件的形式引入的,那么我们只需要把修改后的项目打包插件引入即可

引入插件

插件设置在打印助手,基础设置面板,对应项目文件set.html,软件内置了几个版本插件,同时支持在线切换,把这些逻辑告诉 codex,了解代码内实现

这是总结结果,配置中心默认设置plugin下已下载插件,列表拉取 npm 列表,选择后下载对应文件,并重启应用

那就简单了,把 npm 源换成我们 npm 包的地址,同时修改本地默认插件版本

小插曲

在本地启动electron-hiprint启动调试过程,我也遇到不少问题,虽然 codex 最后都能帮我解决,但如何从一开始的前提就是错误的,那么即使过程中得到正确的结果,最终结果还是有问题

说的有点绕口了,其实就是项目初始化我用的 pnpm 和 node24.0.0,这才导致了一系列问题,虽然最后打包成功,但启动报错。折腾一圈我感觉不太对劲,才切换为 npm 和 node16.0.0

还有啊

没有了,还没用的小伙伴赶紧甩开吧,效率杠杠的,24小时专属编程指导,不香吗

有趣味的登录页它踏着七彩祥云来了

作者 BugShare
2026年3月23日 23:09

最近,有一个比较火的很有趣且灵动的登录页火了。

  • 角色视觉跟随鼠标
  • 输入框打字时扯脖子瞅
  • 显示密码明文时避开视线

PixPin_2026-03-23_14-13-18.gif

已经有大神(katavii)复刻了动画效果,并在github上开源了:github.com/katavii/ani… ,基于React实现。

如果你的项目是用Vue开发的,可以考虑用AI将此项目转换成了Vue3的语法写法。

最简单的方式,直接用Claude Code一句话就能完成,根据模型能力,你可能需要多次调试。

claude
帮我把这个项目转成vue3 + ant-design-vue的前端项目

以下是我的转换代码,如果你的AI代码没有调试成功,可以参考下。

创建项目

现在开发前端项目,肯定首选Vite

pnpm create vite
# 选择Vue模板、TypeScript语法

PixPin_2026-03-23_14-19-09.png

封装组件

src/components/创建animated-characters文件夹

EyeBall

创建 src/components/animated-characters/EyeBall.vue,制作动画的大眼睛

<template>
  <div
    class="eyeball"
    :data-max-distance="maxDistance"
    :style="eyeballStyle"
  >
    <div
      class="eyeball-pupil"
      :style="pupilStyle"
    />
  </div>
</template>

<script setup lang="ts">
interface Props {
  size?: string
  pupilSize?: string
  maxDistance?: number
  eyeColor?: string
  pupilColor?: string
}

const {
  size,
  pupilSize,
  maxDistance,
  eyeColor,
  pupilColor
} = withDefaults(defineProps<Props>(), {
  size: '48px',
  pupilSize: '16px',
  maxDistance: 10,
  eyeColor: 'white',
  pupilColor: 'black'
})

const eyeballStyle = {
  width: size,
  height: size,
  borderRadius: '50%',
  backgroundColor: eyeColor,
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  overflow: 'hidden',
  willChange: 'height'
}

const pupilStyle = {
  width: pupilSize,
  height: pupilSize,
  borderRadius: '50%',
  backgroundColor: pupilColor,
  willChange: 'transform'
}
</script>

PixPin_2026-03-23_14-45-23.png

Pupil

创建 src/components/animated-characters/Pupil.vue,制作动画的小眼睛

<template>
  <div
    :data-max-distance="maxDistance"
    class="pupil"
    :style="pupilStyle"
  />
</template>

<script setup lang="ts">
interface Props {
  size?: string
  maxDistance?: number
  pupilColor?: string
}

const {
  size,
  maxDistance,
  pupilColor
} = withDefaults(defineProps<Props>(), {
  size: '12px',
  maxDistance: 5,
  pupilColor: 'black'
})

const pupilStyle = {
  width: size,
  height: size,
  borderRadius: '50%',
  backgroundColor: pupilColor,
  willChange: 'transform'
}
</script>

PixPin_2026-03-23_14-46-10.png

角色

安装依赖

pnpm install gsap --save

创建 src/components/animated-characters/Index.vue,制作动画的角色

props属性

- is-typing         是否正在输入
- show-password     显示密码明文
- password-length   密码输入框是否有值
<template>
  <div ref="containerRef" :style="containerStyle">
    <!-- 紫色角色 -->
    <div
      ref="purpleRef"
      :style="purpleBodyStyle"
    >
      <div ref="purpleFaceRef" :style="purpleFaceStyle">
        <EyeBall
          size="18px"
          pupil-size="7px"
          :max-distance="5"
          eye-color="white"
          pupil-color="#2D2D2D"
        />
        <EyeBall
          size="18px"
          pupil-size="7px"
          :max-distance="5"
          eye-color="white"
          pupil-color="#2D2D2D"
        />
      </div>
    </div>

    <!-- 黑色角色 -->
    <div
      ref="blackRef"
      :style="blackBodyStyle"
    >
      <div ref="blackFaceRef" :style="blackFaceStyle">
        <EyeBall
          size="16px"
          pupil-size="6px"
          :max-distance="4"
          eye-color="white"
          pupil-color="#2D2D2D"
        />
        <EyeBall
          size="16px"
          pupil-size="6px"
          :max-distance="4"
          eye-color="white"
          pupil-color="#2D2D2D"
        />
      </div>
    </div>

    <!-- 橘黄色角色 -->
    <div
      ref="orangeRef"
      :style="orangeBodyStyle"
    >
      <div ref="orangeFaceRef" :style="orangeFaceStyle">
        <Pupil size="12px" :max-distance="5" pupil-color="#2D2D2D" />
        <Pupil size="12px" :max-distance="5" pupil-color="#2D2D2D" />
      </div>
    </div>

    <!-- 黄色角色 -->
    <div
      ref="yellowRef"
      :style="yellowBodyStyle"
    >
      <div ref="yellowFaceRef" :style="yellowFaceStyle">
        <Pupil size="12px" :max-distance="5" pupil-color="#2D2D2D" />
        <Pupil size="12px" :max-distance="5" pupil-color="#2D2D2D" />
      </div>
      <div ref="yellowMouthRef" :style="yellowMouthStyle" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted, onBeforeUnmount, watch, toRef } from 'vue'
import gsap from 'gsap'
import Pupil from './Pupil.vue'
import EyeBall from './EyeBall.vue'

interface Props {
  isTyping?: boolean
  showPassword?: boolean
  passwordLength?: number
}

const props = withDefaults(defineProps<Props>(), {
  isTyping: false,
  showPassword: false,
  passwordLength: 0
})

const containerRef = ref<HTMLElement | null>(null)
const mouseRef = reactive({ x: 0, y: 0 })
const rafIdRef = ref<number>(0)

const purpleRef = ref<HTMLElement | null>(null)
const blackRef = ref<HTMLElement | null>(null)
const yellowRef = ref<HTMLElement | null>(null)
const orangeRef = ref<HTMLElement | null>(null)

const purpleFaceRef = ref<HTMLElement | null>(null)
const blackFaceRef = ref<HTMLElement | null>(null)
const yellowFaceRef = ref<HTMLElement | null>(null)
const orangeFaceRef = ref<HTMLElement | null>(null)

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

const purpleBlinkTimerRef = ref<ReturnType<typeof setTimeout>>()
const blackBlinkTimerRef = ref<ReturnType<typeof setTimeout>>()
const purplePeekTimerRef = ref<ReturnType<typeof setTimeout>>()

const isHidingPassword = toRef(() => props.passwordLength > 0 && !props.showPassword)
const isShowingPassword = toRef(() => props.passwordLength > 0 && props.showPassword)

const isLookingRef = ref(false)
const lookingTimerRef = ref<ReturnType<typeof setTimeout>>()

const stateRef = reactive({
  isTyping: false,
  isHidingPassword: false,
  isShowingPassword: false,
  isLooking: false
})

watch(
  () => [props.isTyping, isHidingPassword.value, isShowingPassword.value, isLookingRef.value] as const,
  ([isTyping, isHiding, isShowing, isLooking]) => {
    stateRef.isTyping = isTyping
    stateRef.isHidingPassword = isHiding
    stateRef.isShowingPassword = isShowing
    stateRef.isLooking = isLooking
  }
)

// GSAP quickTo instances
const quickToRef = ref<Record<string, any> | null>(null)

const containerStyle = {
  position: 'relative' as const,
  width: '550px',
  height: '400px'
}

const purpleBodyStyle = ref<any>({
  position: 'absolute',
  bottom: 0,
  left: '70px',
  width: '180px',
  height: '400px',
  backgroundColor: '#6C3FF5',
  borderRadius: '10px 10px 0 0',
  zIndex: 1,
  transformOrigin: 'bottom center',
  willChange: 'transform'
})

const blackBodyStyle = ref<any>({
  position: 'absolute',
  bottom: 0,
  left: '240px',
  width: '120px',
  height: '310px',
  backgroundColor: '#2D2D2D',
  borderRadius: '8px 8px 0 0',
  zIndex: 2,
  transformOrigin: 'bottom center',
  willChange: 'transform'
})

const orangeBodyStyle = ref<any>({
  position: 'absolute',
  bottom: 0,
  left: 0,
  width: '240px',
  height: '200px',
  backgroundColor: '#FF9B6B',
  borderRadius: '120px 120px 0 0',
  zIndex: 3,
  transformOrigin: 'bottom center',
  willChange: 'transform'
})

const yellowBodyStyle = ref<any>({
  position: 'absolute',
  bottom: 0,
  left: '310px',
  width: '140px',
  height: '230px',
  backgroundColor: '#E8D754',
  borderRadius: '70px 70px 0 0',
  zIndex: 4,
  transformOrigin: 'bottom center',
  willChange: 'transform'
})

const purpleFaceStyle = ref<any>({
  position: 'absolute',
  display: 'flex',
  gap: '32px',
  left: '45px',
  top: '40px'
})

const blackFaceStyle = ref<any>({
  position: 'absolute',
  display: 'flex',
  gap: '24px',
  left: '26px',
  top: '32px'
})

const orangeFaceStyle = ref<any>({
  position: 'absolute',
  display: 'flex',
  gap: '32px',
  left: '82px',
  top: '90px'
})

const yellowFaceStyle = ref<any>({
  position: 'absolute',
  display: 'flex',
  gap: '24px',
  left: '52px',
  top: '40px'
})

const yellowMouthStyle = ref<any>({
  position: 'absolute',
  width: '80px',
  height: '4px',
  backgroundColor: '#2D2D2D',
  borderRadius: '9999px',
  left: '40px',
  top: '88px'
})

// Initialize GSAP
onMounted(() => {
  gsap.set('.pupil', { x: 0, y: 0 })
  gsap.set('.eyeball-pupil', { x: 0, y: 0 })
})

onMounted(() => {
  if (
    !purpleRef.value ||
    !blackRef.value ||
    !orangeRef.value ||
    !yellowRef.value ||
    !purpleFaceRef.value ||
    !blackFaceRef.value ||
    !orangeFaceRef.value ||
    !yellowFaceRef.value ||
    !yellowMouthRef.value
  )
    return

  const qt = {
    purpleSkew: gsap.quickTo(purpleRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
    blackSkew: gsap.quickTo(blackRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
    orangeSkew: gsap.quickTo(orangeRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
    yellowSkew: gsap.quickTo(yellowRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
    purpleX: gsap.quickTo(purpleRef.value, 'x', { duration: 0.3, ease: 'power2.out' }),
    blackX: gsap.quickTo(blackRef.value, 'x', { duration: 0.3, ease: 'power2.out' }),
    purpleHeight: gsap.quickTo(purpleRef.value, 'height', { duration: 0.3, ease: 'power2.out' }),
    purpleFaceLeft: gsap.quickTo(purpleFaceRef.value, 'left', { duration: 0.3, ease: 'power2.out' }),
    purpleFaceTop: gsap.quickTo(purpleFaceRef.value, 'top', { duration: 0.3, ease: 'power2.out' }),
    blackFaceLeft: gsap.quickTo(blackFaceRef.value, 'left', { duration: 0.3, ease: 'power2.out' }),
    blackFaceTop: gsap.quickTo(blackFaceRef.value, 'top', { duration: 0.3, ease: 'power2.out' }),
    orangeFaceX: gsap.quickTo(orangeFaceRef.value, 'x', { duration: 0.2, ease: 'power2.out' }),
    orangeFaceY: gsap.quickTo(orangeFaceRef.value, 'y', { duration: 0.2, ease: 'power2.out' }),
    yellowFaceX: gsap.quickTo(yellowFaceRef.value, 'x', { duration: 0.2, ease: 'power2.out' }),
    yellowFaceY: gsap.quickTo(yellowFaceRef.value, 'y', { duration: 0.2, ease: 'power2.out' }),
    mouthX: gsap.quickTo(yellowMouthRef.value, 'x', { duration: 0.2, ease: 'power2.out' }),
    mouthY: gsap.quickTo(yellowMouthRef.value, 'y', { duration: 0.2, ease: 'power2.out' })
  }
  quickToRef.value = qt

  const calcPos = (el: HTMLElement) => {
    const rect = el.getBoundingClientRect()
    const cx = rect.left + rect.width / 2
    const cy = rect.top + rect.height / 3
    const dx = mouseRef.x - cx
    const dy = mouseRef.y - cy
    return {
      faceX: Math.max(-15, Math.min(15, dx / 20)),
      faceY: Math.max(-10, Math.min(10, dy / 30)),
      bodySkew: Math.max(-6, Math.min(6, -dx / 120))
    }
  }

  const calcEyePos = (el: HTMLElement, maxDist: number) => {
    const r = el.getBoundingClientRect()
    const cx = r.left + r.width / 2
    const cy = r.top + r.height / 2
    const dx = mouseRef.x - cx
    const dy = mouseRef.y - cy
    const dist = Math.min(Math.sqrt(dx ** 2 + dy ** 2), maxDist)
    const angle = Math.atan2(dy, dx)
    return { x: Math.cos(angle) * dist, y: Math.sin(angle) * dist }
  }

  const tick = () => {
    const container = containerRef.value
    if (!container) return

    const { isTyping: typing, isHidingPassword: hiding, isShowingPassword: showing, isLooking: looking } = stateRef

    if (purpleRef.value && !showing) {
      const pp = calcPos(purpleRef.value)
      if (typing || hiding) {
        qt.purpleSkew(pp.bodySkew - 12)
        qt.purpleX(40)
        qt.purpleHeight(440)
      } else {
        qt.purpleSkew(pp.bodySkew)
        qt.purpleX(0)
        qt.purpleHeight(400)
      }
    }

    if (blackRef.value && !showing) {
      const bp = calcPos(blackRef.value)
      if (looking) {
        qt.blackSkew(bp.bodySkew * 1.5 + 10)
        qt.blackX(20)
      } else if (typing || hiding) {
        qt.blackSkew(bp.bodySkew * 1.5)
        qt.blackX(0)
      } else {
        qt.blackSkew(bp.bodySkew)
        qt.blackX(0)
      }
    }

    if (orangeRef.value && !showing) {
      const op = calcPos(orangeRef.value)
      qt.orangeSkew(op.bodySkew)
    }

    if (yellowRef.value && !showing) {
      const yp = calcPos(yellowRef.value)
      qt.yellowSkew(yp.bodySkew)
    }

    if (purpleRef.value && !showing && !looking) {
      const pp = calcPos(purpleRef.value)
      const purpleFaceX = pp.faceX >= 0 ? Math.min(25, pp.faceX * 1.5) : pp.faceX
      qt.purpleFaceLeft(45 + purpleFaceX)
      qt.purpleFaceTop(40 + pp.faceY)
    }

    if (blackRef.value && !showing && !looking) {
      const bp = calcPos(blackRef.value)
      qt.blackFaceLeft(26 + bp.faceX)
      qt.blackFaceTop(32 + bp.faceY)
    }

    if (orangeRef.value && !showing) {
      const op = calcPos(orangeRef.value)
      qt.orangeFaceX(op.faceX)
      qt.orangeFaceY(op.faceY)
    }

    if (yellowRef.value && !showing) {
      const yp = calcPos(yellowRef.value)
      qt.yellowFaceX(yp.faceX)
      qt.yellowFaceY(yp.faceY)
      qt.mouthX(yp.faceX)
      qt.mouthY(yp.faceY)
    }

    if (!showing) {
      const allPupils = container.querySelectorAll('.pupil')
      allPupils.forEach((p) => {
        const el = p as HTMLElement
        const maxDist = Number(el.dataset.maxDistance) || 5
        const ePos = calcEyePos(el, maxDist)
        gsap.set(el, { x: ePos.x, y: ePos.y })
      })

      if (!looking) {
        const allEyeballs = container.querySelectorAll('.eyeball')
        allEyeballs.forEach((eb) => {
          const el = eb as HTMLElement
          const maxDist = Number(el.dataset.maxDistance) || 10
          const pupil = el.querySelector('.eyeball-pupil') as HTMLElement
          if (!pupil) return
          const ePos = calcEyePos(el, maxDist)
          gsap.set(pupil, { x: ePos.x, y: ePos.y })
        })
      }
    }

    rafIdRef.value = requestAnimationFrame(tick)
  }

  const onMove = (e: MouseEvent) => {
    mouseRef.x = e.clientX
    mouseRef.y = e.clientY
  }

  window.addEventListener('mousemove', onMove, { passive: true })
  rafIdRef.value = requestAnimationFrame(tick)

  onBeforeUnmount(() => {
    window.removeEventListener('mousemove', onMove)
    cancelAnimationFrame(rafIdRef.value)
  })
})

// Purple character blink
onMounted(() => {
  const purpleEyeballs = purpleRef.value?.querySelectorAll('.eyeball')
  if (!purpleEyeballs?.length) return

  const scheduleBlink = () => {
    purpleBlinkTimerRef.value = setTimeout(() => {
      purpleEyeballs.forEach((el) => {
        gsap.to(el, { height: 2, duration: 0.08, ease: 'power2.in' })
      })
      setTimeout(() => {
        purpleEyeballs.forEach((el) => {
          const size = Number((el as HTMLElement).style.width.replace('px', '')) || 18
          gsap.to(el, { height: size, duration: 0.08, ease: 'power2.out' })
        })
        scheduleBlink()
      }, 150)
    }, Math.random() * 4000 + 3000)
  }

  scheduleBlink()
  onBeforeUnmount(() => clearTimeout(purpleBlinkTimerRef.value))
})

// Black character blink
onMounted(() => {
  const blackEyeballs = blackRef.value?.querySelectorAll('.eyeball')
  if (!blackEyeballs?.length) return

  const scheduleBlink = () => {
    blackBlinkTimerRef.value = setTimeout(() => {
      blackEyeballs.forEach((el) => {
        gsap.to(el, { height: 2, duration: 0.08, ease: 'power2.in' })
      })
      setTimeout(() => {
        blackEyeballs.forEach((el) => {
          const size = Number((el as HTMLElement).style.width.replace('px', '')) || 16
          gsap.to(el, { height: size, duration: 0.08, ease: 'power2.out' })
        })
        scheduleBlink()
      }, 150)
    }, Math.random() * 4000 + 3000)
  }

  scheduleBlink()
  onBeforeUnmount(() => clearTimeout(blackBlinkTimerRef.value))
})

const applyLookAtEachOther = () => {
  const qt = quickToRef.value
  if (qt) {
    qt.purpleFaceLeft(55)
    qt.purpleFaceTop(65)
    qt.blackFaceLeft(32)
    qt.blackFaceTop(12)
  }
  purpleRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
    gsap.to(p, { x: 3, y: 4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
  blackRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
    gsap.to(p, { x: 0, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
}

const applyHidingPassword = () => {
  const qt = quickToRef.value
  if (qt) {
    qt.purpleFaceLeft(55)
    qt.purpleFaceTop(65)
  }
}

const applyShowPassword = () => {
  const qt = quickToRef.value
  if (qt) {
    qt.purpleSkew(0)
    qt.blackSkew(0)
    qt.orangeSkew(0)
    qt.yellowSkew(0)
    qt.purpleX(0)
    qt.blackX(0)
    qt.purpleHeight(400)

    qt.purpleFaceLeft(20)
    qt.purpleFaceTop(35)
    qt.blackFaceLeft(10)
    qt.blackFaceTop(28)
    qt.orangeFaceX(50 - 82)
    qt.orangeFaceY(85 - 90)
    qt.yellowFaceX(20 - 52)
    qt.yellowFaceY(35 - 40)
    qt.mouthX(10 - 40)
    qt.mouthY(0)
  }

  purpleRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
    gsap.to(p, { x: -4, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
  blackRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
    gsap.to(p, { x: -4, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
  orangeRef.value?.querySelectorAll('.pupil').forEach((p) => {
    gsap.to(p, { x: -5, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
  yellowRef.value?.querySelectorAll('.pupil').forEach((p) => {
    gsap.to(p, { x: -5, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
  })
}

// Password peek effect
watch(
  () => [isShowingPassword.value, props.passwordLength],
  ([showing, len]) => {
    if (!showing || (len as number) <= 0) {
      clearTimeout(purplePeekTimerRef.value)
      return
    }

    const purpleEyePupils = purpleRef.value?.querySelectorAll('.eyeball-pupil')
    if (!purpleEyePupils?.length) return

    const schedulePeek = () => {
      purplePeekTimerRef.value = setTimeout(() => {
        purpleEyePupils.forEach((p) => {
          gsap.to(p, {
            x: 4,
            y: 5,
            duration: 0.3,
            ease: 'power2.out',
            overwrite: 'auto'
          })
        })
        const qt = quickToRef.value
        if (qt) {
          qt.purpleFaceLeft(20)
          qt.purpleFaceTop(35)
        }

        setTimeout(() => {
          purpleEyePupils.forEach((p) => {
            gsap.to(p, {
              x: -4,
              y: -4,
              duration: 0.3,
              ease: 'power2.out',
              overwrite: 'auto'
            })
          })
          schedulePeek()
        }, 800)
      }, Math.random() * 3000 + 2000)
    }

    schedulePeek()
    onBeforeUnmount(() => clearTimeout(purplePeekTimerRef.value))
  }
)

// Look at each other when typing
watch(
  () => [props.isTyping, isShowingPassword.value],
  ([typing, showing]) => {
    if (typing && !showing) {
      isLookingRef.value = true
      stateRef.isLooking = true
      applyLookAtEachOther()

      clearTimeout(lookingTimerRef.value)
      lookingTimerRef.value = setTimeout(() => {
        isLookingRef.value = false
        stateRef.isLooking = false
        purpleRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
          gsap.killTweensOf(p)
        })
      }, 800)
    } else {
      clearTimeout(lookingTimerRef.value)
      isLookingRef.value = false
      stateRef.isLooking = false
    }
  }
)

// Password state effects
watch(
  () => [isShowingPassword.value, isHidingPassword.value],
  ([showing, hiding]) => {
    if (showing) {
      applyShowPassword()
    } else if (hiding) {
      applyHidingPassword()
    }
  }
)
</script>

PixPin_2026-03-23_15-09-29.gif

登录页

安装依赖

pnpm install --save ant-design-vue @ant-design/icons-vue

src/main.js添加以下内容

import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'

app.use(Antd)

创建 src/pages/login/Index.vue登录页

<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import {
  UserOutlined,
  LockOutlined,
  EyeOutlined,
  EyeInvisibleOutlined,
} from '@ant-design/icons-vue'
import AnimatedCharacters from '../../components/animated-characters/Index.vue'
import styles from './index.module.css'

/** 模拟登录 API(仅前端逻辑,无真实请求) */
async function mockLogin(_values: { username: string; password: string }) {
  await new Promise((resolve) => setTimeout(resolve, 800))
  return { data: { access_token: 'mock_token_' + Date.now() } }
}

const loading = ref(false)
const showPassword = ref(false)
const isTyping = ref(false)
const passwordValue = ref('')
const error = ref('')

const handleLogin = async (values: { username: string; password: string }) => {
  loading.value = true
  error.value = ''
  try {
    const { data } = await mockLogin(values)
    localStorage.setItem('access_token', data.access_token)
    message.success('登录成功')
    setTimeout(() => {
      window.location.href = '/'
    }, 500)
  } catch {
    error.value = '账号或密码有误,请重新输入'
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <div :class="styles.container">
    <!-- 左侧:品牌视觉区 -->
    <div :class="styles.leftPanel">
      <div :class="styles.leftTop">
        <div :class="styles.brandMark">
          <svg width="28" height="28" viewBox="0 0 28 28" fill="none">
            <rect width="28" height="28" rx="7" fill="white" fill-opacity="0.15" />
            <path d="M7 14L12 9L17 14L12 19L7 14Z" fill="white" fill-opacity="0.9" />
            <path d="M13 14L18 9L21 12V16L18 19L13 14Z" fill="white" fill-opacity="0.5" />
          </svg>
        </div>
        <span :class="styles.brandName">Nexus</span>
      </div>

      <div :class="styles.charactersArea">
        <AnimatedCharacters
            :is-typing="isTyping"
            :show-password="showPassword"
            :password-length="passwordValue.length"
        />
      </div>

      <div :class="styles.leftFooter">
        <a href="#">帮助中心</a>
        <a href="#">隐私政策</a>
      </div>

      <div :class="styles.decorBlur1" />
      <div :class="styles.decorBlur2" />
      <div :class="styles.decorGrid" />
    </div>

    <!-- 右侧:登录表单 -->
    <div :class="styles.rightPanel">
      <div :class="styles.formWrapper">
        <div :class="styles.mobileLogo">
          <div :class="styles.mobileLogoIcon">
            <svg width="20" height="20" viewBox="0 0 28 28" fill="none">
              <path d="M7 14L12 9L17 14L12 19L7 14Z" fill="#1E40AF" fill-opacity="0.9" />
              <path d="M13 14L18 9L21 12V16L18 19L13 14Z" fill="#3B82F6" fill-opacity="0.7" />
            </svg>
          </div>
          <span>Nexus 平台</span>
        </div>

        <div :class="styles.formHeader">
          <h1 :class="styles.formTitle">登录到工作台</h1>
          <p :class="styles.formSubtitle">
            统一接入前端平台旗下所有系统
          </p>
        </div>

        <a-form
            name="login"
            @finish="handleLogin"
            autocomplete="off"
            size="large"
            :class="styles.form"
        >
          <div :class="styles.fieldLabel">账号</div>
          <a-form-item
              name="username"
              :rules="[
              { required: true, message: '请输入账号' },
              { min: 3, message: '账号长度不能少于 3 个字符' },
            ]"
          >
            <a-input
                placeholder="输入您的账号"
                @focus="isTyping = true"
                @blur="isTyping = false"
            >
              <template #prefix>
                <UserOutlined :class="styles.prefixIcon" />
              </template>
            </a-input>
          </a-form-item>

          <div :class="styles.fieldLabel">密码</div>
          <a-form-item
              name="password"
              :rules="[
              { required: true, message: '请输入密码' },
              { min: 6, message: '密码长度不能少于 6 个字符' },
            ]"
          >
            <a-input
                :type="showPassword ? 'text' : 'password'"
                placeholder="输入您的密码"
                v-model:value="passwordValue"
            >
              <template #prefix>
                <LockOutlined :class="styles.prefixIcon" />
              </template>
              <template #suffix>
                <span
                    :class="styles.eyeToggle"
                    @click="showPassword = !showPassword"
                >
                  <EyeOutlined v-if="showPassword" />
                  <EyeInvisibleOutlined v-else />
                </span>
              </template>
            </a-input>
          </a-form-item>

          <div v-if="error" :class="styles.errorBox">{{ error }}</div>

          <a-form-item :style="{ marginBottom: 0 }">
            <a-button
                type="primary"
                html-type="submit"
                :loading="loading"
                block
                :class="styles.submitBtn"
            >
              {{ loading ? '登录中...' : '登录' }}
            </a-button>
          </a-form-item>
        </a-form>

        <div :class="styles.divider">
          <span>或</span>
        </div>

        <a-button block :class="styles.googleBtn">
          飞书账号一键登录
        </a-button>

        <div :class="styles.signupRow">
          暂无账号?
          <a href="#" :class="styles.signupLink">
            联系管理员申请开通
          </a>
        </div>
      </div>
    </div>
  </div>
</template>

创建 src/pages/login/index.module.css登录页样式

.container {
    min-height: 100vh;
    display: grid;
    grid-template-columns: 1fr 1fr;
}

@media (max-width: 1024px) {
    .container {
        grid-template-columns: 1fr;
    }
}

/* ─── 左侧面板 ───────────────────────────────────────────────────────────────── */

.leftPanel {
    position: relative;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    padding: 48px;
    background: linear-gradient(145deg, #0f172a 0%, #1e3a8a 50%, #1e40af 100%);
    overflow: hidden;
}

@media (max-width: 1024px) {
    .leftPanel {
        display: none;
    }
}

.leftTop {
    position: relative;
    z-index: 20;
    display: flex;
    align-items: center;
    gap: 10px;
    font-size: 20px;
    font-weight: 700;
    color: #ffffff;
    letter-spacing: 0.5px;
}

.brandMark {
    width: 40px;
    height: 40px;
    border-radius: 10px;
    background: rgba(255, 255, 255, 0.12);
    border: 1px solid rgba(255, 255, 255, 0.2);
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    backdrop-filter: blur(8px);
}

.brandName {
    color: #ffffff;
    font-size: 20px;
    font-weight: 700;
    letter-spacing: 1px;
}

.charactersArea {
    position: relative;
    z-index: 20;
    display: flex;
    align-items: flex-end;
    justify-content: center;
    height: 500px;
}

.leftFooter {
    position: relative;
    z-index: 20;
    display: flex;
    align-items: center;
    gap: 24px;
}

.leftFooter a {
    font-size: 13px;
    color: rgba(255, 255, 255, 0.45);
    text-decoration: none;
    transition: color 0.2s;
    cursor: pointer;
}

.leftFooter a:hover {
    color: rgba(255, 255, 255, 0.85);
}

.decorBlur1 {
    position: absolute;
    top: 15%;
    right: 10%;
    width: 300px;
    height: 300px;
    background: rgba(59, 130, 246, 0.25);
    border-radius: 50%;
    filter: blur(80px);
    pointer-events: none;
    z-index: 0;
}

.decorBlur2 {
    position: absolute;
    bottom: 10%;
    left: 5%;
    width: 400px;
    height: 400px;
    background: rgba(30, 64, 175, 0.3);
    border-radius: 50%;
    filter: blur(100px);
    pointer-events: none;
    z-index: 0;
}

.decorGrid {
    position: absolute;
    inset: 0;
    background-image:
            linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
            linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
    background-size: 40px 40px;
    pointer-events: none;
    z-index: 1;
}

/* ─── 右侧面板 ───────────────────────────────────────────────────────────────── */

.rightPanel {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 32px;
    background: #ffffff;
}

.formWrapper {
    width: 100%;
    max-width: 400px;
}

.mobileLogo {
    display: none;
    align-items: center;
    justify-content: center;
    gap: 8px;
    font-size: 18px;
    font-weight: 700;
    color: #0f172a;
    margin-bottom: 48px;
}

@media (max-width: 1024px) {
    .mobileLogo {
        display: flex;
    }
}

.mobileLogoIcon {
    width: 32px;
    height: 32px;
    border-radius: 8px;
    background: #eff6ff;
    display: flex;
    align-items: center;
    justify-content: center;
}

.formHeader {
    text-align: center;
    margin-bottom: 40px;
}

.formTitle {
    font-size: 26px;
    font-weight: 700;
    letter-spacing: -0.02em;
    color: #0f172a;
    margin: 0 0 10px 0;
    line-height: 1.3;
}

.formSubtitle {
    font-size: 14px;
    color: #6b7280;
    margin: 0;
    line-height: 1.6;
}

.form :global(.ant-form-item) {
    margin-bottom: 20px;
}

.form :global(.ant-input-affix-wrapper) {
    height: 48px !important;
    background: #fafafa !important;
    border: 1px solid #e5e7eb !important;
    border-radius: 10px !important;
    transition: border-color 0.2s, box-shadow 0.2s !important;
}

.form :global(.ant-input-affix-wrapper:hover) {
    border-color: #3b82f6 !important;
}

.form :global(.ant-input-affix-wrapper:focus),
.form :global(.ant-input-affix-wrapper-focused) {
    border-color: #1e40af !important;
    box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.08) !important;
    background: #ffffff !important;
}

.form :global(.ant-input-affix-wrapper .ant-input) {
    background: transparent !important;
    font-size: 14px !important;
    color: #111827 !important;
}

.form :global(.ant-input-affix-wrapper .ant-input::placeholder) {
    color: #c0c4cc !important;
}

.form :global(.ant-form-item-explain-error) {
    font-size: 13px !important;
    margin-top: 4px !important;
}

.fieldLabel {
    font-size: 13px;
    font-weight: 500;
    color: #374151;
    margin-bottom: 6px;
    letter-spacing: 0.2px;
}

.prefixIcon {
    color: #b0b7c3;
    font-size: 15px;
}

.eyeToggle {
    color: #6b7280;
    cursor: pointer;
    font-size: 16px;
    display: flex;
    align-items: center;
    transition: color 0.2s;
}

.eyeToggle:hover {
    color: #374151;
}

.errorBox {
    padding: 10px 14px;
    font-size: 13px;
    color: #dc2626;
    background: #fef2f2;
    border: 1px solid #fecaca;
    border-radius: 8px;
    margin-bottom: 16px;
}

.submitBtn {
    height: 48px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    border-radius: 10px !important;
    background: #1e40af !important;
    border-color: #1e40af !important;
    letter-spacing: 1px;
    transition: background 0.2s, opacity 0.2s !important;
    cursor: pointer;
}

.submitBtn:hover {
    background: #1d4ed8 !important;
    border-color: #1d4ed8 !important;
    opacity: 1 !important;
}

.submitBtn:active {
    opacity: 0.85 !important;
}

.divider {
    display: flex;
    align-items: center;
    gap: 12px;
    margin: 20px 0 0;
    color: #d1d5db;
    font-size: 13px;
}

.divider::before,
.divider::after {
    content: '';
    flex: 1;
    height: 1px;
    background: #e5e7eb;
}

.divider span {
    color: #9ca3af;
    white-space: nowrap;
}

.googleBtn {
    height: 48px !important;
    font-size: 14px !important;
    border-radius: 10px !important;
    margin-top: 12px !important;
    background: #ffffff !important;
    border: 1px solid #e5e7eb !important;
    color: #374151 !important;
    transition: background 0.2s, border-color 0.2s !important;
    cursor: pointer;
}

.googleBtn:hover {
    background: #eff6ff !important;
    border-color: rgba(30, 64, 175, 0.25) !important;
    color: #1e40af !important;
}

.signupRow {
    text-align: center;
    font-size: 13px;
    color: #6b7280;
    margin-top: 28px;
}

.signupLink {
    color: #1e40af;
    font-weight: 500;
    text-decoration: none;
    cursor: pointer;
}

.signupLink:hover {
    text-decoration: underline;
    color: #1d4ed8;
}

源代码

vue2分支是Vue2 + Element-ui实现。

vue-router 5.x 文件式路由

作者 米丘
2026年3月23日 22:34

Vue Router 内置了基于文件的路由插件。会自动根据页面组件生成路由和类型,因此不再需要手动维护 routes 数组。

项目中使用文件式路由

src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router'
// 自动生成的路由(vite 插件注入)
import { routes } from 'vue-router/auto-routes'

console.log('routes', routes)
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
})

router.afterEach((to, from) => {
  document.title = to.meta.title ? `${to.meta.title} |Vite Vue3 平台 ` : 'Vite Vue3 平台'
})

export default router

vite.config.ts

import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import VueRouter from 'vue-router/vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    // 必须要在 vue 插件之前
    VueRouter({
      routesFolder: 'src/pages', // 默认 pages
      extensions: ['.vue'], // 匹配文件后缀
      dts: 'src/typed-router.d.ts', // 生成类型文件

       // 添加调试选项
      logs: true,

      // routeBlockLang: 'json5', // 路由块语言,默认 json
      importMode: 'async',
      root: process.cwd(),

      // 在配置文件写入前,手动修改路由配置(如添加全局路由守卫、调整路由元信息、过滤路由等)
      // beforeWriteFiles: (editedRoutes) => {
      //   console.log('beforeWriteFiles', editedRoutes)
      // },
      watch: true, // 开启路由块文件监听
      // 开启实验性功能
      experimental: {
        
      },
    }),
    vue(),
    vueJsx(),
    vueDevTools(),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    },
  },
})

默认情况下,此插件会检查 src/pages 文件夹中的任何 .vue 文件,并根据文件名生成相应的路由结构。

image.png

vue-router/vite 插件有哪些 options 配置?

/**
 * vue-router plugin options.
 */
export interface Options {
  /**
   * Extensions of files to be considered as pages. Cannot be empty. This allows to strip a
   * bigger part of the filename e.g. `index.page.vue` -> `index` if an extension of `.page.vue` is provided.
   * 
   * 识别为路由页面的文件后缀(不能为空)
   * @default `['.vue']`
   */
  extensions?: string[]

  /**
   * Folder(s) to scan for files and generate routes. Can also be an array if you want to add multiple
   * folders, or an object if you want to define a route prefix. Supports glob patterns but must be a folder, use
   * `extensions` and `exclude` to filter files.
   * 
   * 扫描路由文件的目录(支持多目录 / 前缀)
   *
   * @default `"src/pages"`
   */
  routesFolder?: RoutesFolder

  /**
   * Array of `picomatch` globs to ignore. Note the globs are relative to the cwd, so avoid writing
   * something like `['ignored']` to match folders named that way, instead provide a path similar to the `routesFolder`:
   * `['src/pages/ignored/**']` or use `['**/ignored']` to match every folder named `ignored`.
   * 
   * 排除的文件 / 目录
   * @default `[]`
   */
  exclude?: string[] | string

  /**
   * Pattern to match files in the `routesFolder`. Defaults to `*\/*` plus a
   * combination of all the possible extensions, e.g. `*\/*.{vue,md}` if
   * `extensions` is set to `['.vue', '.md']`. This is relative to the {@link
   * RoutesFolderOption['src']} and
   * 
   * 匹配的文件模式
   *
   * @default `['*\/*']`
   */
  filePatterns?: string[] | string

  /**
   * Method to generate the name of a route. It's recommended to keep the default value to guarantee a consistent,
   * unique, and predictable naming.
   * 自定义路由名称生成逻辑
   */
  getRouteName?: (node: TreeNode) => string

  /**
   * Allows extending a route by modifying its node, adding children, or even deleting it. This will be invoked once for
   * each route.
   * 
   * 扩展 / 修改单个路由(增删改属性)
   *
   * @param route - {@link EditableTreeNode} of the route to extend
   */
  extendRoute?: (route: EditableTreeNode) => _Awaitable<void>

  /**
   * Allows to do some changes before writing the files. This will be invoked **every time** the files need to be written.
   *
   * 写入路由文件前修改整体路由树
   * @param rootRoute - {@link EditableTreeNode} of the root route
   */
  beforeWriteFiles?: (rootRoute: EditableTreeNode) => _Awaitable<void>

  /**
   * Defines how page components should be imported. Defaults to dynamic imports to enable lazy loading of pages.
   * 
   * 页面组件的导入方式(同步 / 异步)
   * @default `'async'`
   */
  importMode?: 'sync' | 'async' | ((filepath: string) => 'sync' | 'async')

  /**
   * Root of the project. All paths are resolved relatively to this one.
   * 
   * 项目根目录(所有路径相对此目录)
   * @default `process.cwd()`
   */
  root?: string

  /**
   * Language for `<route>` blocks in SFC files.
   * SFC 中 <route> 块的解析语言
   * @default `'json5'`
   */
  routeBlockLang?: 'yaml' | 'yml' | 'json5' | 'json'

  /**
   * Should we generate d.ts files or ont. Defaults to `true` if `typescript` is installed. Can be set to a string of
   * the filepath to write the d.ts files to. By default it will generate a file named `typed-router.d.ts`.
   * 是否生成类型文件(指定路径)
   * @default `true`
   */
  dts?: boolean | string

  /**
   * Allows inspection by vite-plugin-inspect by not adding the leading `\0` to the id of virtual modules.
   * 兼容 vite-plugin-inspect 调试
   * @internal
   */
  _inspect?: boolean

  /**
   * Activates debug logs.
   * 开启调试日志(查看路由生成过程)
   */
  logs?: boolean

  /**
   * @inheritDoc ParseSegmentOptions
   */
  pathParser?: ParseSegmentOptions

  /**
   * Whether to watch the files for changes.
   *
   * Defaults to `true` unless the `CI` environment variable is set.
   * 监听文件变化自动更新路由
   * @default `!process.env.CI`
   */
  watch?: boolean

  /**
   * Experimental options. **Warning**: these can change or be removed at any time, even it patch releases. Keep an eye
   * on the Changelog.
   */
  experimental?: {
    /**
     * (Vite only). File paths or globs where loaders are exported. This will be used to filter out imported loaders and
     * automatically re export them in page components. You can for example set this to `'src/loaders/**\/*'` (without
     * the backslash) to automatically re export any imported variable from files in the `src/loaders` folder within a
     * page component.
     * 自动导出数据加载器
     */
    autoExportsDataLoaders?: string | string[]

    /**
     * Enable experimental support for the new custom resolvers and allows
     * defining custom param matchers.
     * 自定义路径解析规则(如参数命名、可选参数标识)
     */
    paramParsers?: boolean | ParamParsersOptions
  }
}

实现 404 页面

要创建通配符路由,需要参数名称前添加 3 个点 (...),例如 src/pages/[...path].vue 将创建一个具有以下路径的路由:/:path(.*)。这将匹配任何路由。

src/pages/[...path].vue

<template>
  <div class="not-found-container">
    <div class="not-found-content">
      <div class="error-code">
        <span class="digit">4</span>
        <span class="digit">0</span>
        <span class="digit">4</span>
      </div>
      <h1 class="error-title">页面不存在</h1>
      <p class="error-description">抱歉,您访问的页面可能已被删除、移动或输入了错误的地址。</p>
      <router-link to="/home" class="back-home-btn"> 返回首页 </router-link>
    </div>
  </div>
</template>

<script lang="ts" setup>
defineOptions({
  name: 'NotFoundView',
})
</script>

image.png

嵌套路由

嵌套路由是通过在文件夹旁边定义一个 .vue 文件同名的来自动定义的。如果创建了 src/pages/role/index.vue 和 src/pages/role.vue 组件,src/pages/role/index.vue 将在 src/pages/role.vue 的 <RouterView> 中渲染。

src/pages/
├── role/
│   └── index.vue
└── roles.vue

image.png

动态路由

  1. 通过用括号包裹 参数名称 来添加 路由参数,例如 src/pages/users/[id].vue 将创建一个具有以下路径的路由:/users/:id
  2. 通过用额外的一对括号包裹 参数名称 来创建 可选参数,例如 src/pages/users/[[id]].vue 将创建一个具有以下路径的路由:/users/:id?
  3. 通过在右括号后添加加号 (+) 来创建 可重复参数,例如 src/pages/articles/[slugs]+.vue 将创建一个具有以下路径的路由:/articles/:slugs+

可选参数 src/pages/home/user.create.[[id]].vue

image.png

image.png

image.png

命名视图

通过在文件名后附加 @ + 名称来定义 命名视图

src/pages/home/dashboard.vu

<template>
  <div>
    <router-view />
    <router-view name="logs" />
  </div>
</template>
<script lang="ts" setup>
defineOptions({
  name: "HomeDashboardView",
});
</script>

src/pages/home/dashboard.vue 会渲染src/pages/home/dashboard/index.vuesrc/pages/home/dashboard/index@logs.vue组件的内容。

image.png

路由组

路由组(Route Groups)是一个用于纯粹组织代码的功能:用 () 包裹的文件夹名称不会出现在最终的路由路径中,可以按功能或模块自由归类页面文件,而不影响 URL 结构。

简单来说,路由组就是不会出现在 URL 中的文件夹。它的语法是在文件夹名称外加上括号,例如 (admin)

image.png

组件内如何修改路由配置?

直接在页面组件文件中覆盖路由配置。插件会拾取这些更改并反映在生成的 typed-router.d.ts 文件中。

definePage

用 definePage() 宏修改和扩展任何页面组件。这对于添加 meta 信息或修改路由对象很有用。

<script lang="ts" setup>
definePage({
  redirect: "/home",
  name: "layout",
  meta: {
    title: "Home",
  },
});
defineOptions({
  name: "IndexView",
});
</script>

image.png

SFC <route> 自定义块

默认情况下,语言是 JSON5(更灵活的 JSON 版本),但也支持 yaml、yml 和 JSON。

<route lang="yaml">
name: "role_index"
</route>
<route lang="json">
{
  "name": "user_details"
}
</route>

image.png

最后

  1. vue-router 官网
  2. vite8 + vue3 文件式路由 demo

别再瞎写 Cesium 可视化!热力图 + 四色图源码全公开,项目直接复用!

作者 李剑一
2026年3月24日 09:26

之前 Cesium 中一直围绕着园区进行开发,现在增加地图的部分,后面还会增加公共组件、统计图等等操作。

智慧园区、区域管控等三维GIS场景中,热力图四色图是两大最常用的可视化展示方案。

image.png

热力图直观展示密度、热度、强度分布,四色图清晰区分行政区域、功能分区、风险等级,两者搭配能让三维可视化效果和实用性直接拉满!

热力图

使用 Cesium 纯原生实现,无第三方依赖。

采用多级渐变色的效果,径向渐变过渡显得更加自然。

image.png

同时支持显示/隐藏切换,内存自动释放。

完整代码

const createHeatMap = () => {
    if (heatMapRef.value) {
        cesiumViewer.value.scene.primitives.remove(heatMapRef.value);
        heatMapRef.value = null;
    }
    
    // 热力图数据点(经纬度+权重值)
    const cameraList = [
        { longitude: 117.105914, latitude: 36.437846, height: 15 },
        { longitude: 117.105842, latitude: 36.437532, height: 14 },
        // 此处可替换为你的业务数据...
    ];
    
    // 创建Billboard集合
    const billboardCollection = new Cesium.BillboardCollection({
        scene: cesiumViewer.value.scene
    });
    
    // 计算最大权重,用于归一化
    const maxHeight = Math.max(...cameraList.map(c => c.height));
    
    // 热力图8级渐变色配置
    const heatColors = {
        veryLow: new Cesium.Color(0.0, 0.0, 1.0, 0.2),    // 深蓝
        low: new Cesium.Color(0.0, 0.5, 1.0, 0.3),        // 蓝色
        mediumLow: new Cesium.Color(0.0, 1.0, 1.0, 0.4),  // 青色
        medium: new Cesium.Color(0.5, 1.0, 0.5, 0.5),     // 黄绿色
        mediumHigh: new Cesium.Color(1.0, 1.0, 0.0, 0.6), // 黄色
        high: new Cesium.Color(1.0, 0.7, 0.0, 0.7),       // 橙色
        veryHigh: new Cesium.Color(1.0, 0.3, 0.0, 0.8),   // 橙红
        extreme: new Cesium.Color(1.0, 0.0, 0.0, 0.9)     // 红色
    };
    
    // 遍历数据生成热力点
    cameraList.forEach(camera => {
        const normalizedHeight = camera.height / maxHeight;
        let color, radius, alpha;
        
        // 8级密度分级,自动匹配颜色、半径、透明度
        if (normalizedHeight < 0.125) {
            color = heatColors.veryLow; radius = 40; alpha = 0.25;
        } else if (normalizedHeight < 0.25) {
            const intensity = (normalizedHeight - 0.125) / 0.125;
            color = Cesium.Color.fromBytes(0, Math.round(128 * intensity), 255, Math.round(255 * 0.3));
            radius = 40 + intensity * 15; alpha = 0.3;
        } else if (normalizedHeight < 0.375) {
            const intensity = (normalizedHeight - 0.25) / 0.125;
            color = Cesium.Color.fromBytes(0, 128 + Math.round(127 * intensity), 255 - Math.round(127 * intensity), Math.round(255 * 0.35));
            radius = 55 + intensity * 15; alpha = 0.35;
        } else if (normalizedHeight < 0.5) {
            const intensity = (normalizedHeight - 0.375) / 0.125;
            color = Cesium.Color.fromBytes(Math.round(128 * intensity), 255, 128 - Math.round(128 * intensity), Math.round(255 * 0.4));
            radius = 70 + intensity * 20; alpha = 0.4;
        } else if (normalizedHeight < 0.625) {
            const intensity = (normalizedHeight - 0.5) / 0.125;
            color = Cesium.Color.fromBytes(128 + Math.round(127 * intensity), 255, 0, Math.round(255 * 0.5));
            radius = 90 + intensity * 25; alpha = 0.5;
        } else if (normalizedHeight < 0.75) {
            const intensity = (normalizedHeight - 0.625) / 0.125;
            color = Cesium.Color.fromBytes(255, 255 - Math.round(178 * intensity), 0, Math.round(255 * 0.6));
            radius = 115 + intensity * 30; alpha = 0.6;
        } else if (normalizedHeight < 0.875) {
            const intensity = (normalizedHeight - 0.75) / 0.125;
            color = Cesium.Color.fromBytes(255, 77 - Math.round(77 * intensity), 0, Math.round(255 * 0.7));
            radius = 145 + intensity * 35; alpha = 0.7;
        } else {
            const intensity = (normalizedHeight - 0.875) / 0.125;
            color = Cesium.Color.fromBytes(255, 0, 0, Math.round(255 * 0.8));
            radius = 180 + intensity * 40; alpha = 0.8;
        }
        
        // 多层圆形叠加,实现渐变光晕效果
        const numCircles = 3;
        for (let i = 0; i < numCircles; i++) {
            const circleRadius = radius * (0.7 + i * 0.15);
            const circleAlpha = alpha * (0.8 - i * 0.2);
            
            // Canvas绘制径向渐变圆形
            const canvas = document.createElement('canvas');
            canvas.width = 256; canvas.height = 256;
            const context = canvas.getContext('2d');
            const gradient = context.createRadialGradient(128,128,0,128,128,128);
            
            gradient.addColorStop(0, `rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${circleAlpha})`);
            gradient.addColorStop(0.7, `rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${circleAlpha * 0.5})`);
            gradient.addColorStop(1, `rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, 0)`);
            
            context.fillStyle = gradient;
            context.beginPath();
            context.arc(128,128,128,0,Math.PI*2);
            context.fill();
            
            // 添加到场景
            billboardCollection.add({
                position: Cesium.Cartesian3.fromDegrees(camera.longitude, camera.latitude, camera.height),
                image: canvas,
                width: circleRadius * 2,
                height: circleRadius * 2,
                scaleByDistance: new Cesium.NearFarScalar(100,1.0,1000,0.5),
                translucencyByDistance: new Cesium.NearFarScalar(100,1.0,500,0.3),
            });
        }
    });
    
    cesiumViewer.value.scene.primitives.add(billboardCollection);
    heatMapRef.value = billboardCollection;
    cesiumViewer.value.scene.requestRender();
    console.log('✅ 热力图创建成功');
};

加载GeoJson四色图实现

使用 fetch 加载标准GeoJson行政区划/面数据,数据可以从这个地址下载: datav.aliyun.com/portal/scho…

image.png

这里采用的是四色循环渲染,如果想要多种颜色也是一样的。

完整代码

const createColorMap = () => {
    // 已存在则先移除
    if (colorMapRef.value) {
        cesiumViewer.value.dataSources.remove(colorMapRef.value);
        colorMapRef.value = null;
    }
    
    // 加载GeoJson区域数据
    fetch('/json/济南市.geojson')
        .then(res => res.json())
        .then(geojsonData => {
            // 四色图配色
            const colorMap = [
                new Cesium.Color(0.0, 0.0, 1.0, 0.7),   // 蓝
                new Cesium.Color(0.0, 1.0, 0.0, 0.7),   // 绿
                new Cesium.Color(1.0, 1.0, 0.0, 0.7),   // 黄
                new Cesium.Color(1.0, 0.0, 0.0, 0.7),   // 红
            ];
            
            const dataSource = new Cesium.GeoJsonDataSource();
            dataSource.load(geojsonData, {
                stroke: Cesium.Color.WHITE,
                fill: colorMap[0],
                strokeWidth: 2,
                clampToGround: true
            }).then(ds => {
                const entities = ds.entities.values;
                let colorIndex = 0;
                
                entities.forEach(entity => {
                    if (entity.polygon) {
                        // 四色循环赋值
                        entity.polygon.material = colorMap[colorIndex % 4];
                        entity.polygon.outline = true;
                        entity.polygon.outlineColor = Cesium.Color.WHITE;
                        entity.polygon.outlineWidth = 2;
                        
                        // 拉伸高度(立体效果)
                        entity.polygon.extrudedHeight = 50;
                        entity.polygon.height = 0;
                        
                        colorIndex++;
                    }
                });
                
                cesiumViewer.value.dataSources.add(ds);
                colorMapRef.value = ds;
                
                // 自动飞掠到视图
                cesiumViewRef.value.flyToLocation({
                    longitude: 117.12,
                    latitude: 36.67,
                    height: 400000,
                    duration: 3,
                    heading: 0,
                    pitch: -90,
                });
                
                console.log('✅ 四色图创建成功');
            });
        });
};

总结

热力图+四色图是Cesium三维GIS可视化中最常用、最实用的两大功能。

这里提供的代码均为原生实现、生产环境可用,无需引入任何第三方库,直接复制即可运行。

热力图效果需要大量的数据才能看出效果,如果是人造数据我建议通过AI生成查看效果。

四色图这里也可以增加颜色显示,看起来更丰富。

异步组件与 Suspense:如何优雅地处理加载状态并优化首屏加载?

作者 wuhen_n
2026年3月24日 07:46

前言

如果我们正在打开一个后台管理系统:

  • 我们点击了"数据分析"菜单,但是页面白屏,什么都没发生
  • 我们可以怀疑一下自己:"我点了吗?" 于是又点了一下
  • 5秒后,页面突然跳出来,吐槽一句:"这什么垃圾系统?"

这就是没有处理好加载状态的结果。用户不知道页面在加载,以为系统坏了。而我们要解决的问题,就是让加载过程变得可见、可预期、可恢复

为什么需要异步组件?

传统路由懒加载的问题

// 传统路由懒加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('./views/Dashboard.vue')  // 2.5MB
  },
  {
    path: '/analysis',
    component: () => import('./views/DataAnalysis.vue')  // 3.2MB
  }
]

上述代码乍一看没什么问题,但如果遇上网络延迟,加载缓慢等情况,再点击菜单后,就会出现页面白屏的问题,用户也不知道页面正在加载...

异步组件的解决方案

import { defineAsyncComponent } from 'vue'

const AnalysisPage = defineAsyncComponent({
  loader: () => import('./views/DataAnalysis.vue'),
  loadingComponent: LoadingSpinner,  // 加载时显示
  errorComponent: ErrorDisplay,       // 出错时显示
  delay: 200,                         // 延迟200ms显示loading,避免闪烁
  timeout: 5000,                      // 5秒超时
  onError(error, retry, fail, attempts) {
    if (attempts <= 3) {
      retry()  // 重试
    } else {
      fail()
    }
  }
})

异步组件完全指南

基础用法

最简单的异步组件

import { defineAsyncComponent } from 'vue'

const SimpleAsync = defineAsyncComponent(() => 
  import('./components/HeavyComponent.vue')
)

完整配置的异步组件

import { defineAsyncComponent } from 'vue'

const FullAsync = defineAsyncComponent({
  loader: () => import('./components/HeavyComponent.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  delay: 200,
  timeout: 5000,
  suspensible: true,
  
  onError(error, retry, fail, attempts) {
    if (attempts <= 3 && error.message.includes('network')) {
      console.log(`重试第 ${attempts} 次...`)
      retry()
    } else {
      fail()
    }
  }
})

加载组件的设计

<!-- LoadingSpinner.vue -->
<template>
  <div class="loading-container">
    <div class="spinner"></div>
    <p class="loading-text">加载中...</p>
  </div>
</template>

<style scoped>
.loading-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 200px;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 3px solid #f3f3f3;
  border-top: 3px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
</style>

错误组件的设计

<!-- ErrorDisplay.vue -->
<template>
  <div class="error-container">
    <div class="error-icon">⚠️</div>
    <h3>加载失败</h3>
    <p>{{ error?.message || '未知错误' }}</p>
    <button @click="retry" class="retry-btn">重试</button>
  </div>
</template>

<script setup>
import { defineProps, defineEmits } from 'vue'

const props = defineProps({
  error: Object
})

const emit = defineEmits(['retry'])

const retry = () => {
  emit('retry')
}
</script>

Suspense - 管理多个异步依赖

什么是 Suspense?

<template>
  <Suspense>
    <!-- 默认插槽:所有异步依赖加载完成后显示 -->
    <template #default>
      <AsyncComponent />
      <AnotherAsyncComponent />
    </template>
    
    <!-- fallback插槽:加载过程中显示 -->
    <template #fallback>
      <LoadingSpinner />
    </template>
  </Suspense>
</template>

工作原理

页面渲染
    ↓
遇到 <Suspense>
    ↓
检查内部组件是否都准备就绪
    ↓
有未完成的异步依赖?
    ├─ 是 → 显示 fallback
    │        ↓
    │     等待所有依赖完成
    │        ↓
    │     切换到 default
    │
    └─ 否 → 直接显示 default

在 setup 中使用 async/await

<!-- AsyncUserProfile.vue -->
<script setup>
import { ref } from 'vue'

// 直接在 setup 中使用 await
// 这个组件会自动触发 Suspense
const user = await fetch('/api/user').then(r => r.json())
const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json())

// 所有数据都加载完成后才渲染
</script>

<template>
  <div>
    <h2>{{ user.name }}</h2>
    <div v-for="post in posts" :key="post.id">
      {{ post.title }}
    </div>
  </div>
</template>

并行数据加载

<!-- Dashboard.vue -->
<script setup>
// 并行加载,提高效率
const [userStats, salesData, recentOrders] = await Promise.all([
  fetch('/api/stats').then(r => r.json()),
  fetch('/api/sales').then(r => r.json()),
  fetch('/api/orders').then(r => r.json())
])
</script>

<template>
  <div class="dashboard">
    <StatsCard :data="userStats" />
    <SalesChart :data="salesData" />
    <OrderList :orders="recentOrders" />
  </div>
</template>

实战案例

案例一:路由级 Suspense

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component }">
    <Suspense>
      <template #default>
        <component :is="Component" />
      </template>
      
      <template #fallback>
        <div class="page-loading">
          <div class="spinner"></div>
          <p>加载页面中...</p>
        </div>
      </template>
    </Suspense>
  </router-view>
</template>

案例二:骨架屏

<template>
  <Suspense>
    <template #default>
      <UserProfile :user-id="userId" />
    </template>
    
    <template #fallback>
      <!-- 骨架屏:形状匹配实际内容 -->
      <div class="profile-skeleton">
        <div class="skeleton-avatar"></div>
        <div class="skeleton-info">
          <div class="skeleton-line w-32"></div>
          <div class="skeleton-line w-48"></div>
          <div class="skeleton-line w-40"></div>
        </div>
      </div>
    </template>
  </Suspense>
</template>

<style scoped>
.profile-skeleton {
  display: flex;
  gap: 20px;
  padding: 20px;
}

.skeleton-avatar {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

.skeleton-line {
  height: 16px;
  margin-bottom: 12px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
}

.w-32 { width: 128px; }
.w-40 { width: 160px; }
.w-48 { width: 192px; }

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
</style>

案例三:嵌套 Suspense

<template>
  <!-- 外层:整个页面的加载状态 -->
  <Suspense>
    <template #fallback>
      <PageSkeleton />
    </template>
    
    <template #default>
      <div class="page">
        <Header />
        
        <!-- 内层:局部区域的加载状态 -->
        <Suspense>
          <template #fallback>
            <ContentSkeleton />
          </template>
          
          <template #default>
            <AsyncContent />
          </template>
        </Suspense>
        
        <Footer />
      </div>
    </template>
  </Suspense>
</template>

案例四:预加载

<script setup>
import { defineAsyncComponent, shallowRef } from 'vue'

const showModal = ref(false)
const ModalComponent = shallowRef()

// 鼠标悬停时预加载
const preloadModal = () => {
  ModalComponent.value = defineAsyncComponent(() => 
    import('./components/Modal.vue')
  )
}

// 点击时显示
const openModal = () => {
  showModal.value = true
}
</script>

<template>
  <button 
    @mouseenter="preloadModal"
    @click="openModal"
  >
    打开弹窗
  </button>
  
  <ModalComponent v-if="showModal" />
</template>

性能优化策略

优先级加载

// 定义加载优先级
const loadQueue = {
  critical: [],   // 首屏必需,立即加载
  normal: [],     // 普通优先级
  idle: []        // 空闲时加载
}

function loadWithPriority(loader, priority = 'normal') {
  if (priority === 'critical') {
    // 立即加载
    return defineAsyncComponent(loader)
  }
  
  // 存入队列
  loadQueue[priority].push(loader)
  
  // 返回占位组件
  return defineAsyncComponent({
    loader: () => new Promise(resolve => {
      // 稍后加载
      setTimeout(() => loader().then(resolve), 0)
    })
  })
}

预连接优化

<!-- index.html -->
<head>
  <!-- 预连接到可能用到的域名 -->
  <link rel="preconnect" href="https://api.example.com">
  <link rel="preconnect" href="https://cdn.example.com">
  
  <!-- DNS 预解析 -->
  <link rel="dns-prefetch" href="https://analytics.example.com">
  
  <!-- 预加载关键资源 -->
  <link rel="preload" href="/critical.js" as="script">
</head>

组件缓存

// 缓存已加载的组件
const componentCache = new Map()

function cachedAsyncComponent(path) {
  if (componentCache.has(path)) {
    return componentCache.get(path)
  }
  
  const component = defineAsyncComponent(() => 
    import(path).then(comp => {
      componentCache.set(path, comp)
      return comp
    })
  )
  
  componentCache.set(path, component)
  return component
}

错误处理与降级

完整的错误处理

<template>
  <Suspense @fallback="handleFallback">
    <template #default>
      <AsyncComponent />
    </template>
    
    <template #fallback>
      <LoadingSpinner />
    </template>
  </Suspense>
  
  <div v-if="error" class="error-boundary">
    <ErrorIcon />
    <h3>加载失败</h3>
    <p>{{ error.message }}</p>
    <button @click="retry">重试</button>
    <button @click="useFallback">使用基础版本</button>
  </div>
</template>

<script setup>
import { ref, onErrorCaptured } from 'vue'
import BaseVersion from './BaseVersion.vue'

const error = ref(null)
const useBase = ref(false)

onErrorCaptured((err) => {
  error.value = err
  return false  // 阻止继续传播
})

function retry() {
  error.value = null
  window.location.reload()
}

function useFallback() {
  useBase.value = true
  error.value = null
}
</script>

自动重试机制

function withRetry(loader, maxRetries = 3) {
  return defineAsyncComponent({
    loader: () => {
      return new Promise((resolve, reject) => {
        let attempts = 0
        
        function attempt() {
          loader()
            .then(resolve)
            .catch(error => {
              attempts++
              if (attempts < maxRetries) {
                // 指数退避
                const delay = 1000 * Math.pow(2, attempts - 1)
                console.log(`重试 ${attempts}/${maxRetries},等待 ${delay}ms`)
                setTimeout(attempt, delay)
              } else {
                reject(error)
              }
            })
        }
        
        attempt()
      })
    },
    timeout: 10000
  })
}

性能监控

加载时间监控

// composables/useLoadMonitor.js
export function useLoadMonitor(componentName) {
  const startTime = performance.now()
  
  onMounted(() => {
    const loadTime = performance.now() - startTime
    
    // 上报性能数据
    console.log(`[性能] ${componentName} 加载时间: ${loadTime.toFixed(2)}ms`)
    
    if (loadTime > 3000) {
      console.warn(`⚠️ ${componentName} 加载时间过长: ${loadTime.toFixed(2)}ms`)
    }
  })
}

用户体验指标

指标 目标值 含义
FCP < 1.5s 第一个内容出现的时间
LCP < 2.5s 主要内容出现的时间
TTI < 3.5s 页面可交互的时间
加载反馈 < 100ms 点击后显示加载状态的时间

最佳实践清单

实施检查清单

  • 大组件使用异步加载
  • 配置 loadingComponent 和 errorComponent
  • 设置合理的 delay(200ms)避免闪烁
  • 设置 timeout(5-10秒)避免无限等待
  • 关键路径考虑预加载
  • 设计匹配布局的骨架屏
  • 实现错误重试机制
  • 监控加载性能指标

决策树

组件是否需要异步加载?
├─ 否 → 普通组件
└─ 是 → 是否在首屏?
    ├─ 是 → 考虑预加载
    └─ 否 → 是否需要加载状态?
        ├─ 是 → 使用 Suspense
        └─ 否 → 使用 defineAsyncComponent

结语

好的用户体验 = 立即反馈 + 预期符合 + 可恢复!

当我们优化完加载体验,用户不再抱怨"页面卡",而是觉得"很流畅",那就说明我们成功了!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

昨天以前首页

Vue - @ 事件指南:原生 / 内置 / 自定义事件全解析

2026年3月22日 13:36

前言

在 Vue 开发中,@v-on 指令的简写,是绑定事件监听的核心语法。很多新手容易混淆不同类型的 @ 事件用法,本文整理了 Vue 中所有常用的 @ 事件类型,包括原生 DOM 事件、内置组件事件、自定义事件,以及提升开发效率的事件修饰符,看完就能直接上手用!

一、 Vue @ 事件的核心分类

Vue 中的 @ 事件本质是对 DOM 事件 / 组件事件的封装,核心分为三大类:

  • 原生 DOM 事件:浏览器自带的基础交互事件
  • Vue 内置组件事件:Vue 官方组件专属的状态监听事件
  • 自定义事件:组件间通信的核心自定义事件

二、原生 DOM 事件

这类事件是浏览器原生支持的 DOM 事件,Vue 可直接通过 @ 绑定,覆盖绝大部分交互场景,按类型整理如下:

1. 鼠标事件

事件语法 说明 常用场景
@click 点击事件(最常用) 按钮点击、卡片跳转
@dblclick 双击事件 列表项编辑、文件重命名
@mouseenter 鼠标进入(不冒泡) 悬浮提示、菜单展开
@mouseleave 鼠标离开(不冒泡) 悬浮提示隐藏、菜单收起
@mousemove 鼠标移动 拖拽跟随、坐标监听
@mousedown 鼠标按下 拖拽开始、按住触发
@mouseup 鼠标松开 拖拽结束、松开停止
@contextmenu 右键菜单事件 自定义右键菜单

2. 键盘事件

事件语法 说明 注意点
@keydown 键盘按下时触发 可监听组合键(如 @keydown.ctrl.s
@keyup 键盘松开时触发 常用 @keyup.enter 监听回车
@keypress 键盘按压时触发 已逐步废弃,推荐用 keydown 替代

3. 表单事件

事件语法 说明 触发时机对比
@input 输入框内容变化 实时触发(每输入一个字符都触发)
@change 表单值变化 失去焦点 / 选择完成后触发(如下拉框选值)
@submit 表单提交事件 点击提交按钮 / 按回车触发
@focus 元素获取焦点 输入框激活、下拉框展开
@blur 元素失去焦点 输入框失活、表单校验

4. 移动端触摸事件

事件语法 说明 适用场景
@touchstart 触摸开始 移动端点击、滑动开始
@touchend 触摸结束 移动端点击完成、滑动结束

5. 页面 / 窗口事件

事件语法 说明 优化建议
@scroll 滚动事件 监听页面滚动加载、导航栏吸顶
@resize 窗口大小变化 响应式布局适配、画布重绘

6.使用示例

<template>
  <div>
    <!-- 点击事件 -->
    <button @click="handleClick">普通点击</button>
    <!-- 键盘事件(监听回车) -->
    <input @keyup.enter="handleSearch" placeholder="按回车搜索" />
    <!-- 表单输入事件 -->
    <input @input="handleInput" @blur="handleBlur" placeholder="实时输入监听" />
  </div>
</template>

<script setup>
const handleClick = () => console.log('按钮被点击');
const handleSearch = () => console.log('执行搜索');
const handleInput = (e) => console.log('实时输入:', e.target.value);
const handleBlur = () => console.log('输入框失活,可做校验');
</script>


三、 Vue 内置组件事件:监听生命周期

Vue 的内置组件(如动画、路由)拥有自己独特的“生命周期事件”,让我们能精准控制交互细节。

内置组件 常用事件 触发时机
<transition> @before-enter / @enter 进入动画开始前与执行中
@after-enter 动画完全结束,常用于清理工作
@leave / @after-leave 离开动画的相关节点
<router-link> @click 点击跳转(Vue Router 内部处理)
@navigate (Vue Router 4+) 导航正式开始时触发

四、 自定义事件:父子通信核心

自定义事件是 Vue 父子组件通信的重要方式,子组件通过 emit 触发事件,父组件通过 @ 监听事件并接收参数。

  1. 子组件触发:使用 emit 抛出事件和数据。

  2. 父组件监听:通过 @ 绑定回调。

<!-- 子组件 Child.vue -->
<template>
  <button @click="sendData">向父组件传值</button>
</template>

<script setup>
  // 定义可触发的自定义事件
  const emit = defineEmits(['custom-event']);

  const sendData = () => {
    // 触发事件并传递参数
    emit('custom-event', { name: 'Vue', version: '3.x' });
  };
</script>

<!-- 父组件 Parent.vue -->
<template>
  <!-- 监听子组件自定义事件 -->
  <Child @custom-event="handleCustomEvent" />
</template>

<script setup>
  import Child from './Child.vue';

  const handleCustomEvent = (data) => {
    console.log('接收子组件数据:', data); // 输出:{ name: 'Vue', version: '3.x' }
  };
</script>


五、 扩展:事件修饰符

Vue 提供事件修饰符简化事件处理逻辑,无需手动调用 e.preventDefault()/e.stopPropagation(),常用修饰符如下:

1. 流程控制

  • .stop阻止冒泡。相当于 e.stopPropagation()
  • .prevent阻止默认行为。常用于 <a> 标签和 <form> 提交。
  • .capture:使用捕获模式触发事件。

2. 触发频率与性能

  • .once只触发一次。之后再点击将失效。

  • .passive提升性能(移动端必用)

3. 按键与鼠标修饰符

  • .enter / .esc / .space:特定按键触发。
  • .left / .right / .middle:限制特定的鼠标按键。

ESLint + Prettier + Husky + lint-staged:建立自动化的高效前端工作流

作者 wuhen_n
2026年3月21日 05:36

前言

在团队协作中,代码规范往往是一个容易引发争议却又不得不解决的问题。每个人都有自己的编码习惯,有人喜欢加分号,有人不喜欢;有人用两个空格缩进,有人用四个;有人变量命名用 camelCase,有人用 snake_case。这些差异在 Code Review 时往往演变成无休止的争论,消耗着团队的宝贵时间。

这就是为什么要建立自动化代码规范工作流——让工具做工具擅长的事,让人做人擅长的事。

为什么需要自动化代码规范?

没有规范带来的问题

// 团队成员A写的代码
function fetchData(){
let result = getData()
return result
}

// 团队成员B写的代码
function fetchData() {
  const result = getData();
  return result;
}

上述两段代码看起来差不多,但:

  1. 格式不一致(缩进、空格、分号)
  2. 变量命名风格不同
  3. Code Review 时会争论这些细节

有了自动化工具之后

// 不管我们怎么写,保存时自动变成统一格式
function fetchData() {
  const result = getData()
  return result
}
// 提交时自动检查,有问题就拦截
// 再也不用手动改格式了

自动化工作流的价值

传统流程:
写代码 → 手动检查 → 提交 → Code Review → 发现问题 → 修改 → 再次提交

自动化流程:
写代码 → 保存时自动格式化 → 提交时自动检查 → 提交成功
                        ↓
                    发现问题自动拦截

收益:
- 减少 90% 的代码风格争论
- 提前发现 70% 的潜在错误
- Code Review 时间缩短 50%
- 新人融入时间减少 60%

工具链全景图

四大工具的分工

ESLint:代码质量检查

  • 发现潜在错误(未定义变量、未使用变量)
  • 强制最佳实践(使用 === 代替 ==)
  • 统一代码风格(但能力有限)

Prettier:代码美容师

  • 统一代码风格(空格、换行、引号)
  • 专注格式化,不做质量检查

Husky:看门人

  • 在 Git 操作时触发脚本
  • 确保提交前代码符合规范

lint-staged:高效助手

  • 只检查即将提交的文件
  • 避免检查整个项目,提高效率

工作流程示意图

开发阶段
┌─────────────────┐
│  VS Code 编辑器 │
│  - 保存时格式化 │
│  - 实时错误提示 │
└────────┬────────┘
         ↓
Git 提交阶段
┌─────────────────┐
│   git commit    │
└────────┬────────┘
         ↓
Husky 触发 pre-commit
┌─────────────────┐
│  执行 lint-staged│
└────────┬────────┘
         ↓
lint-staged 检查暂存区
┌─────────────────┐
│ 1. 运行 ESLint  │
│ 2. 运行 Prettier│
└────────┬────────┘
    有问题?→ 拦截提交
         ↓
    没问题 → 提交成功

ESLint - 代码质量守门员

什么是 ESLint?

ESLint 就像考试时的阅卷老师,专门帮我们找出代码中的"错误"和"不规范":

// 1. 未使用的变量
let unusedVar = '没人用我'  // ESLint: 'unusedVar' is defined but never used

// 2. 未定义的变量
console.log(notDefined)  // ESLint: 'notDefined' is not defined

// 3. 不安全的比较
if (count == 1) {  // ESLint: Expected '===' and instead saw '=='
  // ...
}

// 4. 重复定义
let name = '张三'
let name = '李四'  // ESLint: 'name' is already defined

安装和初始化

# 安装 ESLint
npm install eslint --save-dev

# 初始化配置
npx eslint --init

# 交互式选择:
# - How would you like to use ESLint? → To check syntax and find problems
# - What type of modules does your project use? → JavaScript modules (import/export)
# - Which framework does your project use? → Vue.js
# - Does your project use TypeScript? → Yes
# - Where does your code run? → Browser
# - What format do you want your config file to be in? → JavaScript

Vue 3 + TypeScript 项目的最佳配置

// .eslintrc.js
module.exports = {
  root: true,
  env: {
    browser: true,
    es2021: true,
    node: true,
    'vue/setup-compiler-macros': true
  },
  extends: [
    'eslint:recommended',
    'plugin:vue/vue3-recommended',  // 使用推荐规则
    'plugin:@typescript-eslint/recommended',
    'plugin:prettier/recommended'    // 整合 Prettier
  ],
  parser: 'vue-eslint-parser',
  parserOptions: {
    ecmaVersion: 'latest',
    parser: '@typescript-eslint/parser',
    sourceType: 'module',
    extraFileExtensions: ['.vue']
  },
  plugins: ['vue', '@typescript-eslint'],
  rules: {
    // 关闭与 Prettier 冲突的规则
    'vue/max-attributes-per-line': 'off',
    'vue/singleline-html-element-content-newline': 'off',
    'vue/html-self-closing': 'off',
    
    // 自定义规则
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    
    // Vue 3 推荐
    'vue/multi-word-component-names': 'off',
    'vue/no-v-model-argument': 'off',
    
    // TypeScript
    '@typescript-eslint/no-explicit-any': 'warn',
    '@typescript-eslint/explicit-module-boundary-types': 'off',
    '@typescript-eslint/no-unused-vars': ['error', { 
      argsIgnorePattern: '^_' 
    }]
  },
  globals: {
    defineProps: 'readonly',
    defineEmits: 'readonly',
    defineExpose: 'readonly',
    withDefaults: 'readonly'
  }
}

自定义规则详解

// .eslintrc.js
module.exports = {
  rules: {
    // 规则级别:off(0) 关闭 / warn(1) 警告 / error(2) 错误
    
    // ========== 最佳实践 ==========
    'eqeqeq': ['error', 'always'],  // 必须用 === 和 !==
    'no-eval': 'error',              // 禁止 eval
    'no-implied-eval': 'error',      // 禁止隐式 eval
    'no-with': 'error',              // 禁止 with 语句
    
    // ========== 变量相关 ==========
    'no-unused-vars': ['error', { 
      vars: 'all',                   // 检查所有变量
      args: 'after-used',           // 检查使用后的参数
      ignoreRestSiblings: true      // 忽略剩余参数
    }],
    'no-use-before-define': ['error', { 
      functions: false,              // 函数可以在定义前使用
      classes: true,                // 类不行
      variables: true               // 变量也不行
    }],
    
    // ========== 代码风格 ==========
    // 这些规则会被 Prettier 覆盖,但保留作为参考
    'array-bracket-spacing': ['error', 'never'],  // [1, 2, 3] 而不是 [ 1, 2, 3 ]
    'object-curly-spacing': ['error', 'always'],  // { foo: bar } 而不是 {foo: bar}
    'comma-dangle': ['error', 'never'],           // 不加尾逗号
    'quotes': ['error', 'single'],                // 用单引号
    'semi': ['error', 'never'],                   // 不加分号
    
    // ========== 复杂度控制 ==========
    'max-depth': ['warn', 4],        // 最大嵌套深度不超过4
    'max-params': ['warn', 5],        // 函数参数不超过5个
    'max-statements': ['warn', 30],    // 函数语句不超过30行
    'complexity': ['warn', 10]         // 圈复杂度不超过10
  }
}

在 package.json 中添加脚本

{
  "scripts": {
    "lint": "eslint . --ext .js,.ts,.vue",
    "lint:fix": "eslint . --ext .js,.ts,.vue --fix",
    "lint:src": "eslint src --ext .js,.ts,.vue"
  }
}

Prettier - 代码美容师

什么是 Prettier?

Prettier 是格式化工具,它只有一个任务:把代码变得好看:

// 这是我们写的(乱七八糟)
function   hello(   name   ){
console.log(   `Hello ${   name   }`   )   }

// Prettier 帮我们变成这样
function hello(name) {
  console.log(`Hello ${name}`)
}

安装与基础配置

# 安装 Prettier
npm install --save-dev prettier

# 安装 ESLint 整合插件
npm install --save-dev eslint-config-prettier eslint-plugin-prettier

Prettier 配置文件

// .prettierrc.js
module.exports = {
  // 基础配置
  printWidth: 100,              // 每行最大宽度
  tabWidth: 2,                  // 缩进空格数
  useTabs: false,               // 用空格代替 tab
  semi: false,                  // 不加分号
  singleQuote: true,            // 用单引号
  quoteProps: 'as-needed',      // 对象属性只在必要时加引号
  trailingComma: 'none',        // 不加尾逗号
  bracketSpacing: true,         // 对象括号内加空格 { foo: bar }
  arrowParens: 'always',        // 箭头函数参数总是加括号 (x) => x
  endOfLine: 'auto',            // 自动处理换行符
  
  // Vue 相关
  vueIndentScriptAndStyle: true, // 缩进 <script> 和 <style>
  htmlWhitespaceSensitivity: 'strict',
  
  // 针对不同文件的特殊配置
  overrides: [
    {
      files: '*.vue',
      options: {
        printWidth: 120
      }
    },
    {
      files: '*.md',
      options: {
        proseWrap: 'always'
      }
    }
  ]
}

添加格式化脚本

{
  "scripts": {
    "format": "prettier --write \"src/**/*.{js,ts,vue,json,md}\"",
    "format:check": "prettier --check \"src/**/*.{js,ts,vue,json,md}\""
  }
}

编辑器集成(VS Code)

// .vscode/settings.json
{
  // 保存时自动格式化
  "editor.formatOnSave": true,
  
  // 使用 Prettier 作为默认格式化工具
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  
  // 对特定文件使用不同的格式化工具
  "[javascript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[vue]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  
  // 保存时自动修复 ESLint 问题
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  
  // 禁用内置的 CSS/HTML 格式化器
  "css.validate": false,
  "scss.validate": false,
  "html.validate.scripts": false,
  "html.validate.styles": false
}

Husky + lint-staged:Git 钩子自动化

什么是 Git 钩子?

Git 钩子就像"守门员":我们在做某个 Git 操作之前,可以先执行一些检查:

你想提交代码 → 守门员检查 → 没问题 → 提交成功
                 ↓
               有问题 → 不让提交,让你改

Husky 安装与配置

# 安装 Husky
npm install --save-dev husky

# 初始化 Husky(创建 .husky 目录)
npx husky install

# 添加 prepare 脚本,确保其他人安装后自动启用
npm pkg set scripts.prepare="husky install"

# 创建 pre-commit 钩子
npx husky add .husky/pre-commit "npx lint-staged"

lint-staged 配置

// .lintstagedrc.js
module.exports = {
  // 对 JS/TS/Vue 文件运行 ESLint(并自动修复)
  '*.{js,jsx,ts,tsx,vue}': ['eslint --fix', 'prettier --write'],
  
  // 对其他文件只运行 Prettier
  '*.{json,md,yml,yaml,html,css,scss}': ['prettier --write']
}

添加提交信息规范

# 安装 commitlint
npm install --save-dev @commitlint/cli @commitlint/config-conventional

# 创建配置
cat > commitlint.config.js << 'EOF'
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      [
        'feat',      // 新功能
        'fix',       // 修复
        'docs',      // 文档
        'style',     // 代码风格
        'refactor',  // 重构
        'perf',      // 性能优化
        'test',      // 测试
        'chore',     // 构建/工具
        'revert'     // 回滚
      ]
    ],
    'subject-full-stop': [2, 'never', '.'],  // 结尾不能有句号
    'header-max-length': [2, 'always', 100]  // 最大长度100
  }
}
EOF

# 添加 commit-msg 钩子
npx husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"'

提交信息格式

# 格式
<type>(<scope>): <subject>

# 示例
feat(user): 添加用户登录功能
fix(api): 修复请求超时问题
docs(readme): 更新安装说明
style(component): 格式化代码
refactor(utils): 重构日期处理函数
perf(list): 优化列表渲染性能

完整工作流演示

日常开发流程

1. 写代码
   ↓
2. 保存文件(VS Code 自动格式化)
   ↓
3. 提交代码
   ↓
4. pre-commit 钩子触发
   ↓
5. lint-staged 检查要提交的文件
   ├─ 通过 → 提交成功
   └─ 不通过 → 显示错误,拒绝提交

常见问题

场景1:提交被拦截,因为代码有问题

$ git commit -m "feat: 添加功能"
→ lint-staged 检查发现错误
→ 提交失败
解决方案:修复错误后重新提交
$ npm run lint:fix
$ git add .
$ git commit -m "feat: 添加功能"

场景2:紧急情况,跳过检查

$ git commit --no-verify -m "hotfix: 紧急修复"

CI/CD 集成

GitHub Actions 配置

# .github/workflows/lint.yml
name: Code Quality

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run ESLint
        run: npm run lint
      
      - name: Check formatting
        run: npm run format:check
      
      - name: Run TypeScript check
        run: npm run type-check

package.json 脚本

{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    
    "lint": "eslint . --ext .js,.ts,.vue",
    "lint:fix": "eslint . --ext .js,.ts,.vue --fix",
    "format": "prettier --write \"src/**/*.{js,ts,vue,json,md}\"",
    "format:check": "prettier --check \"src/**/*.{js,ts,vue,json,md}\"",
    "type-check": "vue-tsc --noEmit",
    
    "prepare": "husky install"
  }
}

常见问题与解决方案

ESLint 和 Prettier 冲突

让 Prettier 说了算:

// .eslintrc.js
module.exports = {
  extends: [
    'plugin:vue/vue3-recommended',
    'plugin:prettier/recommended'  // 放在最后,覆盖冲突规则
  ]
}

Husky 钩子不执行

检查钩子权限:

# 1. 检查钩子文件
ls -la .husky/pre-commit

# 2. 添加执行权限
chmod +x .husky/pre-commit

# 3. 重新安装
npm run prepare

lint-staged 运行太慢

// .lintstagedrc.js
module.exports = {
  // 方法1:限制每次检查的文件数
  '*.{js,ts,vue}': files => {
    const chunks = chunk(files, 10)
    return chunks.map(chunk => `eslint --fix ${chunk.join(' ')}`)
  },
  
  // 方法2:先跑 Prettier 再跑 ESLint
  '*.{js,ts,vue}': ['prettier --write', 'eslint --fix']
}

function chunk(arr, size) {
  return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) =>
    arr.slice(i * size, i * size + size)
  )
}

新成员加入时配置不一致

# 解决方案1:提交配置文件到仓库
git add .eslintrc.js .prettierrc.js .vscode/

# 解决方案2:在 README 中说明
## 开发环境配置

1. 安装 Node.js 18+
2. 运行 `npm install`
3. 安装 VS Code 推荐插件
4. 运行 `npm run dev`

# 解决方案3:添加快速启动脚本
npm run setup

完整配置清单

项目文件结构

my-project/
├── .husky/
│   ├── pre-commit          # 提交前检查
│   └── commit-msg          # 提交信息检查
├── .vscode/
│   ├── settings.json       # VS Code 设置
│   └── extensions.json     # 推荐插件
├── .eslintrc.js            # ESLint 配置
├── .prettierrc.js          # Prettier 配置
├── .lintstagedrc.js        # lint-staged 配置
├── commitlint.config.js    # 提交信息规范
├── package.json
└── README.md

快速初始化脚本

#!/bin/bash
# setup.sh - 一键配置代码规范

echo "开始配置代码规范工具链..."

# 1. 安装依赖
npm install --save-dev \
  eslint \
  prettier \
  eslint-config-prettier \
  eslint-plugin-prettier \
  eslint-plugin-vue \
  @typescript-eslint/parser \
  @typescript-eslint/eslint-plugin \
  husky \
  lint-staged \
  @commitlint/cli \
  @commitlint/config-conventional

# 2. 初始化配置文件
# (这里可以复制配置文件内容)

# 3. 初始化 Husky
npx husky install
npm pkg set scripts.prepare="husky install"
npx husky add .husky/pre-commit "npx lint-staged"
npx husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"'

echo "配置完成!"

规则选择原则

  1. 不要过度约束

    • 能自动修复的用 error
    • 不能自动修复的用 warn
  2. 优先使用社区推荐

    • eslint:recommended
    • vue/vue3-recommended
  3. 团队共识优先

    • 有争议的规则,团队讨论决定
    • 少数服从多数

工具使用原则

  1. 让工具做工具的事

    • 格式化的交给 Prettier
    • 质量检查交给 ESLint
    • 提交检查交给 Git 钩子
  2. 减少手动操作

    • 保存时自动格式化
    • 提交前自动检查
    • CI 自动验证
  3. 允许特殊情况

    • 可以用 --no-verify 跳过
    • 可以用 eslint-disable 忽略
    • 可以用 prettier-ignore 忽略

团队协作建议

  1. 配置纳入版本控制

    • 所有配置文件提交到仓库
    • 新人 clone 后直接可用
  2. 做好文档

    • README 说明如何配置
    • 记录特殊规则的原因
  3. 定期回顾

    • 收集反馈
    • 调整规则
    • 持续优化

结语

工具是辅助,不是目的。代码规范的核心是提升团队效率和代码质量,而不是制造障碍。自动化的代码规范工具链,不是为了限制开发者,而是为了解放开发者。让工具处理那些可以自动化的琐事,让人专注于真正需要思考的业务逻辑。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

网络请求在Vite层的代理与Mock:告别跨域和后端依赖

作者 wuhen_n
2026年3月21日 05:34

前言

在前端开发中,网络请求是连接前后端的桥梁,但也常常成为开发效率的瓶颈。跨域问题、后端接口未就绪、环境不稳定,这些问题每天都在消耗着我们的时间和精力。我们可以先看几个场景:

场景1

我们正在开发一个新功能,需要调用 /api/user/login 接口;启动项目,点击登录,浏览器报错:

Access to fetch at 'http://localhost:3000/api/user/login' 
from origin 'http://localhost:5173' has been blocked by CORS policy

然后我们去找后端同事:"帮我配一下CORS"。后端说:"好的,等我5分钟"。那我们就只能干等着。

场景2

我们要开发一个复杂报表页面,需要调用 /api/report/complex-data。但后端说这个接口要下周才能好;我们只能先写静态数据,等接口好了再改代码联调。

场景3

我们要测试页面在接口返回 500 错误时的表现,但后端服务表现得一直很稳定,怎么也触发不了错误。

这些问题每天都在消耗着我们的时间和精力。而Vite提供的代理和Mock能力,正是解决这些痛点的利器。

为什么要在Vite层解决网络请求问题?

开发环境的三大网络困境

困境1:跨域问题

  • 前端在 localhost:5173
  • 后端在 localhost:3000
  • 浏览器:不同端口 → 跨域 → 拦截

困境2:接口未就绪

  • 后端说要下周才能好
  • 前端这周只能干等?

困境3:环境不稳定

  • 测试服务器时而500,时而超时
  • 开发效率直线下降

传统方案的问题

  • 跨域:让后端配CORS → 依赖后端,每次新增接口都要配
  • Mock:单独启动一个Mock服务 → 多维护一个服务,端口冲突
  • 环境问题:手动改代码 → 容易忘记改回来,导致生产事故

Vite方案的优势

  • 代理:开发服务器自动转发 → 零后端依赖,前端完全可控
  • Mock:插件注入拦截 → 无额外服务,随项目启动
  • 配置中心化 → 一键切换,不会污染业务代码

代理 - 优雅解决跨域问题

代理是什么?

我们可以用一个快递的例子,来理解什么是代理: 我们(浏览器)想通过公司内部快递,寄一份快递给后端,但快递公司说"不同地址不能寄"(跨域):

  • 于是我们找了个中间人:公司综合员(Vite开发服务器)
  • 把快递给综合员(请求发给Vite)
  • 综合员帮我们转寄给后端(Vite转发请求)
  • 后端把回执给综合员,综合员再转交给我们

这个例子的关键是:综合员和你是同一个部门(地址),所以快递公司不拦截。

Vite 代理的工作原理

请求流程:

浏览器 → http://localhost:5173/api/users
             ↓
Vite 开发服务器 (localhost:5173)
             ↓
代理配置匹配 /api
             ↓
转发请求 → http://localhost:3000/api/users
             ↓
后端服务器 (localhost:3000)
             ↓
响应返回 → Vite 服务器
             ↓
转发给浏览器

关键:浏览器只和同源的 Vite 服务器通信,完美绕过跨域

最简单的代理配置

// vite.config.js
export default {
  server: {
    proxy: {
      // 把所有 /api 开头的请求,转发到 http://localhost:3000
      '/api': 'http://localhost:3000'
    }
  }
}

// 现在可以这样请求了
fetch('/api/users')  
// 实际请求:http://localhost:3000/api/users
// 完美绕过跨域!

完整的代理配置详解

// vite.config.js
export default {
  server: {
    proxy: {
      // 详细的代理配置
      '/api': {
        target: 'http://localhost:3000',  // 目标服务器地址
        changeOrigin: true,                // 改变请求源头(重要!)
        
        // 重写路径:去掉 /api 前缀
        rewrite: (path) => path.replace(/^\/api/, ''),
        // 请求 /api/users → 实际转发 /users
        
        secure: false,     // 如果目标是https但证书无效,设为false
        ws: true,          // 支持 WebSocket 代理
        
        // 添加自定义请求头
        headers: {
          'X-Dev-Proxy': 'vite'
        },
        
        // 调试:查看代理过程
        configure: (proxy) => {
          proxy.on('proxyReq', (proxyReq, req) => {
            console.log('→ 代理请求:', req.url)
          })
          proxy.on('proxyRes', (proxyRes, req) => {
            console.log('← 代理响应:', proxyRes.statusCode)
          })
          proxy.on('error', (err) => {
            console.log('✗ 代理错误:', err)
          })
        }
      }
    }
  }
}

多环境代理配置策略

为什么需要多环境?

实际开发中,我们通常需要配置多个环境:

  • 开发环境:连接本地后端 localhost:3000
  • 测试环境:连接测试服务器 test-api.example.com
  • 预发环境:连接预发服务器 staging-api.example.com
  • 生产环境:连接正式服务器 api.example.com

每次切换环境都要改代码,这太麻烦了!

使用环境变量配置

// vite.config.js
import { defineConfig, loadEnv } from 'vite'

export default defineConfig(({ mode }) => {
  // 根据当前模式加载对应的环境变量
  // mode 可能是 development / staging / production
  const env = loadEnv(mode, process.cwd())
  
  return {
    server: {
      proxy: {
        '/api': {
          target: env.VITE_API_URL,  // 从环境变量读取
          changeOrigin: true,
          rewrite: path => path.replace(/^\/api/, '')
        },
        
        // 如果有多个后端服务
        '/upload': {
          target: env.VITE_UPLOAD_URL,
          changeOrigin: true
        }
      }
    }
  }
})

环境变量文件配置

# .env.development - 开发环境
VITE_API_URL=http://localhost:3000
VITE_UPLOAD_URL=http://localhost:3001

# .env.staging - 测试环境
VITE_API_URL=http://test-api.example.com
VITE_UPLOAD_URL=http://test-upload.example.com

# .env.production - 生产环境
VITE_API_URL=https://api.example.com
VITE_UPLOAD_URL=https://upload.example.com

启动不同环境

// package.json
{
  "scripts": {
    "dev": "vite --mode development",
    "dev:staging": "vite --mode staging",
    "build:prod": "vite build --mode production"
  }
}

Mock - 摆脱后端依赖

什么时候需要Mock?

场景1:后端接口还没开发完成

真实接口后端需要开发 2 周后才能完成;此时前端不能等,需要继续开发,我们就可以 Mock 数据继续开发:

// 解决方案:Mock 数据
fetch('/api/complex-report')
  .then(res => res.json())
  .then(data => renderReport(data))  // 用 Mock 数据继续开发

场景2:测试边界情况

const testCases = [
  { status: 200, data: [...] },        // 正常情况
  { status: 500, message: '服务器错误' }, // 错误情况
  { status: 401, message: '未登录' },     // 权限问题
  { status: 200, data: [] }              // 空数据情况
]

场景3:演示或测试环境

不需要真实后端,通过 Mock 数据,前端也能正常跑起来!

安装 vite-plugin-mock

npm install vite-plugin-mock -D

基础配置

// vite.config.js
import { viteMockServe } from 'vite-plugin-mock'

export default {
  plugins: [
    viteMockServe({
      mockPath: 'mock',        // mock文件存放目录
      supportTs: true,          // 支持TypeScript
      watchFiles: true,         // 监听文件变化(修改mock自动重启)
      localEnabled: true,       // 开发环境启用
      prodEnabled: false,       // 生产环境禁用
      
      // 生产环境注入的代码(如果需要)
      injectCode: `
        import { setupProdMockServer } from './mockProdServer';
        setupProdMockServer();
      `
    })
  ]
}

第一个Mock接口

// mock/user.js
export default [
  // GET请求示例
  {
    url: '/api/users',
    method: 'get',
    response: () => {
      return {
        code: 200,
        message: 'success',
        data: [
          { id: 1, name: '张三', age: 25 },
          { id: 2, name: '李四', age: 30 },
          { id: 3, name: '王五', age: 28 }
        ]
      }
    }
  },
  
  // POST请求示例
  {
    url: '/api/login',
    method: 'post',
    response: ({ body }) => {
      const { username, password } = body
      
      // 模拟登录验证
      if (username === 'admin' && password === '123456') {
        return {
          code: 200,
          data: {
            token: 'mock-token-' + Date.now(),
            username
          }
        }
      }
      
      return {
        code: 401,
        message: '用户名或密码错误'
      }
    }
  }
]

带参数的Mock

// mock/user.js
export default [
  // 动态路径参数
  {
    url: '/api/user/:id',
    method: 'get',
    response: ({ params }) => {
      const { id } = params
      
      return {
        code: 200,
        data: {
          id: Number(id),
          name: `用户${id}`,
          age: 20 + Number(id),
          avatar: `https://randomuser.me/api/portraits/${id % 2 ? 'men' : 'women'}/${id}.jpg`
        }
      }
    }
  },
  
  // 查询参数
  {
    url: '/api/users',
    method: 'get',
    response: ({ query }) => {
      const { page = 1, pageSize = 10 } = query
      
      // 生成分页数据
      const start = (page - 1) * pageSize
      const total = 100
      
      const data = Array.from({ length: pageSize }, (_, i) => ({
        id: start + i + 1,
        name: `用户${start + i + 1}`,
        age: 20 + Math.floor(Math.random() * 30)
      }))
      
      return {
        code: 200,
        data: {
          list: data,
          total,
          page: Number(page),
          pageSize: Number(pageSize)
        }
      }
    }
  }
]

高级Mock技巧

模拟不同场景

// mock/scenarios.ts
export default [
  // 模拟分页数据
  {
    url: '/api/users/paged',
    method: 'get',
    response: ({ query }) => {
      const { page = 1, pageSize = 10 } = query
      const start = (page - 1) * pageSize
      const total = 100
      
      const data = Array.from({ length: pageSize }, (_, i) => ({
        id: start + i + 1,
        name: `用户${start + i + 1}`,
        age: 20 + Math.floor(Math.random() * 30)
      }))
      
      return {
        code: 200,
        data: {
          list: data,
          total,
          page: Number(page),
          pageSize: Number(pageSize)
        }
      }
    }
  },
  
  // 模拟延迟
  {
    url: '/api/slow-request',
    method: 'get',
    timeout: 3000, // 3秒延迟
    response: () => {
      return {
        code: 200,
        data: '终于响应了'
      }
    }
  },
  
  // 模拟错误
  {
    url: '/api/error',
    method: 'get',
    statusCode: 500,
    response: () => {
      return {
        code: 500,
        message: '服务器内部错误'
      }
    }
  },
  
  // 模拟超时
  {
    url: '/api/timeout',
    method: 'get',
    timeout: 10000, // 超时时间
    response: () => {
      // 永远不会执行
    }
  }
]

使用 mockjs 生成随机数据

// mock/dashboard.js
import Mock from 'mockjs'

export default [
  {
    url: '/api/dashboard',
    method: 'get',
    response: () => {
      return {
        code: 200,
        data: {
          // 随机数字
          visits: Mock.mock('@integer(1000, 10000)'),
          
          // 随机浮点数
          sales: Mock.mock('@float(1000, 10000, 2, 2)'),
          
          // 随机数组
          trends: Mock.mock({
            'data|7': ['@integer(100, 1000)']
          }).data,
          
          // 随机用户列表
          users: Mock.mock({
            'list|10': [{
              'id|+1': 1,
              name: '@cname',  // 中文名
              avatar: '@image(100x100)',  // 随机图片
              'age|20-40': 1,
              email: '@email',
              address: '@county(true)',
              'gender|1': ['男', '女']
            }]
          }).list
        }
      }
    }
  }
]

动态增删改查

// mock/crud.js
// 模拟数据库
const store = {
  users: new Map([
    [1, { id: 1, name: '张三' }],
    [2, { id: 2, name: '李四' }]
  ])
}

export default [
  // 查询列表
  {
    url: '/api/users',
    method: 'get',
    response: () => ({
      code: 200,
      data: Array.from(store.users.values())
    })
  },
  
  // 新增
  {
    url: '/api/users',
    method: 'post',
    response: ({ body }) => {
      const id = store.users.size + 1
      const newUser = { id, ...body }
      store.users.set(id, newUser)
      return {
        code: 200,
        data: newUser
      }
    }
  },
  
  // 删除
  {
    url: '/api/users/:id',
    method: 'delete',
    response: ({ params }) => {
      const id = Number(params.id)
      const deleted = store.users.get(id)
      store.users.delete(id)
      return {
        code: 200,
        data: deleted
      }
    }
  },
  
  // 更新
  {
    url: '/api/users/:id',
    method: 'put',
    response: ({ params, body }) => {
      const id = Number(params.id)
      const user = store.users.get(id)
      if (user) {
        const updated = { ...user, ...body }
        store.users.set(id, updated)
        return {
          code: 200,
          data: updated
        }
      }
      return {
        code: 404,
        message: '用户不存在'
      }
    }
  }
]

代理与Mock协同工作

按需启用Mock

// vite.config.js
import { defineConfig, loadEnv } from 'vite'
import { viteMockServe } from 'vite-plugin-mock'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd())
  
  // 是否启用Mock(从环境变量读取)
  const useMock = env.VITE_USE_MOCK === 'true'
  
  return {
    server: {
      proxy: {
        '/api': {
          target: env.VITE_API_URL,
          changeOrigin: true
        }
      }
    },
    
    plugins: [
      // 只有启用Mock时才加载插件
      useMock && viteMockServe({
        mockPath: 'mock',
        localEnabled: true
      })
    ].filter(Boolean)
  }
})

环境变量配置

# .env.development - 正常开发(连接真实后端)
VITE_API_URL=http://localhost:3000
VITE_USE_MOCK=false

# .env.development.mock - Mock模式(不依赖后端)
VITE_API_URL=http://localhost:3000  # 这个其实用不到了
VITE_USE_MOCK=true

# .env.staging - 测试环境
VITE_API_URL=http://test-api.example.com
VITE_USE_MOCK=false

启动脚本配置

{
  "scripts": {
    "dev": "vite --mode development",
    "dev:mock": "vite --mode development.mock",
    "dev:user": "VITE_USE_MOCK=true vite",  // 临时启用Mock
    "dev:no-mock": "VITE_USE_MOCK=false vite"  // 临时关闭Mock
  }
}

请求封装配合

// src/utils/request.js
import axios from 'axios'

const request = axios.create({
  baseURL: import.meta.env.VITE_API_URL
})

// 请求拦截器 - 可以添加统一处理
request.interceptors.request.use(config => {
  // 添加token
  const token = localStorage.getItem('token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

// 响应拦截器 - 统一处理错误
request.interceptors.response.use(
  response => response.data,
  error => {
    // 统一错误处理
    if (error.response?.status === 401) {
      // 跳转登录
      window.location.href = '/login'
    }
    return Promise.reject(error)
  }
)

export default request

最佳实践与项目组织

Mock 文件组织结构

project/
├── mock/
│   ├── index.ts                 # 主入口,导出所有 Mock
│   ├── utils/                   
│   │   ├── response.ts          # 响应工具函数
│   │   ├── database.ts          # 模拟数据库
│   │   └── generator.ts         # 数据生成器
│   ├── modules/
│   │   ├── user/
│   │   │   ├── index.ts         # 用户模块 Mock
│   │   │   ├── data.ts          # 用户数据
│   │   │   └── scenarios.ts     # 用户场景
│   │   ├── order/
│   │   │   ├── index.ts
│   │   │   ├── data.ts
│   │   │   └── scenarios.ts
│   │   └── product/
│   │       ├── index.ts
│   │       ├── data.ts
│   │       └── scenarios.ts
│   └── fixtures/
│       ├── users.json           # 静态数据
│       └── products.json
└── vite.config.ts

统一响应格式

// mock/utils/response.ts
export interface ApiResponse<T = any> {
  code: number
  message: string
  data: T
}

// 成功响应
export const success = <T>(data: T, message = 'success'): ApiResponse<T> => ({
  code: 200,
  message,
  data
})

// 错误响应
export const error = (message: string, code = 500): ApiResponse => ({
  code,
  message,
  data: null
})

// 分页响应
export const paged = <T>(
  list: T[],
  total: number,
  page: number,
  pageSize: number
) => success({
  list,
  total,
  page,
  pageSize,
  totalPages: Math.ceil(total / pageSize)
})

主入口文件

// mock/index.js
import user from './modules/user'
import order from './modules/order'
import product from './modules/product'

// 合并所有mock
export default [
  ...user,
  ...order,
  ...product
]

模块化示例

// mock/modules/user.js
import { success, error } from '../utils/response'

export default [
  // 登录
  {
    url: '/api/login',
    method: 'post',
    response: ({ body }) => {
      const { username, password } = body
      
      if (username === 'admin' && password === '123456') {
        return success({
          token: 'mock-token',
          username
        })
      }
      
      return error('用户名或密码错误', 401)
    }
  },
  
  // 获取用户信息
  {
    url: '/api/user/info',
    method: 'get',
    response: ({ headers }) => {
      const token = headers.authorization
      
      if (!token) {
        return error('未登录', 401)
      }
      
      return success({
        id: 1,
        name: '张三',
        avatar: 'https://randomuser.me/api/portraits/men/1.jpg',
        roles: ['admin']
      })
    }
  }
]

常见问题与解决方案

问题一:代理不生效

检查点1:路径是否正确

fetch('/api/users')  // 正确
fetch('api/users')   // 错误,缺少斜杠

检查点2:代理配置是否正确

proxy: {
  '/api': 'http://localhost:3000'  // 请求会转发到 http://localhost:3000/api
  // 如果需要重写路径:
  '/api': {
    target: 'http://localhost:3000',
    rewrite: path => path.replace(/^\/api/, '')  // 转发到 http://localhost:3000
  }
}

检查点3:后端是否在运行

curl http://localhost:3000/api/test

问题二:Mock 数据不更新

// vite.config.ts
export default {
  plugins: [
    viteMockServe({
      watchFiles: true,  // 确保开启监听
      // 或者手动清除缓存
      logger: true       // 查看日志
    })
  ]
}

// 如果还是不更新,尝试:
// 1. 重启开发服务器
// 2. 删除 node_modules/.vite 缓存
// 3. 检查文件修改时间

问题三:代理和 Mock 冲突

场景:同一个路径既配置了代理,又配置了 Mock,这时可能会引发冲突

解决方案1:优先级控制

plugins: [
  viteMockServe({
    // 确保 Mock 插件在代理之前
    // 插件的顺序决定了优先级
  })
]

解决方案2:使用不同路径

proxy: {
  '/api/real': 'http://localhost:3000',  // 真实 API
}
// Mock 使用相同路径,但通过插件配置

解决方案3:条件启用

const useMock = process.env.USE_MOCK === 'true'

proxy: {
  ...(!useMock && {
    '/api': 'http://localhost:3000'
  })
}

问题四:开发环境正常,生产环境报404

解决方案:确保生产环境用真实接口

// 请求封装中判断
const baseURL = import.meta.env.PROD 
  ? 'https://api.example.com'  // 生产用真实地址
  : '/api'                      // 开发用代理

const request = axios.create({ baseURL })

代理与 Mock 的最佳实践

配置清单

  • 基础代理配置完成
  • 多环境代理配置
  • Mock 插件安装配置
  • Mock 接口编写规范
  • 环境变量控制开关
  • 代理与 Mock 协同策略

开发流程建议

阶段1:后端接口未定义
├─ 前端先定义接口格式
├─ 编写Mock数据
└─ 前端独立开发

阶段2:后端开发中
├─ 已完成的接口用代理
├─ 未完成的用Mock
└─ 逐步替换

阶段3:后端全部完成
├─ 关闭Mock
├─ 全部使用代理
└─ 联调测试

阶段4:特殊场景测试
├─ 临时启用Mock
├─ 模拟各种边界情况
└─ 测试完成后关闭

常用配置模板

// vite.config.js - 完整配置模板
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { viteMockServe } from 'vite-plugin-mock'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd())
  const useMock = env.VITE_USE_MOCK === 'true'
  
  return {
    plugins: [
      vue(),
      useMock && viteMockServe({
        mockPath: 'mock',
        supportTs: true,
        watchFiles: true,
        logger: true
      })
    ].filter(Boolean),
    
    server: {
      proxy: {
        '/api': {
          target: env.VITE_API_URL,
          changeOrigin: true,
          rewrite: path => path.replace(/^\/api/, ''),
          configure: (proxy) => {
            proxy.on('proxyReq', (proxyReq, req) => {
              console.log('[代理]', req.method, req.url)
            })
          }
        }
      }
    }
  }
})

三个黄金原则

  1. 需要时才启用,不需要时关闭
  2. 模拟真实场景,不止是成功路径
  3. 与代理无缝切换,对业务代码无侵入

结语

代理和Mock不是用来骗人的,而是用来解放前端的。好的代理和Mock策略应该是:

  • 开发时:前端不依赖后端,想怎么测就怎么测
  • 联调时:一键关闭Mock,无缝切换到真实接口
  • 维护时:配置清晰,不会因为忘记关Mock而出问题

掌握了这些,我们就可以告别跨域报错,告别等待后端,让开发效率真正起飞!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Vue3动态组件Component的深度解析与应用

2026年3月22日 03:00

image

前言

  在 Vue 开发中,动态组件渲染是构建灵活界面的重要技术,允许开发者在运行时动态地切换组件。通过动态组件,可以根据不同的条件渲染不同的组件,从而实现灵活的界面和交互,特别适用于标签页、动态表单等需要组件动态切换的场景。

一、初识动态组件:为什么需要它🤔

1.1 什么是动态组件

  在 Vue 中,动态组件是一种强大的特性,可以根据不同的条件在运行时动态地切换组件的显示。与静态组件不同,动态组件的类型不是在编译时确定的,而是在运行时根据某些条件动态决定的。Vue 提供了一个组件 component 标签来动态的完成组件的切换, 不需要我们自己去封装。

1.2 <component> 标签的作用

  动态组件使用特殊的 <component> 标签结合 is 属性来实现,is 属性用于指定要渲染的组件名称或组件选项对象,Vue 会根据 is 属性的值来动态地加载和渲染相应的组件。这种方式允许在运行时根据条件来渲染不同的组件,这对于创建灵活的、可复用的 UI 部分非常有用,比如在构建一个标签页组件、动态表单或是需要根据用户权限显示不同组件的场景中。

image

1.3 使用场景

  灵活运用 Vue 的动态组件功能,能够帮助我们满足动态性和灵活性的需求,这里列举几个常见的使用场景:

  • 「条件渲染」:根据不同条件加载组件,如根据用户权限加载权限组件或根据用户选择加载不同的组件。
  • 「动态表单」:根据表单类型或步骤动态渲染相关组件,避免加载整个表单,只加载与当前状态相关的部分。
  • 「模态框和弹出窗口」:通过动态组件实现模态框和弹出窗口内容,根据触发条件或用户操作动态加载相应内容。
  • 「复用和扩展组件」:使用动态组件轻松复用和扩展现有组件,通过替换动态组件实现不同展现和行为。
  • 「路由视图切换」:在路由器中使用动态组件实现动态路由视图切换,根据路由路径加载相应组件,实现无缝页面切换。
  • 「可配置的组件选择」:动态组件用于根据用户配置选择和加载特定组件,快速生成定制化应用程序。

二、基础功能与核心用法🚀

2.1 动态渲染机制

  Vue 通过 component 标签配合 is 属性来实现动态组件的渲染,从而决定要渲染哪个组件。这种方式允许我们在不修改模板结构的前提下,动态地切换组件。

注册的组件名(字符串)

  这是最直接的方式,在父组件中通过 components 选项(在 Options API 中)或者直接导入(在 Composition API 中)注册子组件,然后使用注册时用的名字(字符串)来切换。首先,我们需要定义几个组件,然后在父组件中使用 component 标签,并通过 is 属性动态绑定组件名。在 is 属性中使用字符串来指定组件的名称,实现组件的动态切换。例如:

<script setup>
import ComponentA from "./ComponentA.vue";
import ComponentB from "./ComponentB.vue";

const currentComponent = "ComponentA";// 绑定组件名称
</script>

<template>
  <div>
    <component :is="currentComponent"></component>
  </div>
</template>

  在上述示例中,我们定义了两个子组件 ComponentA 和 ComponentB,并在父组件中根据 currentComponent 变量的值,从而动态地渲染 ComponentA 或 ComponentB 组件。

绑定组件对象

  在实际业务中,我们可能需要根据用户选择的不同选项来展示不同的表单组件,可以使用变量来指定一个组件选项对象,适用于需要逻辑判断的场景。假设我们有两个简单的Vue组件:ComponentA 和 ComponentB,我们想要根据某个条件(比如一个名为 currentComp 的数据属性)来动态地显示它们。

<script setup>
// 导入需要切换的组件
import CompA from "./ComponentA.vue";
import CompB from './ComponentB.vue'

// 绑定组件对象(响应式)
const currentComp = ref(CompA) // 默认渲染 CompA
</script>

<template>
  <div>
    <!-- 切换按钮 -->
    <button @click="currentComp = CompA">切换组件A</button>
    <button @click="currentComp = CompB">切换组件B</button>

    <!-- 动态组件核心标签 -->
    <component :is="currentComp"></component>
  </div>
</template>

  在上述示例中,currentComponent 变量可以在运行时被赋值为 ComponentA 或 ComponentB 的组件选项对象,从而实现动态切换组件。

2.2 动态传参

属性传递

  在父组件和动态组件之间传递数据也非常简单,父组件可以通过属性绑定传递数据,子组件通过 defineProps 声明接收:

<!-- 父组件 -->
<component :is="currentComponent" :message="text" />

<!-- 子组件 -->
<script setup>
defineProps(['message']);
</script>

监听组件生命周期

  动态组件也支持监听其内部组件的生命周期钩子,这可以通过在父组件中定义对应的生命周期钩子,并使用 $refs来访问来实现。

<script setup>
const dynamicComponent = ref()
watch(dynamicComponent,(newVal)=>{
  console.log('组件已切换:', newVal);
})
</script>

<template>
  <div>
    <component :is="currentComponent" ref="dynamicComponent"></component>
  </div>
</template>

事件监听

  父组件通过@ 监听子组件事件,子组件通过$emit触发:

<!-- 父组件 -->
<component :is="currentComponent" @custom-event="handleEvent" />

<!-- 子组件 -->
<script setup>
const emit = defineEmits(['custom-event']);
emit('custom-event', data);
</script>

三、实践技巧

3.1 动态组件切换

  当我们需要根据不同的条件来渲染不同的组件。这时,我们可以使用 v-if 和 v-else指令来实现条件渲染。例如:

<component v-if="showComponentA" :is="'ComponentA'"></component>
<component v-else :is="'ComponentB'"></component>

<!-- 代码简化 -->
<component :is="showComponentA ? 'ComponentA' : 'ComponentB'"></component>

  在这个示例中,根据 showComponentA 的值来决定渲染 ComponentA 还是 ComponentB。

3.2 动态组件的过渡效果

  为了让动态组件的切换更加平滑,我们可以为添加过渡效果(包括入场和离场的过渡动画)。我们可以使用 Vue 内置的 transition 组件和过渡类名,来实现过渡效果。

<template>
  <div>
    <transition name="fade">
      <component :is="currentComponent"></component>
    </transition>
  </div>
</template>

<style>
  .fade-enter-active,
  .fade-leave-active {
    transition: opacity 0.5s;
  }

  .fade-enter,
  .fade-leave-to {
    opacity: 0;
  }
</style>

四、Vue3中的优化实践

4.1 script setup语法优化

  在组合式API中可直接使用组件对象:

<script setup>
import ComponentA from './ComponentA.vue'
import ComponentB from './ComponentB.vue'

const components = {
  a: ComponentA,
  b: ComponentB
}
const currentKey = ref('a')
</script>

<template>
  <component :is="components[currentKey]"></component>
</template>

4.2 异步组件加载

  在大型应用中,可能需要懒加载某些组件以提高应用的加载速度和性能。Vue 支持异步组件,这意味着可以按需加载组件,这对于优化大型应用的加载时间非常有帮助。可以结合 defineAsyncComponent 实现按需加载:

<script setup>
import { defineAsyncComponent, ref } from 'vue'

const asyncComponent = defineAsyncComponent(() => import('./AsyncComponent.vue'))
</script>

<template>
  <component :is="asyncComponent"></component>
</template>

五、总结

  动态组件是 Vue 中非常重要的一个组件类型,它可以让我们可以在不改变DOM结构的情况下,根据数据的变化动态地切换不同的组件。在开发过程中,合理利用动态组件的功能,可以使应用结构更加清晰,逻辑更加灵活,同时也方便后续的维护和扩展,极大地提高了开发效率和应用的用户体验。

image

Vue3+Vite项目极致性能优化:从构建到运行全链路实战指南

2026年3月21日 17:23

在前端工程化日趋成熟的今天,项目性能直接决定用户体验和产品留存率。Vue3搭配Vite作为当下主流的前端开发组合,凭借超快的热更新和编译速度收获大量开发者青睐,但随着项目业务迭代、依赖包增多,很容易出现打包体积过大、首屏加载缓慢、运行时卡顿等问题。

本文将从构建打包优化、运行时性能优化、资源加载优化、代码层面优化四个维度,梳理Vue3+Vite项目全链路性能优化方案,全部搭配实战代码和实操步骤,看完直接落地到项目,轻松实现项目体积缩减50%+、首屏加载速度提升60%+。

适用场景:Vue3.2+、Vite4.x+、Composition API项目,包含PC端管理后台、移动端H5、小程序内嵌H5等各类Vue3工程化项目

一、前置准备:性能问题排查工具

优化之前,首先要精准定位性能瓶颈,避免盲目优化。推荐两款掘金社区高频使用、上手零成本的排查工具:

1.1 Vite打包分析插件

通过可视化图表查看打包后各依赖包和文件体积,快速定位大包依赖,是优化打包体积的核心工具。

安装与配置

# 安装依赖
npm install rollup-plugin-visualizer -D
# 或者yarn
yarn add rollup-plugin-visualizer -D

在vite.config.js中引入配置:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// 引入打包分析插件
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    vue(),
    // 打包分析配置,生成stats.html可视化文件
    visualizer({
      open: true, // 打包完成后自动打开浏览器
      gzipSize: true, // 显示gzip压缩后体积
      brotliSize: true // 显示brotli压缩后体积
    })
  ],
  build: {
    // 生产环境构建配置
    sourcemap: false // 关闭sourcemap,减小打包体积
  }
})

执行npm run build,会自动生成stats.html文件,打开后就能清晰看到各模块体积占比,重点关注体积超过100KB的依赖包。

1.2 Chrome DevTools性能排查

  • Network面板:查看资源加载时长、体积、并发数,定位慢加载资源和冗余资源
  • Performance面板:录制页面运行时性能,查看FP、FCP、LCP等核心性能指标,定位长任务和渲染卡顿
  • Lighthouse:一键生成性能报告,获取性能评分和优化建议,掘金文章必备性能参考依据

二、构建打包优化:减小产物体积是核心

Vite基于Rollup构建,生产环境打包优化主要围绕代码分割、依赖分包、压缩、剔除冗余代码展开,这是提升首屏加载速度的关键。

2.1 依赖按需引入,杜绝全量打包

项目中常用的Element Plus、Ant Design Vue、ECharts等UI库和图表库,全量引入会导致打包体积暴增,必须改用按需引入。

Element Plus按需引入实战

# 安装按需引入插件
npm install unplugin-vue-components unplugin-auto-import -D

vite.config.js配置:

import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),
    // 自动导入API
    AutoImport({
      resolvers: [ElementPlusResolver()],
      // 自动导入Vue、VueRouter等核心API,无需手动import
      imports: ['vue', 'vue-router', 'pinia']
    }),
    // 自动导入组件
    Components({
      resolvers: [ElementPlusResolver()]
    })
  ]
})

配置完成后,无需在main.js全局引入Element Plus,组件和API会自动按需导入,打包体积可缩减60%以上。

2.2 代码分割与路由懒加载

Vue3路由默认全量加载,首屏会加载所有路由组件,导致加载缓慢,通过路由懒加载实现组件按需加载,拆分打包chunk。

路由懒加载配置

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
// 非首页组件全部采用懒加载
const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/home/index.vue') // 懒加载
  },
  {
    path: '/user',
    name: 'User',
    component: () => import('@/views/user/index.vue'),
    // 嵌套路由同样懒加载
    children: [
      { path: 'info', component: () => import('@/views/user/info.vue') }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

export default router

同时在vite.config.js配置chunk拆分规则,避免单个chunk体积过大:

build: {
  rollupOptions: {
    output: {
      // 拆分chunk,第三方依赖单独打包
      manualChunks(id) {
        if (id.includes('node_modules')) {
          return 'vendor' // 所有第三方依赖打包为vendor.js
        }
        // 进一步拆分大体积依赖
        if (id.includes('echarts')) {
          return 'echarts'
        }
        if (id.includes('element-plus')) {
          return 'element-plus'
        }
      }
    }
  }
}

2.3 开启Gzip/Brotli压缩,大幅减小资源体积

静态资源开启压缩后,体积可缩减60%-80%,Vite可直接配置生成压缩文件,配合Nginx配置生效。

npm install vite-plugin-compression -D
import viteCompression from 'vite-plugin-compression'

plugins: [
  // 开启gzip压缩
  viteCompression({
    algorithm: 'gzip', // 压缩算法
    threshold: 10240, // 大于10KB的文件才压缩
    deleteOriginFile: false // 不删除源文件
  }),
  // 开启brotli压缩(压缩率更高,优先使用)
  viteCompression({
    algorithm: 'brotliCompress',
    threshold: 10240
  })
]

2.4 剔除生产环境冗余代码

  • 关闭生产环境console.log和debugger,避免调试代码上线
  • 剔除未使用的CSS代码,减少样式文件体积
build: {
  // 剔除console和debugger
  minify: 'terser',
  terserOptions: {
    compress: {
      drop_console: true,
      drop_debugger: true
    }
  },
  // 剔除未使用CSS
  cssCodeSplit: true,
  rollupOptions: {
    output: {
      assetFileNames: 'assets/[name].[hash][extname]'
    }
  }
}

三、运行时性能优化:解决页面卡顿问题

除了打包体积,运行时渲染卡顿、响应延迟是影响用户体验的另一大痛点,Vue3基于Proxy响应式,本身性能优于Vue2,但不合理的代码写法仍会导致性能损耗。

3.1 合理使用响应式API,避免过度响应式

Vue3的ref、reactive、computed、watch是核心响应式API,错误使用会导致不必要的重新渲染,优化原则:

  • 基础数据类型用ref,引用类型用reactive,避免深层嵌套响应式
  • 只读数据不用响应式,直接用const定义
  • computed替代冗余的方法计算,缓存计算结果
  • watch加immediate和deep慎用,避免不必要的监听

错误写法VS优化写法

<template>
  <div>{{ totalPrice }}</div>
</template>

<script setup>
import { ref, computed } from 'vue'

// 错误:用方法计算,每次渲染都会重新执行
const price = ref(100)
const num = ref(2)
const getTotalPrice = () => price.value * num.value

// 优化:用computed缓存结果,仅依赖变化时重新计算
const totalPrice = computed(() => price.value * num.value)
</script>

3.2 长列表虚拟滚动,避免DOM过载

后台系统常见的长列表、大数据表格,直接渲染全部DOM会导致页面卡死,使用虚拟滚动只渲染可视区域DOM,大幅提升渲染性能。

推荐Vue3虚拟滚动库:vue-virtual-scrollervxe-table(适配表格)

3.3 组件懒加载与keep-alive合理使用

  • 非首屏必要组件,用defineAsyncComponent异步懒加载
  • keep-alive缓存高频切换组件,避免重复渲染,搭配include、exclude精准控制缓存
<template>
  <!-- 只缓存首页和用户页组件 -->
  <keep-alive include="Home,User">
    <router-view />
  </keep-alive>

  <!-- 异步懒加载非必要组件 -->
  <AsyncModal v-if="showModal" />
</template>

<script setup>
import { defineAsyncComponent } from 'vue'
// 异步懒加载弹窗组件,点击时才加载
const AsyncModal = defineAsyncComponent(() => import('@/components/Modal/index.vue'))
const showModal = ref(false)
</script>

3.4 事件节流防抖,优化高频触发操作

搜索框输入、页面滚动、窗口 resize、按钮频繁点击等高频事件,不加节流防抖会导致函数频繁执行,引发卡顿,封装通用节流防抖工具函数。

// utils/debounce-throttle.js
// 防抖:触发后n秒内只执行一次,重复触发重新计时
export function debounce(fn, delay = 300) {
  let timer = null
  return function (...args) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
    }, delay)
  }
}

// 节流:n秒内只执行一次,稀释执行频率
export function throttle(fn, interval = 500) {
  let lastTime = 0
  return function (...args) {
    const now = Date.now()
    if (now - lastTime >= interval) {
      lastTime = now
      fn.apply(this, args)
    }
  }
}

四、资源加载优化:提升首屏加载速度

4.1 图片资源极致优化

  • 图片压缩:使用tinypng压缩图片,生产环境禁用原图
  • 图片懒加载:使用v-lazy指令,非可视区域图片延迟加载
  • WebP格式替换:WebP体积比JPG/PNG小30%,兼容性好
  • CDN加速:静态图片、字体、第三方资源改用CDN加载,分担服务器压力

Vue3图片懒加载配置

npm install vue3-lazy -D
// main.js
import App from './App.vue'
import lazyPlugin from 'vue3-lazy'

const app = createApp(App)
app.use(lazyPlugin, {
  loading: 'loading.png', // 加载中占位图
  error: 'error.png' // 加载失败占位图
})
app.mount('#app')
<!-- 图片懒加载使用 -->
<img v-lazy="item.imgUrl" alt="商品图片" />

4.2 第三方资源CDN引入,脱离本地打包

Vue、VueRouter、Pinia、Axios等核心依赖,改用CDN引入,不参与本地打包,大幅减小vendor体积。

// vite.config.js
build: {
  rollupOptions: {
    // 外部化依赖,不打包
    external: ['vue', 'vue-router', 'axios'],
    output: {
      // CDN全局变量映射
      globals: {
        vue: 'Vue',
        'vue-router': 'VueRouter',
        axios: 'axios'
      }
    }
  }
}

在index.html中引入CDN资源:

<!-- vue3 cdn -->
<script src="https://cdn.jsdelivr.net/npm/vue@3.4.0/dist/vue.global.prod.js"></script>
<!-- vue-router cdn -->
<script src="https://cdn.jsdelivr.net/npm/vue-router@4.2.0/dist/vue-router.global.prod.js"></script>

五、优化效果复盘与核心指标

按照以上方案优化后,通过Lighthouse检测和打包分析,可实现以下效果:

  • 打包整体体积缩减50%-70%,Gzip压缩后体积进一步减小
  • 首屏加载时间(LCP)从3-5s优化至1s以内
  • 页面运行时无长任务,卡顿率降低90%
  • Lighthouse性能评分从60分提升至90分以上

六、总结与避坑要点

  1. 优化优先级:先排查打包体积 → 再优化首屏加载 → 最后解决运行时卡顿,循序渐进
  2. 避免过度优化:小型项目无需复杂分包,按需配置,避免增加工程复杂度
  3. 兼容性考量:Brotli压缩、WebP图片需确认服务器和客户端兼容性,做好降级方案
  4. 持续监控:项目迭代后定期用打包分析和Lighthouse检测,及时发现新增性能问题

Vue3+Vite项目性能优化没有统一标准,核心是按需加载、减少冗余、提升渲染效率。本文的优化方案均经过线上项目验证,可直接复制代码落地,适合各类Vue3工程化项目参考。

如果觉得本文对你有帮助,欢迎点赞、收藏、评论,后续会持续更新Vue3+Vite实战干货,关注我不迷路~

作者:前端技术博主

链接:本文首发于掘金,转载请注明出处

React vs Vue:两种前端架构哲学的深度解析

作者 小凡同志
2026年3月20日 23:12

React vs Vue:两种前端架构哲学的深度解析

2026年了,React Compiler 已经稳定可用,Vue Vapor Mode 也在 Vue 3.6 中正式亮相。这两个框架的底层逻辑到底有什么不同?

前言:从手动操作到声明式编程

十年前我们还在用 jQuery 手动操作 DOM。

$('#btn').click() 写多了,代码就像意大利面条。后来 Angular 带来了 MVC,React 带来了 Virtual DOM,Vue 把响应式做到了极致。

现在回头看,React 和 Vue 其实代表了两种完全不同的架构思路。理解它们的分歧点,比纠结"哪个更好"更有价值。

一、核心理念:显式控制 vs 自动追踪

React 的哲学:给你控制权

React 的设计理念很简单:开发者知道什么时候该更新

function Counter() {
  const [count, setCount] = useState(0);

  // 你必须显式调用 setCount
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

React 的渲染是"全量"的。每次状态变化,组件函数重新执行,返回新的 JSX。React 再对比新旧 Virtual DOM,算出最小变更。

这种方式的好处是可预测。你写的代码就是执行的逻辑,没有黑魔法。

坏处也明显:优化负担在开发者身上。useMemouseCallbackReact.memo 缺一不可,稍不注意就重复渲染。

Vue 的哲学:我帮你追踪

Vue 的想法相反:框架比开发者更清楚依赖关系

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

<template>
  <button @click="count++">{{ count }}</button>
</template>

Vue 在编译阶段就分析好了模板中的依赖。count 变化时,框架自动定位到具体 DOM 节点,直接更新。

不需要你记一堆优化规则。响应式系统帮你搞定。

关键分歧

维度 React Vue
更新粒度 组件级 细粒度(变量级)
优化责任 开发者 框架
心智模型 显式控制 自动追踪
代码风格 函数式 声明式

二、响应式机制:Pull vs Push

这两个词听起来很抽象,但本质是怎么知道"数据变了"。

React:Pull 模型

React 是 Pull。它不会监听数据变化,而是在渲染时拉取最新值

function User({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]); // 依赖数组靠人工维护

  return <div>{user?.name}</div>;
}

这里有个坑:如果忘了写 [userId],就拿到旧数据。如果写了 [user],就无限循环。

React 的依赖数组是信任开发者。你说没依赖,它就信。

React Compiler 在 2025 年底已经稳定发布。Compiler 能自动推导依赖,不用再手写 useMemo/useCallback

Vue:Push 模型

Vue 是 Push。数据变化主动推送给订阅者。

<script setup>
import { ref, watchEffect } from 'vue'

const userId = ref(1)
const user = ref(null)

// 自动追踪 userId 的依赖
watchEffect(async () => {
  user.value = await fetchUser(userId.value)
})
</script>

watchEffect 会自动收集用到的响应式变量。userId 一变,回调重新执行。

不需要依赖数组。框架帮你追踪。

源码层面的差异

React 的依赖检测在运行时。每次渲染对比上一次的状态。

Vue 的依赖检测在编译时 + 运行时。编译阶段标记响应式变量,运行时通过 Proxy 拦截访问和修改。

// Vue 响应式简化实现
function ref(value) {
  const dep = new Set();

  return new Proxy({ value }, {
    get(target, key) {
      // 收集当前活跃的 effect
      if (activeEffect) dep.add(activeEffect);
      return target[key];
    },
    set(target, key, newVal) {
      target[key] = newVal;
      // 通知所有订阅者
      dep.forEach(effect => effect());
      return true;
    }
  });
}

这套机制让 Vue 能做到精准更新。只有真正用到的数据变了,才会触发更新。

三、2026年的新变量:编译时优化

过去一年,两个框架的编译时优化都有了实质性进展。

React Compiler:自动 memoization

2025年10月,React Compiler 1.0 正式发布。现在是 2026 年,它已经经过了生产环境的验证。

它是个 Babel 插件,编译阶段分析你的代码,自动插入 memoization。不用再手写 useMemo/useCallback

// 以前
function List({ items }) {
  const sorted = useMemo(() =>
    items.sort((a, b) => b.score - a.score),
    [items]
  );
  return <div>{sorted.map(...)}</div>;
}

// 有了 Compiler,直接写
function List({ items }) {
  const sorted = items.sort((a, b) => b.score - a.score);
  return <div>{sorted.map(...)}</div>;
}

Compiler 会把 sorted 编译成条件性 memoized 值。只有 items 真的变了,才重新计算。

实测效果

  • Meta Quest Store:某些交互快 2.5 倍
  • Sanity Studio:渲染时间减少 20-30%
  • Wakelet:LCP 提升 10%,INP 提升 15%

这解决了 React 最大的痛点:优化负担太重。

Vue Vapor Mode:干掉 Virtual DOM

Vue 的回应是 Vapor Mode。2025 年底它在 Vue 3.6 中作为实验性功能发布,现在(2026年3月)已经可以尝试使用。

Vapor Mode 的思路很激进:编译时直接生成 DOM 操作代码,跳过 Virtual DOM

<template>
  <div>{{ count }}</div>
  <button @click="count++">+</button>
</template>

传统 Vue:编译成 Virtual DOM → 运行时 diff → patch DOM

Vapor Mode:编译成直接的 DOM 操作代码

// Vapor Mode 编译结果示意
let _div, _btn;
export function render(_ctx) {
  if (!_div) {
    _div = document.createElement('div');
    _btn = document.createElement('button');
    _btn.onclick = () => _ctx.count++;
  }
  _div.textContent = _ctx.count;
  return [_div, _btn];
}

没有 diff,没有 patch,直接操作 DOM。

性能数据

  • 能在 100ms 内挂载 10 万个组件
  • 目标是匹配 Solid.js 的渲染效率

Vapor Mode 支持混合模式:可以和现有 Virtual DOM 组件共存。你可以只对性能敏感的部分启用 Vapor Mode。

对比总结

特性 React Compiler Vue Vapor Mode
发布状态 2025.10 稳定版 2025 实验性,2026 可用
优化阶段 编译时 编译时
优化方式 自动 memoization 消除 Virtual DOM
向后兼容 React 17+ Vue 3 混合模式
限制 需遵循 React 规则 仅支持 Composition API

四、性能数据:到底谁快?

2024-2025 年的 benchmark 数据:

DOM 操作

Vue 在 DOM 操作任务上比 React 快 36%(几何平均 1.02 vs React)。

初始渲染

  • Vue:中小型 SPA 首屏略快
  • React:大型数据密集型应用扩展性更好

包体积

  • Vue:31KB(gzip)/ 84KB(未压缩)
  • React:32.5KB(gzip)/ 101KB(未压缩)

差距不大,都能接受。

Core Web Vitals

  • Vue:FCP(首次内容绘制)更好
  • React:复杂交互场景表现更优

一个关键结论

性能不是主要差异点。两个框架都足够快。

真正的区别是优化方式

  • React:手动优化 → React Compiler 自动优化
  • Vue:自动优化 → Vapor Mode 极致优化

五、怎么选?

没有标准答案,但有几个参考维度。

选 React,如果你:

  • 团队偏好函数式编程
  • 需要丰富的第三方生态(React 的 npm 包更多)
  • 做复杂交互应用(仪表盘、可视化)
  • 已经投入 Next.js 生态

选 Vue,如果你:

  • 想要开箱即用的体验
  • 团队有后端转前端的成员(模板语法更友好)
  • 需要渐进式迁移(可以先在一个页面用 Vue)
  • 重视性能且不想手动优化

2026年的现状

React Compiler 已经在生产环境证明了价值,优化负担不再是 React 的短板。Vue Vapor Mode 让 Vue 性能更进一步,两者差距在缩小。

两个框架都在进化,差距在缩小。

写在最后

架构选择没有银弹。

React 给你控制权,代价是多写代码。Vue 帮你省代码,代价是接受框架的约束。

2026年,这两条路线已经收敛:React 变得更智能,Vue 变得更高效。

你现在的选择,不会让你后悔。重要的是深入理解你选的框架,而不是来回横跳。

毕竟,用户不关心你用什么框架。他们只关心产品好不好用。


参考来源

文中性能数据来自 2024-2025 年公开 benchmark 报告。

开发环境优化完全指南:告别等待,让开发如丝般顺滑

作者 wuhen_n
2026年3月20日 09:33

前言

想象一下这个场景:

我们正在写一个复杂的组件,思路如泉涌。保存文件,想看看效果:5 秒... 10 秒... 30 秒...

等页面刷新出来的时候,我们已经忘了刚才在想什么。心流被打断,灵感消失,只能重新理清思路。

这不是技术问题,这是对开发者时间的浪费。

根据 Stack Overflow 2023 年的调查,前端开发者平均每天要等待 30 - 60 分钟用于构建和热更新。

好消息是:这些等待时间,大部分都可以被优化掉。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,帮你一步步把开发环境的等待时间从“喝杯咖啡”缩短到“眨个眼”。

为什么会慢?先找到问题在哪

# 早上9点,开始工作
$ npm run dev

# 等待... 30 秒后项目终于启动了
# 打开浏览器,还要等 10 秒才能看到页面

# 修改一个文件,保存
# 等待... 10 秒后热更新完成

# 一天下来:
# 启动次数:10次 × 30 秒 = 300秒
# 修改次数:100次 × 10 秒 = 1500秒
# 总等待时间:1500秒 = 25分钟

这还只是保守估计。在大项目中,等待时间可能是这个数字的 3-5 倍。

开发环境的性能瓶颈

开发环境的速度主要受四个因素影响:

  1. 依赖处理:扫描、预构建 node_modules
  2. 文件编译:转换 .vue.ts.scss 等文件
  3. 模块图维护:跟踪文件之间的依赖关系
  4. 网络传输:浏览器加载文件的速度

如何判断瓶颈在哪?

我们可以使用 Vite 的调试模式:

vite --debug

我们会看到类似这样的输出:

vite:deps 扫描依赖中... 245.3ms
vite:deps 找到 156 个依赖 245.3ms
vite:deps 预构建中... 3240.5ms  ← 这里最慢!
vite:server 服务器启动完成 3512.8ms

根据输出结果,我们就可以做出正确的决断:

  • 如果 预构建 时间最长 → 优化依赖预构建
  • 如果 转换文件 时间最长 → 优化文件编译
  • 如果 服务器启动 时间最长 → 优化配置

依赖预构建优化 - 80%的性能提升从这里开始

什么是依赖预构建?

想象我们要整理一个巨大的图书馆(node_modules):

  • 不预构建:每次有人要看书,都要现场整理那一本书
  • 预构建:提前把所有书整理好,有人要就直接拿

Vite 的预构建就是提前把第三方库整理成浏览器可以直接使用的格式。

为什么需要手动配置预构建?

Vite 默认会自动预构建,但它其实没有那么智能,以下场景,Vite 并不会预构件:

场景1:动态导入

if (user.isAdmin) {
  const Chart = await import('echarts')  // 不会被预构建!
}

场景2:Monorepo 本地包

import { Button } from '@company/ui'  // 不会被预构建!

场景3:深层依赖

import 'a'  // a 依赖 b,b 依赖 c  // c 可能不会被预构建! 

include 优化:告诉 Vite 需要预构建什么

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  
  optimizeDeps: {
    // ✅ 需要预构建的依赖
    include: [
      // 1. 体积大的库(减少请求数)
      'echarts',           // 原来可能有几百个文件,合并成一个
      'lodash-es',         // lodash-es 有 600+ 个文件!
      'ant-design-vue',    // UI 库通常都很大
      
      // 2. Monorepo 中的本地包
      '@company/ui',
      '@company/utils',
      '@company/hooks',
      
      // 3. 动态导入的库
      'monaco-editor',     // 只在需要时加载,但预构建后加载更快
      'xlsx',              // 导出功能可能不常用,但需要时希望快
      
      // 4. 有深层依赖的库
      'date-fns',          // 有很多子模块
      'lodash'             // 虽然不推荐,但如果用了就预构建
    ]
  }
})

exclude 优化:告诉 Vite 不需要预构建什么

// vite.config.js
export default defineConfig({
  optimizeDeps: {
    exclude: [
      // 1. 已经提供 ESM 格式的现代库
      'vue',           // Vue 本身已经优化好
      'vue-router',    // 不需要再打包
      'pinia',
      
      // 2. 很少用到的大库(按需加载更好)
      'pdfjs-dist',    // 只在查看 PDF 时用到
      'three',         // 只在 3D 页面用到
      
      // 3. 有特殊构建要求的库
      '@sentry/browser',  // 有自己的构建工具
      'firebase'          // 复杂的构建配置
    ]
  }
})

include 还是 exclude?一个流程看懂

遇到一个依赖 →
    ↓
是本地包(@company/xxx)? → 是 → include
    ↓否
是动态导入的? → 是 → include
    ↓否
体积 > 1MB? → 是 → include(除非很少用)
    ↓否
依赖深度 > 3层? → 是 → include
    ↓否
已提供 ESM 格式? → 是 → 可以 exclude
    ↓否
用默认行为

实战:如何找出需要 include 的依赖

// scripts/analyze-deps.js
import fs from 'fs'
import path from 'path'

// 分析 node_modules 中哪些包体积大
function findHeavyDeps() {
  const nodeModules = path.resolve('node_modules')
  const deps = fs.readdirSync(nodeModules)
    .filter(d => !d.startsWith('.'))
    .map(dep => {
      const pkgPath = path.join(nodeModules, dep)
      try {
        const stats = fs.statSync(pkgPath)
        return { name: dep, size: stats.size }
      } catch {
        return { name: dep, size: 0 }
      }
    })
    .sort((a, b) => b.size - a.size)
    .slice(0, 20)  // 前20个最大的
  
  console.log('体积最大的依赖:')
  deps.forEach(d => {
    console.log(`${d.name}: ${(d.size / 1024 / 1024).toFixed(2)}MB`)
  })
}

findHeavyDeps()

文件监听优化 - 让电脑知道该看哪

为什么需要优化文件监听?

Vite 默认会监听项目中的所有文件。在大型项目中,这可能会导致很多问题:

  • CPU 占用高:要监控几万个文件的变化
  • 内存占用大:要维护所有文件的状态
  • 更新慢:变化时要检查的文件太多

配置监听范围

// vite.config.js
export default defineConfig({
  server: {
    watch: {
      // ❌ 不要监听这些文件夹
      ignored: [
        '**/node_modules/**',  // 依赖包,不需要监听
        '**/dist/**',          // 构建输出,不需要监听
        '**/.git/**',          // git 目录
        '**/.idea/**',         // IDE 配置
        '**/.vscode/**',       // VSCode 配置
        '**/*.log',            // 日志文件
        '**/coverage/**',      // 测试覆盖率报告
        '**/tests/**',         // 测试文件(通常不需要热更新)
        '**/__tests__/**',     // 同上
        '**/__mocks__/**'      // Mock 文件
      ],
      
      // 只在需要的地方监听
      // 默认会监听整个项目,但我们可以更精确
      paths: [
        'src/**',              // 源代码
        'index.html',          // 入口文件
        'vite.config.js'       // 配置文件
      ]
    }
  }
})

热更新优化 - 从“等 5 秒”到“眨眼就好”

热更新为什么慢?

修改文件
    ↓
Vite 发现变化
    ↓
重新编译这个文件
    ↓
找出所有依赖这个文件的模块(可能很多!)
    ↓
重新编译所有受影响的模块
    ↓
通过 WebSocket 通知浏览器
    ↓
浏览器请求新模块
    ↓
执行更新

优化一:减少模块依赖范围

// 不好的做法:一个文件导入太多东西
// UserManagement.vue
import { useUserStore } from '@/stores/user'
import { usePermissionStore } from '@/stores/permission'
import { useSettingsStore } from '@/stores/settings'
import UserList from './UserList.vue'
import UserForm from './UserForm.vue'
import UserFilters from './UserFilters.vue'
import UserStats from './UserStats.vue'
// ... 20 个 import

// ✅ 好的做法:按需加载,拆分组件
// UserManagement.vue
import { useUserStore } from '@/stores/user'  // 只导入需要的

// 其他组件通过异步加载
const UserList = defineAsyncComponent(() => import('./UserList.vue'))
const UserForm = defineAsyncComponent(() => import('./UserForm.vue'))
const UserFilters = defineAsyncComponent(() => import('./UserFilters.vue'))

优化二:定义热更新边界

// 在组件中明确告诉 Vite 如何处理更新
if (import.meta.hot) {
  // 1. 接受自身更新(默认行为)
  import.meta.hot.accept()
  
  // 2. 只接受某些依赖的更新
  import.meta.hot.accept(['./api.js', './utils.js'], (modules) => {
    console.log('API 或工具函数更新了')
    // 重新执行某些逻辑
  })
  
  // 3. 拒绝更新(某些模块不适合热更新)
  import.meta.hot.decline('./heavy-chart.js')
  
  // 4. 清理资源(更新前执行)
  import.meta.hot.dispose(() => {
    // 清理定时器、事件监听器等
    clearInterval(timer)
    window.removeEventListener('resize', handler)
  })
}

优化三:CSS 热更新优化

// vite.config.js
export default defineConfig({
  css: {
    // 开发时的 CSS 选项
    devSourcemap: false,  // 关闭 sourcemap,加快速度
    
    preprocessorOptions: {
      scss: {
        // 缓存编译结果
        implementation: 'sass',
        // 避免使用 fiber(会导致热更新慢)
        fiber: false,
        // 全局注入变量(只注入需要的)
        additionalData: `@import "@/styles/variables.scss";`
      }
    }
  }
})

优化四:使用更快的编译器

// vite.config.js
export default defineConfig({
  // 使用 esbuild 替代 tsc 进行 TypeScript 转译
  esbuild: {
    target: 'es2020',
    // 启用 esbuild 的 JSX 编译
    jsxFactory: 'h',
    jsxFragment: 'Fragment',
    // 排除不需要转译的文件
    include: /\.(ts|jsx|tsx)$/,
    exclude: /node_modules/
  },
  
  // 生产构建时才使用 TypeScript 检查
  plugins: [
    vue(),
    // 开发环境不检查类型,加快速度
    process.env.NODE_ENV === 'production' && tsChecker()
  ]
})

内存优化 - 让浏览器喘口气

为什么内存占用高?

内存占用主要来自:

  • 模块图:记录所有文件的依赖关系
  • 转换缓存:每个文件转换后的结果
  • sourcemap:调试用的映射信息
  • 浏览器缓存:编译后的代码

配置内存限制

// vite.config.js
export default defineConfig({
  server: {
    // 模块缓存限制
    moduleCache: {
      maxSize: 500  // 最多缓存 500 个模块
    },
    
    // 模块图清理间隔
    moduleGraph: {
      pruneInterval: 60000  // 每 60 秒清理一次未使用的模块
    }
  },
  
  // 开发环境关闭 sourcemap
  build: {
    sourcemap: false
  },
  
  // 限制处理的文件大小
  esbuild: {
    exclude: [/\.(png|jpe?g|gif|webp|mp4|webm|ogg|mp3|wav|flac|aac)$/]
  }
})

内存监控和自动清理

// 在 vite.config.js 中添加内存监控
export default defineConfig({
  plugins: [
    {
      name: 'memory-monitor',
      configureServer(server) {
        let timer = setInterval(() => {
          const used = process.memoryUsage().heapUsed / 1024 / 1024 / 1024
          
          if (used > 1.5) {  // 超过 1.5GB
            console.log(`🧹 内存使用 ${used.toFixed(2)}GB,正在清理...`)
            
            // 清理模块缓存
            server.moduleGraph.clear()
            
            // 强制垃圾回收(如果可用)
            if (global.gc) {
              global.gc()
            }
          }
        }, 60000)  // 每分钟检查一次
        
        // 服务器关闭时清理定时器
        server.httpServer?.on('close', () => {
          clearInterval(timer)
        })
      }
    }
  ]
})

一键优化配置模板

完整的优化配置

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { dependencies } from './package.json'

// 需要预构建的重型依赖
const heavyDeps = [
  'echarts',
  'ant-design-vue',
  'lodash-es',
  'xlsx',
  'monaco-editor',
  'd3',
  'three',
  '@company/ui',
  '@company/utils',
  '@company/charts'
]

// 不需要预构建的现代库
const esmDeps = ['vue', 'vue-router', 'pinia', 'vueuse']

export default defineConfig({
  plugins: [vue()],
  
  // 依赖优化
  optimizeDeps: {
    include: heavyDeps,
    exclude: esmDeps,
    // 使用 esbuild 加速
    esbuildOptions: {
      target: 'es2020',
      define: {
        'process.env.NODE_ENV': '"development"'
      }
    }
  },
  
  // 开发服务器配置
  server: {
    // 启用 HTTP/2 加速请求
    https: true,
    http2: true,
    
    // 文件监听优化
    watch: {
      ignored: [
        '**/node_modules/**',
        '**/dist/**',
        '**/.git/**',
        '**/.idea/**',
        '**/.vscode/**',
        '**/*.log',
        '**/coverage/**',
        '**/tests/**',
        '**/__tests__/**',
        '**/__mocks__/**'
      ]
    },
    
    // 内存优化
    moduleCache: {
      maxSize: 500
    },
    
    // 热更新优化
    hmr: {
      timeout: 5000,
      overlay: false  // 关闭错误覆盖,加快速度
    }
  },
  
  // 编译优化
  esbuild: {
    target: 'es2020',
    include: /\.(ts|jsx|tsx)$/,
    exclude: /node_modules|\.(png|jpe?g|gif|webp|mp4)$/,
    jsxFactory: 'h',
    jsxFragment: 'Fragment'
  },
  
  // CSS 优化
  css: {
    devSourcemap: false,
    preprocessorOptions: {
      scss: {
        implementation: 'sass',
        fiber: false,
        additionalData: `@import "@/styles/variables.scss";`
      }
    }
  }
})

NPM 脚本优化

{
  "scripts": {
    "dev": "vite",
    "dev:debug": "vite --debug",
    "dev:fresh": "rm -rf node_modules/.vite && vite",
    "dev:profile": "vite --profile",
    "build": "vite build",
    "preview": "vite preview",
    "analyze": "node scripts/analyze-deps.js"
  }
}

常见问题速查表

启动很慢

可能原因 解决方案
预构建太多 优化 include 配置
文件监听范围太大 配置 watch.ignored
依赖版本冲突 删除 node_modules 重装
磁盘 I/O 瓶颈 迁移到 SSD

热更新慢

可能原因 解决方案
模块图过大 拆分大组件
没有定义热更新边界 使用 import.meta.hot.accept()
CSS 编译慢 优化预处理器配置
浏览器卡顿 关闭不必要的扩展

内存占用高

可能原因 解决方案
缓存太多 限制 moduleCache.maxSize
没有垃圾回收 添加内存监控和清理
sourcemap 太大 关闭 devSourcemap
内存泄漏 检查插件和代码

优化检查清单

  • 使用 vite --debug 分析启动时间
  • 确认 include 包含所有重型依赖
  • 确认 exclude 排除了已优化的依赖
  • 优化文件监听范围
  • 拆分大文件为小组件
  • 使用虚拟列表处理长列表
  • 启用 HTTP/2
  • 监控内存使用
  • 配置合理的缓存策略

结语

记住:开发者的时间比机器的时间更宝贵。花一个小时优化开发环境,可能每天能为团队节省数小时的等待时间。这是性价比最高的投资之一。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Ant Design Vue 表格组件空数据统一处理 踩坑

作者 28256_
2026年3月20日 18:06

transformCellText

提供 transformCellText 这个表格属性来做数据的处理

transformCellText 数据渲染前可以再次改变,一般用于空数据的默认配置 Function({ text, column, record, index }) => any,此处的 text 是经过其它定义单元格 api 处理后的数据,有可能是 VNode/string/number 类型

数据处理时,都是用text这个属性

划重点

text会有两种情况,这个才是坑的地方

  • 非数组(直接就是要展示的数据)
  • 是个数组(要展示的数据被数组包裹了一层)

text非数组情况


<a-table :dataSource="dataSource" :columns="columns" />

直接简单使用,不使用table组件的插槽,这个时候返回的就是要展示的数据

image.png 可以从图上看出,打印的text的结果

text是个数组


<template>
  <a-table :dataSource="dataSource" :columns="columns" :transformCellText="ssss">
    <template #bodyCell="{ column, record }">
      <template v-if="column.key === 'avatar'">
        <a-avatar :src="record.avatar" :style="{ backgroundColor: '#1890ff' }">
          {{ record.name?.charAt(0) }}
        </a-avatar>
      </template>
    </template>
  </a-table>
</template>

使用了table组件的bodyCell插槽,这个时候要展示的数据被数组包裹了一层

image.png 可以从图上看出,打印的text被数组包裹了一层

实践方案

既然text会有两种情况,就可以从两种情况下手,完成我们的需求

// 当返回的类型是VNode时,不用特殊处理,因为VNode是自定义的dom 直接渲染
const handleTransform = ({ text }) => {

  const isEmpty = val => val === null || val === undefined || val === ''

  const target = Array.isArray(text) ? (text.length > 0 ? text[0] : undefined) : text

  return isEmpty(target) ? '--' : text
}

Vite 核心原理:ESM 带来的开发时“瞬移”体验

作者 wuhen_n
2026年3月19日 10:18

前言

还记得用Webpack开发时的日常吗? 控制台输入 npm run dev ,等待 30 秒后项目终于启动了 ;过了一会儿,修改了一个文件,保存,等待 10 秒之后热更新完成;后来项目变大了,每次保存要等 20 秒以上...

这是 Webpack 时代的真实写照,而 Vite 的出现,彻底改变了这一切: 控制台输入 npm run dev ,1 秒后项目就启动了;修改了一个文件,保存,50ms 页面就更新了。

Vite是怎么做到的? 它不是魔法,而是巧妙地利用了现代浏览器的原生能力。本文将从最基础的概念讲起,带领我们一步步理解 Vite 的核心原理。

为什么传统构建工具这么慢?

Webpack的工作方式

Webpack 就像我们去参加宴席,必须要等酒店把所有的菜品都准备好,再一次性全部端上来;如果有一道菜没做好,我们就全部得等着:

Webpack的打包过程:
1. 找到入口文件 (main.js)
2. 解析import语句,找出所有依赖
3. 递归解析所有依赖的依赖
4. 把所有文件打包成一个bundle.js
5. 启动开发服务器
6. 浏览器加载bundle.js

随着项目越大,依赖越多,打包就会越慢。

为什么Webpack会越来越慢?

假如我们有这样一个项目结构:

project
├── vue (100个文件)
├── vue-router (50个文件)
├── pinia (30个文件)
├── element-plus (500个文件)
├── 你自己的组件 (200个文件)
└── 各种第三方库 (300个文件)

Webpack 启动时要处理 1180 个文件,并全部打包成一个文件,才能启动开发服务器。

ESM 基础:现代浏览器的模块系统

什么是ES Module?

在 ES Module 出现之前,我们是这样引入 JavaScript 的:

<!-- 老方式:必须按顺序,否则报错 -->
<script src="jquery.js"></script>
<script src="lodash.js"></script>
<script src="app.js"></script>

有了 ES Module 之后,我们可以这样写:

<script type="module">
  // 浏览器会自动加载这些依赖
  import $ from 'https://unpkg.com/jquery'
  import _ from 'https://unpkg.com/lodash'
  import app from './app.js'
</script>

浏览器如何加载ES Module?

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')

当浏览器遇到这个脚本时,会进行以下操作:

第1步:下载 main.js
     ↓
第2步:解析 main.js,发现需要 vue、App.vue、router
     ↓
第3步:同时下载 vue、App.vue、router (并行下载)
     ↓
第4步:解析 router.js,发现新的依赖
     ↓
第5步:继续下载新的依赖
     ↓
直到所有依赖都加载完成

而且,浏览器可以并行下载多个文件,互不影响。

ESM的核心特性

特性1:静态导入(编译时确定依赖)

import { ref } from 'vue'  // 打包工具可以静态分析

特性2:动态导入(运行时加载)

if (user.isAdmin) {
  const adminPanel = await import('./AdminPanel.vue')
  // 只有在需要时才加载
}

特性3:模块作用域

// a.js
const name = 'module-a'
export { name }

// b.js
const name = 'module-b'  // 同名变量,互不干扰
export { name }

Vite 的核心思想 - 让浏览器做它擅长的事

Vite 的开发服务器

Vite 的开发服务器做了什么?

// 简化的Vite服务器
class ViteDevServer {
  constructor() {
    this.app = require('koa')()  // HTTP服务器
    this.watcher = require('chokidar').watch('src')  // 文件监听
  }
  
  async start() {
    // 1. 启动HTTP服务器
    this.app.listen(3000)
    
    // 2. 注册中间件
    this.app.use(this.transformMiddleware())
    
    // 3. 开始监听文件变化
    this.watcher.on('change', this.handleFileChange.bind(this))
  }
  
  // 处理文件请求
  async transformMiddleware(ctx, next) {
    if (ctx.path.endsWith('.vue')) {
      // 当浏览器请求 .vue 文件时,才进行编译
      const code = await compileVueFile(ctx.path)
      ctx.body = code
    }
  }
}

Vite的启动流程

传统方式(Webpack):
启动 → 打包所有文件 → 启动服务器 → 浏览器请求 → 返回打包后的文件

Vite方式:
启动 → 启动服务器 → 浏览器请求 → 按需编译 → 返回单个文件

还是用餐厅来比喻:

  • Webpack:客人来之前做好所有菜;如果菜没做好,所有客人都得等着
  • Vite:客人点一道,做一道;做好一道,上一道

一个完整的请求流程

假设我们的项目结构是这样的:

src/
├── main.js
├── App.vue
└── components/
    └── HelloWorld.vue

浏览器访问页面的过程如下:

// 第1步:浏览器请求 index.html
GET /index.html

// index.html 内容
<!DOCTYPE html>
<html>
  <head>
    <script type="module" src="/src/main.js"></script>
  </head>
</html>

// 第2步:浏览器发现需要 main.js
GET /src/main.js

// main.js 内容
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

// 第3步:浏览器发现需要 vue 和 App.vue
GET /@modules/vue  // Vite 特殊处理
GET /src/App.vue

// 第4步:App.vue 中又引用了 HelloWorld.vue
GET /src/components/HelloWorld.vue

// 第5步:全部加载完成,页面显示

依赖预构建 - 解决性能瓶颈

如果没有预构建,会有什么问题?

问题1:CommonJS 模块无法在浏览器直接运行

import _ from 'lodash'  // lodash 是 CommonJS 格式,浏览器不认识

问题2:大量小文件请求

import { debounce } from 'lodash-es'
// lodash-es 有 600 多个文件!
// 浏览器要发 600 多个请求!

问题3:深度嵌套的依赖

import A from 'package-a'
// package-a 依赖 package-b
// package-b 依赖 package-c
// 每个包都要单独请求

预构建做了什么?

  1. 扫描项目中的所有 import
  2. 找出第三方依赖(不是相对路径的)
  3. esbuild 打包成单个文件
  4. 存到 node_modules/.vite/
  5. 下次直接使用打包后的文件

esbuild 为什么这么快?

  1. 用 Go 语言写的(直接编译成机器码)
  2. 充分利用 CPU 多核
  3. 一切从零设计,没有历史包袱
  4. 高度并行化

热更新 - 瞬间响应的秘密

热更新模式

修改代码 → 页面自动更新 → 状态保持不变 → 继续工作

热更新的工作原理

我们修改了一个文件
    ↓
Vite 监听到文件变化
    ↓
重新编译这个文件
    ↓
通过 WebSocket 通知浏览器
    ↓
浏览器请求更新的文件
    ↓
执行热更新回调
    ↓
页面局部更新,状态保留

WebSocket 通信

// 服务器端
class HMRServer {
  constructor(server) {
    // 创建 WebSocket 服务
    this.ws = new WebSocket.Server({ server })
    
    // 所有连接的客户端
    this.clients = new Set()
    
    this.ws.on('connection', (socket) => {
      this.clients.add(socket)
      
      socket.on('close', () => {
        this.clients.delete(socket)
      })
    })
  }
  
  // 文件变化时通知所有客户端
  sendUpdate(file) {
    const message = JSON.stringify({
      type: 'update',
      file: file,
      timestamp: Date.now()
    })
    
    this.clients.forEach(client => {
      client.send(message)
    })
  }
}

// 浏览器端
const socket = new WebSocket(`ws://${location.host}`)

socket.onmessage = async ({ data }) => {
  const { type, file, timestamp } = JSON.parse(data)
  
  if (type === 'update') {
    // 重新加载修改的文件
    const module = await import(`${file}?t=${timestamp}`)
    
    // 执行热更新
    if (import.meta.hot) {
      import.meta.hot.accept(file, module)
    }
  }
}

Vue 组件的热更新

// Vue 组件的热更新实现
if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    // 更新组件
    const { render, data } = newModule
    
    // 保留当前组件的状态
    const oldData = instance.data
    
    // 应用新的渲染函数
    instance.render = render
    
    // 重新渲染
    instance.update()
  })
}

插件系统:Vite 的扩展能力

插件的工作流程

请求进入
    ↓
resolveId(解析模块 ID)
    ↓
load(加载模块内容)
    ↓
transform(转换代码)
    ↓
返回给浏览器

插件的钩子函数

// 一个完整的 Vite 插件
const myPlugin = {
  name: 'vite:my-plugin',
  
  // 构建阶段钩子
  options(options) {
    // 修改或扩展配置
    return options
  },
  
  buildStart() {
    // 构建开始时调用
    console.log('构建开始')
  },
  
  // 解析模块 ID
  resolveId(source, importer) {
    if (source === 'virtual-module') {
      return '\0virtual-module' // \0 标记为虚拟模块
    }
  },
  
  // 加载模块
  load(id) {
    if (id === '\0virtual-module') {
      return 'export default "virtual module content"'
    }
  },
  
  // 转换代码
  async transform(code, id) {
    if (id.endsWith('.special')) {
      // 转换特殊文件格式
      const result = await compileSpecial(code)
      return {
        code: result.js,
        map: result.sourcemap
      }
    }
  },
  
  // 配置解析完成后
  configResolved(config) {
    console.log('配置已解析', config)
  },
  
  // 热更新处理
  handleHotUpdate(ctx) {
    // 自定义热更新逻辑
  },
  
  // 构建结束
  buildEnd() {
    console.log('构建结束')
  },
  
  // 关闭服务
  closeBundle() {
    console.log('服务关闭')
  }
}

常用插件示例

// 环境变量注入插件
function injectEnvPlugin(env: Record<string, string>) {
  return {
    name: 'vite:inject-env',
    
    transform(code, id) {
      if (id.includes('node_modules')) return
      
      // 替换环境变量
      return code.replace(
        /import\.meta\.env\.(\w+)/g,
        (_, key) => JSON.stringify(env[key])
      )
    }
  }
}

// 文件大小监控插件
function sizeMonitorPlugin() {
  return {
    name: 'vite:size-monitor',
    
    generateBundle(_, bundle) {
      Object.entries(bundle).forEach(([name, asset]) => {
        if (asset.type === 'chunk') {
          const size = asset.code.length
          const kb = (size / 1024).toFixed(2)
          
          if (size > 100 * 1024) {
            console.warn(`⚠️ 大文件警告: ${name} (${kb}KB)`)
          } else {
            console.log(`✅ ${name}: ${kb}KB`)
          }
        }
      })
    }
  }
}

Vite vs Webpack

启动时间对比

项目规模 Webpack Vite 差距
小项目(50组件) 8.5秒 1.2秒 Vite快7倍
中项目(200组件) 22秒 2.1秒 Vite快10倍
大项目(1000组件) 58秒 3.8秒 Vite快15倍

热更新时间对比

操作 Webpack Vite 差距
修改一个组件 2.8秒 45ms Vite快62倍
修改CSS 1.5秒 8ms Vite快187倍
保存后恢复 3.1秒 60ms Vite快52倍

资源消耗对比

指标 Webpack Vite 差距
CPU占用 45% 18% 降低60%
内存占用 1.8GB 420MB 降低77%
电池消耗 延长2-3倍

常见问题与优化技巧

问题一:依赖预构建失效

修改了 node_modules 里的代码,但是不生效:

解决方案1:强制重新预构建

// vite.config.ts
export default {
  optimizeDeps: {
    // 强制重新预构建
    force: true
  }
}

解决方案2:删除缓存目录

$ rm -rf node_modules/.vite

解决方案3:重启开发服务器

npm run dev

问题二:热更新不生效

修改了文件,但页面不更新,可以按以下步骤排查:

步骤1:检查 WebSocket 连接

打开浏览器控制台,看是否有 WebSocket 连接。

步骤2:检查文件监听配置

export default {
  server: {
    watch: {
      // 确保没有忽略我们的文件
      ignored: ['!**/node_modules/**']
    }
  }
}

步骤3:手动触发更新

if (import.meta.hot) {
  import.meta.hot.accept()
}

问题三:首次加载慢

第一次打开页面要等很久。

解决方案:预加载关键路由

export default {
  optimizeDeps: {
    include: [
      // 预构建这些依赖
      'vue',
      'vue-router',
      'pinia',
      // 你的常用组件
      'src/components/Button.vue',
      'src/components/Modal.vue'
    ]
  }
}

问题四:内存占用过高

// vite.config.ts
export default {
  server: {
    // 限制缓存大小
    moduleCache: {
      maxSize: 100 * 1024 * 1024 // 100MB
    },
    
    // 清理未使用的模块
    moduleGraph: {
      pruneInterval: 60000 // 每 60 秒清理一次
    }
  }
}

Vite 的最佳实践

Vite 配置文件模板

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  // 插件
  plugins: [vue()],
  
  // 开发服务器配置
  server: {
    port: 3000,
    open: true,  // 自动打开浏览器
    proxy: {
      '/api': 'http://localhost:8080'  // 代理
    }
  },
  
  // 构建配置
  build: {
    target: 'es2020',
    outDir: 'dist',
    assetsDir: 'assets',
    sourcemap: true
  },
  
  // 依赖优化
  optimizeDeps: {
    include: ['vue', 'vue-router', 'pinia']
  },
  
  // 别名
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  }
})

性能优化清单

  • 依赖预构建:配置 optimizeDeps.include 预构建常用依赖
  • 路由懒加载:使用动态 import() 分割代码
  • 图片优化:使用 vite-plugin-image-optimizer
  • CSS 提取:生产环境提取独立 CSS 文件
  • Gzip 压缩:使用 vite-plugin-compression

学习要点

  1. 理解 ESM 的核心特性:静态导入、模块作用域、浏览器加载机制
  2. 掌握依赖预构建的作用:解决 CommonJS 兼容性、减少请求数
  3. 熟悉热更新的工作流程:WebSocket 通信、模块边界、HMR API
  4. 学会编写 Vite 插件:钩子函数、虚拟模块、代码转换
  5. 能够诊断和优化性能问题:预构建失效、热更新慢、内存占用高

结语

Vite 的出现,标志着前端构建工具从打包时代进入了原生 ESM 时代。理解它的核心原理,不仅能让我们更高效地使用它,更能让我们对现代前端开发有更深的理解。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Cesium 海量点位不卡顿!图标动态聚合效果深度解析,看完直接抄代码!

作者 李剑一
2026年3月19日 09:45

接上文# 告别冗余代码!Cesium点位图标模糊、重叠?自适应参数调优攻略,一次封装终身复用!,在地图上创建图标是基础操作,但是当地图上的图标过多的时候展示效果其实并不好。

毕竟谁也不想看到密密麻麻的图标,所以部分距离相近的图标应该聚合在一起,形成一个聚合图标展示出来。

image.png

在Cesium开发中,图标聚合能够解决海量图标重叠、界面杂乱、性能卡顿等问题。

尤其在智慧安防、智慧园区、设备监控等场景,几十个甚至上百个摄像头/设备图标挤在一块,不仅看不清,还会严重影响地图流畅度。

解决方案

通过监听相机高度,高度超过阈值,自动开启聚合。

根据计算屏幕像素距离,把三维坐标转成屏幕坐标,算两点多远,距离小于设定值,归为一组。

image.png

这时候隐藏原始图标,只显示聚合图标。

生成聚合点:显示图标+数量,拉近后自动散开。

实现代码

计算屏幕距离 + 判断是否在屏幕内。是聚合的核心基础:把三维坐标转屏幕坐标,再算距离。

/**
 * 计算两点在屏幕上的像素距离
 */
const calculateScreenDistance = (pos1, pos2) => {
    if (!viewer.value || !viewer.value.scene) return Infinity
    
    const scene = viewer.value.scene
    try {
        // 世界坐标 → 屏幕坐标
        const screenPos1 = Cesium.SceneTransforms.worldToWindowCoordinates(scene, pos1)
        const screenPos2 = Cesium.SceneTransforms.worldToWindowCoordinates(scene, pos2)
        
        if (!screenPos1 || !screenPos2) return Infinity
        
        // 勾股定理算像素距离
        const dx = screenPos1.x - screenPos2.x
        const dy = screenPos1.y - screenPos2.y
        return Math.sqrt(dx * dx + dy * dy)
    } catch (error) {
        return Infinity
    }
}

/**
 * 检查点是否在屏幕上可见
 */
const isPositionOnScreen = (position) => {
    if (!viewer.value || !viewer.value.scene) return false
    try {
        const screenPos = Cesium.SceneTransforms.worldToWindowCoordinates(viewer.value.scene, position)
        return screenPos != null
    } catch (error) {
        return false
    }
}

生成聚合点,图标更大、创建label显示当前标签数量更明显。

/**
 * 创建聚合图标
 */
const createClusterIcon = (clusterData) => {
    if (!viewer.value) return null
    const { icons, type, center } = clusterData
    const count = icons.length

    // 坐标转换
    const cartographic = Cesium.Cartographic.fromCartesian(center)
    const longitude = Cesium.Math.toDegrees(cartographic.longitude)
    const latitude = Cesium.Math.toDegrees(cartographic.latitude)

    // 创建聚合实体
    const clusterId = `cluster_${type}_${Date.now()}`
    const entity = viewer.value.entities.add({
        id: clusterId,
        position: center,
        billboard: {
            image: getClusterIconUrl(type),
            scale: 1.2,
            width: 40,
            height: 40,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            disableDepthTestDistance: Number.POSITIVE_INFINITY
        }
    })

    // 聚合数量标签
    const typeName = getTypeDisplayName(type)
    entity.label = {
        text: `${typeName} ${count}个`,
        font: '14px sans-serif',
        fillColor: Cesium.Color.WHITE,
        outlineColor: Cesium.Color.BLACK,
        outlineWidth: 2,
        pixelOffset: new Cesium.Cartesian2(0, -50),
        showBackground: true,
        disableDepthTestDistance: Number.POSITIVE_INFINITY
    }

    // 存入聚合列表
    clusterEntities.set(clusterId, { entity, icons, type, center })
    return entity
}

动态计算聚合阈值,通过遍历图标 → 分组 → 合并/显示,自动隐藏原始图标,显示聚合点。

/**
 * 更新图标聚合状态
 */
const updateClustering = () => {
    if (!viewer.value || iconEntities.size === 0) return
    clearClusters()

    // 关闭聚合 = 显示全部
    if (!isClusteringEnabled.value) {
        showAllIcons()
        return
    }

    // 动态阈值:相机越高,聚合越明显
    const cameraHeight = viewer.value.camera.positionCartographic.height
    const dynamicClusterDistance = Math.min(
        MAX_SCREEN_CLUSTER_DISTANCE,
        SCREEN_CLUSTER_DISTANCE + (cameraHeight - CLUSTER_THRESHOLD) / 50
    )

    // 收集所有图标
    const allIcons = []
    iconEntities.forEach((iconData, id) => {
        const position = iconData.entity.position.getValue(Cesium.JulianDate.now())
        allIcons.push({ id, entity: iconData.entity, position, type: iconData.type })
    })

    // 先隐藏所有图标
    allIcons.forEach(icon => icon.entity.show = false)

    // 聚类算法
    const clusters = []
    const visited = new Set()

    for (let i = 0; i < allIcons.length; i++) {
        if (visited.has(i)) continue
        const current = allIcons[i]
        if (!isPositionOnScreen(current.position)) continue

        const cluster = [current]
        visited.add(i)

        // 寻找附近图标
        for (let j = i + 1; j < allIcons.length; j++) {
            if (visited.has(j)) continue
            const other = allIcons[j]
            if (!isPositionOnScreen(other.position)) continue

            const dist = calculateScreenDistance(current.position, other.position)
            if (dist <= dynamicClusterDistance) {
                cluster.push(other)
                visited.add(j)
            }
        }
        clusters.push(cluster)
    }

    // 生成聚合点 / 显示单个图标
    clusters.forEach(cluster => {
        if (cluster.length === 1) {
            cluster[0].entity.show = true
        } else {
            // 计算中心点
            let centerX = 0, centerY = 0, centerZ = 0
            cluster.forEach(icon => {
                centerX += icon.position.x
                centerY += icon.position.y
                centerZ += icon.position.z
            })
            const center = new Cesium.Cartesian3(
                centerX / cluster.length,
                centerY / cluster.length,
                centerZ / cluster.length
            )

            createClusterIcon({
                icons: cluster.map(c => c.id),
                type: 'camera',
                center
            })
        }
    })
}

总结

Cesium 图标聚合原理上很简单:

算距离 → 分组 → 隐藏/显示 → 生成聚合点

在园区级别的模型上其实启不启用影响不大,但是在城市级别,或者是多地区复杂情况的模型上还是有必要的。

能够极大的提升加载的流畅度,减少操作的卡顿。

生产环境极致优化:拆包、图片压缩、Gzip/Brotli 完全指南

作者 wuhen_n
2026年3月20日 10:53

前言

当我们的应用从开发环境走向生产环境,真正的挑战才刚刚开始。用户不会关心我们的代码写得多么优雅,他们只关心页面加载快不快、交互流不流畅。一个未经优化的生产构建,可能让我们的用户在第一秒就流失。

为什么要优化生产构建?

一个真实的反面教材

我们先来看一个系统打包后的产物:

dist/
├── index.html                5KB
├── assets/index.abc123.js    2.8MB  ← 一个文件包含了所有代码
├── assets/vendor.def456.js   1.2MB  ← 第三方库
├── assets/style.ghi789.css   180KB
└── images/
    ├── logo.png              120KB  ← 未压缩
    ├── banner.jpg            850KB  ← 巨大
    └── ...

当用户访问这个系统时:

  • 下载 2.8MB + 1.2MB + 180KB + 970KB = 约 5MB
  • 4G 网络下需要 2 秒;3G 网络会更慢
  • 用户早跑了

构建优化的核心目标

优化维度 目标 收益
拆包优化 分离业务代码和第三方库 利用浏览器缓存,二次访问提速
图片压缩 减少图片体积 平均减少 60-80% 体积
Gzip/Brotli 压缩文本资源 减少 70-90% 传输体积
长期缓存 文件名哈希,内容变化才更新 最大化缓存利用率

优化能带来什么?

指标 优化前 优化后 提升
首屏 JS 体积 4.2 MB 2.1 MB 50%
图片总体积 2.8 MB 0.6 MB 78%
传输体积(Gzip后) 3.2 MB 0.8 MB 75%
首次加载时间 3.2 秒 1.1 秒 65%
二次加载时间 2.1 秒 0.3 秒 85%

先诊断,后开药 - 构建分析工具

为什么要先分析?

就像医生看病要先做检查一样,优化构建也要先找到问题在哪。在主观上,我们可能会觉得是不是某个依赖太大了?但实际上可能是另一个我们没想到的库!

使用 rollup-plugin-visualizer 分析

安装

npm install --save-dev rollup-plugin-visualizer

配置

// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default {
  plugins: [
    visualizer({
      filename: 'dist/stats.html',  // 输出文件
      open: true,                   // 构建后自动打开
      gzipSize: true,                // 显示 gzip 后大小
      brotliSize: true,              // 显示 brotli 后大小
      template: 'treemap'            // 图表类型: treemap, sunburst, network
    })
  ]
}

运行构建

npm run build
// 浏览器会自动打开一个酷炫的图表
// 一眼就能看出哪些文件最大

使用 vite-bundle-visualizer 分析

安装

npm install --save-dev vite-bundle-visualizer

运行分析

npx vite-bundle-visualizer

输出示例

┌───────────────────────┬─────────────┬──────────┬───────┐
│       Module          │    Size     │  Gzip    │ Brotli│
├───────────────────────┼─────────────┼──────────┼───────┤
│ node_modules/         │ 2.3 MB      │ 680 KB   │ 520 KB│
│   vue/                │ 680 KB      │ 210 KB   │ 160 KB│
│   element-plus/       │ 890 KB      │ 280 KB   │ 210 KB│
│   echarts/            │ 520 KB      │ 150 KB   │ 115 KB│
│   lodash-es/          │ 210 KB      │ 62 KB    │ 48 KB │
│ src/                  │ 1.8 MB      │ 480 KB   │ 360 KB│
└───────────────────────┴─────────────┴──────────┴───────┘

自定义分析脚本

// scripts/analyze.js
import fs from 'fs'
import path from 'path'
import { gzipSizeSync } from 'gzip-size'
import { brotliSizeSync } from 'brotli-size'

function analyzeDist() {
  const distDir = path.resolve('./dist/assets')
  const files = fs.readdirSync(distDir)
  
  let totalSize = 0
  let totalGzip = 0
  let totalBrotli = 0
  
  console.log('📦 构建产物分析\n')
  
  files
    .filter(f => f.endsWith('.js') || f.endsWith('.css'))
    .forEach(file => {
      const filePath = path.join(distDir, file)
      const content = fs.readFileSync(filePath)
      const size = content.length
      const gzip = gzipSizeSync(content)
      const brotli = brotliSizeSync(content)
      
      totalSize += size
      totalGzip += gzip
      totalBrotli += brotli
      
      console.log(`${file}:`)
      console.log(`  Raw:    ${(size / 1024).toFixed(2)} KB`)
      console.log(`  Gzip:   ${(gzip / 1024).toFixed(2)} KB (${(gzip/size*100).toFixed(0)}%)`)
      console.log(`  Brotli: ${(brotli / 1024).toFixed(2)} KB (${(brotli/size*100).toFixed(0)}%)\n`)
    })
  
  console.log('📊 总计:')
  console.log(`  Raw:    ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
  console.log(`  Gzip:   ${(totalGzip / 1024 / 1024).toFixed(2)} MB`)
  console.log(`  Brotli: ${(totalBrotli / 1024 / 1024).toFixed(2)} MB`)
}

analyzeDist()

看懂分析结果

分析结果能告诉我们什么?

1. 找出最大的依赖

  • echarts: 520KB → 考虑按需加载
  • monaco-editor: 2.8MB → 考虑动态导入

2. 找出重复的依赖

  • lodash 和 lodash-es 同时存在? → 统一用 lodash-es
  • moment 和 dayjs 同时存在? → 用 dayjs 替代 moment

3. 找出可以拆分的点

  • node_modules 打包在一起太大了 → 拆成多个 chunk
  • 所有页面代码都在一个文件里 → 按路由拆分

拆包策略 - 把大象放进冰箱

为什么要拆包?

用一个比喻来解释

不拆包:把所有东西都塞进一个行李箱
├─ 想拿牙刷 → 要翻遍整个箱子
├─ 箱子破了 → 所有东西都掉出来
└─ 箱子太大 → 搬不动

拆包:分成多个小包
├─ 洗漱包:牙刷、牙膏、毛巾
├─ 衣物包:衣服、裤子、袜子
├─ 电子包:充电器、数据线
├─ 哪个包破了 → 只损失那部分
└─ 每个包都很轻 → 好搬

技术层面的好处

不拆包:
├─ 修改一行代码 → 整个大文件缓存失效
└─ 用户每次更新都要重新下载所有代码

拆包后:
├─ 第三方库独立 → 几乎不变,长期缓存
├─ 业务代码拆分 → 只下载修改的部分
└─ 多个小文件可以并行下载

基础拆包配置

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 最基本的拆包策略
        manualChunks: {
          // 将 Vue 全家桶打包在一起
          'vendor-vue': ['vue', 'vue-router', 'pinia', 'vuex'],
          
          // 将 UI 库打包在一起
          'vendor-ui': ['element-plus', '@element-plus/icons-vue', 'ant-design-vue'],
          
          // 将工具库打包在一起
          'vendor-utils': ['lodash-es', 'dayjs', 'axios', 'date-fns'],
          
          // 将图表库打包在一起
          'vendor-charts': ['echarts', 'd3', 'chart.js']
        }
      }
    }
  }
}

智能拆包:根据依赖关系自动拆分

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id: string) {
          // node_modules 中的依赖
          if (id.includes('node_modules')) {
            // 按包名拆分
            if (id.includes('vue')) {
              return 'vendor-vue'  // 所有 vue 相关
            }
            
            if (id.includes('element-plus') || id.includes('antd')) {
              return 'vendor-ui'   // UI 库
            }
            
            if (id.includes('echarts') || id.includes('d3')) {
              return 'vendor-charts' // 图表库
            }
            
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'  // 工具库
            }
            
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'  // 编辑器单独打包
            }
            
            // 其他依赖打包在一起
            return 'vendor-other'
          }
          
          // 业务代码按页面拆分
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) {
              return `page-${match[1]}` // 按页面拆分
            }
          }
          
          // 公共组件按模块拆分
          if (id.includes('/src/components/')) {
            const match = id.match(/\/src\/components\/([^\/]+)/)
            if (match) {
              return `components-${match[1]}`
            }
          }
        }
      }
    }
  }
}

高级拆包:基于大小的自动拆分

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id: string, { getModuleInfo }) {
          // 如果模块大于 500KB,单独拆包
          const moduleInfo = getModuleInfo(id)
          if (moduleInfo && moduleInfo.code) {
            const size = Buffer.byteLength(moduleInfo.code, 'utf8')
            if (size > 500 * 1024) { // 500KB
              const name = id.match(/[^/]+\.(js|ts|vue)$/)?.[0]
              return `large-${name}`  // 大文件单独打包
            }
          }
          
          // 继续其他拆分逻辑
          if (id.includes('node_modules')) {
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus')) return 'vendor-ui'
          }
        }
      }
    }
  }
}

异步 chunk 的命名优化

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 异步 chunk 命名
        chunkFileNames: 'assets/chunks/[name]-[hash].js',
        
        // 入口文件命名
        entryFileNames: 'assets/[name]-[hash].js',
        
        // 资源文件命名
        assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
        
        manualChunks: {
          // ... 拆包配置
        }
      }
    }
  }
}

// 输出结果:
// assets/index-abc123.js                (入口)
// assets/chunks/vendor-vue-def456.js    (Vue 相关)
// assets/chunks/page-dashboard-ghi789.js (页面)
// assets/images/logo-jkl012.png         (图片)

拆包后的效果

拆包方式 文件数量 缓存利用率 适用场景
不拆包 1个 极低 小项目
按依赖拆分 5-10个 中大型项目
按页面拆分 10-50个 较高 多页面应用
按大小拆分 可变 中等 有大文件的项目

图片压缩 - 看不见的优化

为什么图片是优化重点?

我们先来看一个典型的页面资源分布:

const pageResources = {
  js: '2.8MB (40%)',
  css: '180KB (3%)',
  images: '3.5MB (50%)',  // 图片占了一半!
  fonts: '500KB (7%)'
}

在页面中,图片通常占页面总体积的 50-70%,因此优化图片是最容易见效的!

vite-plugin-image-optimizer 配置

安装

npm install --save-dev vite-plugin-image-optimizer

配置

// vite.config.ts
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'

export default {
  plugins: [
    ViteImageOptimizer({
      // 配置文件类型和压缩参数
      png: {
        quality: 80,  // PNG 质量 0-100
        compressionLevel: 9, // 压缩级别 0-9
      },
      jpeg: {
        quality: 75,  // JPEG 质量
        progressive: true, // 渐进式 JPEG
      },
      jpg: {
        quality: 75,
      },
      webp: {
        quality: 75,  // WebP 质量
        lossless: false, // 是否无损
      },
      avif: {
        quality: 60,  // AVIF 质量
        lossless: false,
      },
      svg: {
        // SVG 优化选项
        plugins: [
          {
            name: 'preset-default',
            params: {
              overrides: {
                removeViewBox: false, // 保留 viewBox
                cleanupIds: false,     // 保留 ID
              },
            },
          },
        ],
      },
      tiff: {
        quality: 70,
      },
      gif: {
        optimizationLevel: 3, // 优化级别 1-3
      },
    })
  ]
}

不同图片类型的优化策略

// vite.config.ts
export default {
  plugins: [
    ViteImageOptimizer({
      // 根据不同用途设置不同参数
      
      // 1. 图标类:需要清晰,适当压缩
      'src/assets/icons/**/*': {
        png: { quality: 90 },
        svg: { plugins: ['preset-default'] }
      },
      
      // 2. 背景图:可以牺牲一些质量换取体积
      'src/assets/backgrounds/**/*': {
        jpeg: { quality: 65 },
        webp: { quality: 60 }
      },
      
      // 3. 产品图:平衡质量和体积
      'src/assets/products/**/*': {
        jpeg: { quality: 80 },
        webp: { quality: 75 }
      },
      
      // 4. 用户上传:保持较好质量
      'src/assets/uploads/**/*': {
        jpeg: { quality: 85 },
        png: { quality: 85 }
      }
    })
  ]
}

使用现代图片格式

配置

// vite.config.ts
export default {
  plugins: [
    ViteImageOptimizer({
      // 生成 WebP 版本(浏览器支持更好)
      webp: {
        quality: 75
      },
      
      // 生成 AVIF 版本(压缩率更高)
      avif: {
        quality: 60
      }
    })
  ]
}

在组件中配合使用

<template>
  <!-- picture 元素让浏览器选择最佳格式 -->
  <picture>
    <!-- 现代浏览器优先使用 AVIF -->
    <source srcset="/image.avif" type="image/avif">
    <!-- 其次使用 WebP -->
    <source srcset="/image.webp" type="image/webp">
    <!-- 降级到 JPEG -->
    <img src="/image.jpg" alt="图片" loading="lazy">
  </picture>
</template>

懒加载与图片优化结合

<template>
  <img 
    v-lazy="optimizedImageUrl"
    :data-srcset="`
      ${smallImage} 400w,
      ${mediumImage} 800w,
      ${largeImage} 1200w
    `"
    sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
    loading="lazy"
    :alt="alt"
  >
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps<{ 
  imagePath: string,
  alt?: string 
}>()

// 根据视图宽度选择合适大小的图片
const optimizedImageUrl = computed(() => {
  // 假设构建时生成了不同尺寸的图片
  // logo-small.jpg, logo-medium.jpg, logo-large.jpg
  const width = typeof window !== 'undefined' ? window.innerWidth : 1200
  
  if (width < 600) {
    return props.imagePath.replace(/\.(jpg|png)$/, '-small.$1')
  }
  if (width < 1200) {
    return props.imagePath.replace(/\.(jpg|png)$/, '-medium.$1')
  }
  return props.imagePath.replace(/\.(jpg|png)$/, '-large.$1')
})
</script>

图片优化的效果

图片类型 优化前 优化后 节省
PNG 图标 120KB 35KB 71%
JPG 产品图 850KB 180KB 79%
WebP 背景 650KB 110KB 83%
SVG 矢量 15KB 8KB 47%
总体积 2.8MB 0.6MB 78%

Gzip/Brotli 压缩 - 让传输更轻盈

什么是 Gzip/Brotli?

我们可以用快递来比喻,比如我们有一件很大的“羽绒服”要邮寄给浏览器:

  • 原始文件:一件羽绒服(很大,但很轻)
  • Gzip:真空压缩袋,把羽绒服压扁
  • Brotli:更好的真空压缩袋,压得更扁

当浏览器收到压缩后的文件,它只需要打开压缩袋,羽绒服(文件)就可以恢复原状!

压缩算法的对比

算法 压缩率 压缩速度 解压速度 浏览器支持
Gzip 中等 所有浏览器
Brotli 中等 现代浏览器 (92%)
Deflate 极快 极快 所有浏览器

相同文件对比

  • 原始 JS: 1000 KB
  • Gzip: 280 KB (72% 减少)
  • Brotli: 220 KB (78% 减少)
  • Brotli 比 Gzip 再减少 21% 体积

使用 vite-plugin-compression 配置

安装

npm install --save-dev vite-plugin-compression

配置

// vite.config.ts
import compression from 'vite-plugin-compression'

export default {
  plugins: [
    // Gzip 压缩
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      threshold: 10240, // 10KB 以上才压缩
      deleteOriginFile: false, // 保留原文件
      verbose: true, // 输出压缩信息
      filter: /\.(js|css|html|svg)$/ // 只压缩文本文件
    }),
    
    // Brotli 压缩
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      threshold: 10240,
      deleteOriginFile: false,
      verbose: true,
      filter: /\.(js|css|html|svg)$/
    })
  ]
}

// 构建结果:
// index.abc123.js
// index.abc123.js.gz    (Gzip)
// index.abc123.js.br    (Brotli)

智能压缩策略 - 多算法混合策略

// vite.config.ts
import compression from 'vite-plugin-compression'

export default {
  plugins: [
    // 对不同的资源使用不同的策略
    
    // 1. HTML: 使用 Brotli(最高压缩率)
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.html$/,
      threshold: 1024
    }),
    
    // 2. JS/CSS: 同时生成 Gzip 和 Brotli
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      filter: /\.(js|css)$/,
      threshold: 10240
    }),
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.(js|css)$/,
      threshold: 10240
    }),
    
    // 3. 大文件用 Brotli,小文件用 Gzip
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.(js|css)$/,
      threshold: 51200 // 50KB 以上用 Brotli
    }),
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      filter: /\.(js|css)$/,
      threshold: 10240, // 10-50KB 用 Gzip
      deleteOriginFile: true // 小文件可以删除原文件
    })
  ]
}

Nginx 配置示例

# nginx.conf
server {
  listen 80;
  server_name example.com;
  root /usr/share/nginx/html;
  
  # 开启 Gzip
  gzip on;
  gzip_vary on;
  gzip_min_length 10240;
  gzip_types text/plain text/css text/xml text/javascript 
             application/javascript application/x-javascript 
             application/xml application/json;
  gzip_comp_level 6;
  gzip_buffers 16 8k;
  gzip_http_version 1.1;
  
  # Brotli 支持(需要编译 brotli 模块)
  brotli on;
  brotli_min_length 10240;
  brotli_types text/plain text/css text/xml text/javascript 
               application/javascript application/x-javascript 
               application/xml application/json;
  brotli_comp_level 6;
  
  location / {
    try_files $uri $uri/ /index.html;
    
    # 尝试 Brotli,然后是 Gzip,最后是原始文件
    location ~* \.(js|css)$ {
      try_files $uri.br $uri.gz $uri =404;
      
      # 根据 Accept-Encoding 设置正确的 Content-Encoding
      if ($http_accept_encoding ~* br) {
        add_header Content-Encoding br;
        add_header Content-Type $content_type;
      }
      if ($http_accept_encoding ~* gzip) {
        add_header Content-Encoding gzip;
        add_header Content-Type $content_type;
      }
      
      # 长期缓存
      expires 1y;
      add_header Cache-Control "public, immutable";
      add_header Vary Accept-Encoding;
    }
    
    # 图片缓存
    location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ {
      expires 30d;
      add_header Cache-Control "public";
    }
  }
}

验证压缩效果

# 使用 curl 验证压缩

# 查看是否支持压缩
curl -H "Accept-Encoding: gzip, br" -I https://example.com/app.js

# 响应头应该包含
Content-Encoding: br
Content-Type: application/javascript
Content-Length: 220000

# 下载并解压验证
curl -H "Accept-Encoding: br" https://example.com/app.js | brotli -d

# 或者使用 httpie
http https://example.com/app.js Accept-Encoding:br

长期缓存策略:让缓存最大化

文件名哈希的原理

// 构建后的文件名
// index.[hash].js

// 哈希是基于文件内容生成的
// 内容不变 → 哈希不变 → 缓存有效
// 内容变化 → 哈希变化 → 重新下载

dist/
├── index.abc123.js    // 哈希基于内容生成
├── index.def456.js    // 内容变化,哈希变化
├── vendor-vue.123abc.js // 第三方库几乎不变
└── vendor-ui.456def.js   // UI 库偶尔更新

配置文件名哈希

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 入口文件
        entryFileNames: 'assets/[name].[hash].js',
        
        // 异步 chunk
        chunkFileNames: 'assets/chunks/[name].[hash].js',
        
        // 资源文件
        assetFileNames: 'assets/[ext]/[name].[hash].[ext]',
        
        manualChunks: {
          // 稳定的第三方库单独打包(几乎不变)
          'vendor-stable': [
            'vue',
            'vue-router',
            'pinia',
            'vuex'
          ],
          
          // 可能更新的 UI 库单独打包
          'vendor-ui': [
            'element-plus',
            '@element-plus/icons-vue',
            'ant-design-vue'
          ],
          
          // 可能更新的工具库
          'vendor-utils': [
            'lodash-es',
            'dayjs',
            'axios'
          ]
        }
      }
    },
    
    // 生成 manifest.json
    manifest: true
  }
}

Nginx 缓存配置

# nginx.conf
server {
  # 静态资源缓存配置
  
  # JS/CSS 长期缓存(带 hash 的文件)
  location ~* \.(js|css)$ {
    # 匹配带 hash 的文件
    if ($uri ~* "\.[a-f0-9]{8,20}\.(js|css)$") {
      expires 1y;
      add_header Cache-Control "public, immutable";
    }
    
    # 如果不带 hash,短时间缓存
    expires 1h;
    add_header Cache-Control "public";
    
    # 尝试压缩版本
    try_files $uri.br $uri.gz $uri =404;
    add_header Vary Accept-Encoding;
  }
  
  # 图片等资源
  location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ {
    expires 30d;
    add_header Cache-Control "public";
  }
  
  # 字体文件
  location ~* \.(woff2?|ttf|eot)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    add_header Access-Control-Allow-Origin "*";
  }
  
  # HTML 文件不缓存
  location ~* \.html$ {
    expires -1;
    add_header Cache-Control "no-cache, must-revalidate";
  }
}

Service Worker 缓存策略

// sw.js
const CACHE_NAME = 'v1'
const CACHE_URLS = [
  '/',
  '/index.html',
  '/manifest.json'
]

// 安装时缓存核心资源
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(CACHE_URLS))
  )
})

// 缓存策略:缓存优先,网络回退
self.addEventListener('fetch', event => {
  const url = new URL(event.request.url)
  
  // 静态资源使用 Cache First 策略
  if (url.pathname.match(/\.(js|css|png|jpg|webp)$/)) {
    event.respondWith(
      caches.match(event.request)
        .then(response => {
          // 缓存命中直接返回
          if (response) return response
          
          // 未命中则请求网络并缓存
          return fetch(event.request).then(response => {
            const clone = response.clone()
            caches.open(CACHE_NAME).then(cache => {
              cache.put(event.request, clone)
            })
            return response
          })
        })
    )
  } 
  // HTML 使用 Network First 策略
  else if (url.pathname.endsWith('.html') || url.pathname === '/') {
    event.respondWith(
      fetch(event.request)
        .then(response => {
          const clone = response.clone()
          caches.open(CACHE_NAME).then(cache => {
            cache.put(event.request, clone)
          })
          return response
        })
        .catch(() => caches.match(event.request))
    )
  }
})

缓存命中率的提升

文件类型 更新频率 缓存策略 命中率
vendor-vue.js 几乎不变 永久缓存 99%
vendor-ui.js 偶尔更新 永久缓存 92%
page-*.js 经常更新 永久缓存 65%
图片 很少更新 30天缓存 95%
字体 从不更新 永久缓存 99%

实战案例:一个中大型项目的构建优化

优化前的状态

// 项目信息
// - 页面数量:45 个
// - 组件数量:850 个
// - 第三方依赖:230 个
// - 图片数量:1200 张

// 构建产物
dist/ 总大小: 45 MB
├── js/      28 MB
├── css/     2.5 MB
├── images/  14 MB
└── others/  0.5 MB

// 性能指标
// - 构建时间:3 分 45 秒
// - 首屏体积:4.2 MB
// - 加载时间:3.2 秒

优化步骤

第一步:分析找出问题

# 运行分析
npx vite-bundle-visualizer

# 发现问题
echarts: 1.2MB        ← 太大
monaco-editor: 2.8MB  ← 巨大!
lodash-es: 210KB      ← 还好
moment: 450KB         ← 可以用 dayjs 替代

第二步:优化拆包

// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 把 echarts 单独打包
            if (id.includes('echarts')) {
              return 'vendor-echarts'
            }
            
            // 把 monaco-editor 单独打包
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'
            }
            
            // 其他分组
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus')) return 'vendor-ui'
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'
            }
            
            return 'vendor-other'
          }
          
          // 按页面拆分
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) return `page-${match[1]}`
          }
        }
      }
    }
  }
}

第三步:图片压缩

// vite.config.js
export default {
  plugins: [
    ViteImageOptimizer({
      png: { quality: 75 },
      jpeg: { quality: 70 },
      webp: { quality: 70 },
      avif: { quality: 60 }
    })
  ]
}

第四步:开启压缩

// vite.config.js
export default {
  plugins: [
    compression({
      algorithm: 'brotliCompress',
      threshold: 10240
    })
  ]
}

第五步:按需加载

// 大组件使用动态导入
const MonacoEditor = defineAsyncComponent(() => 
  import('monaco-editor')
)

// 路由懒加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('./views/Dashboard.vue')  // 按需加载
  }
]

优化后的结果

指标 优化前 优化后 提升
构建时间 3 分 45 秒 2 分 20 秒 38%
总大小 45 MB 18 MB 60%
首屏 JS 体积 4.2 MB 1.8 MB 57%
图片体积 14 MB 3.5 MB 75%
传输体积 3.2 MB 0.8 MB 75%
加载时间 3.2 秒 1.1 秒 65%

常见问题与解决方案

问题一:拆包过多导致请求数爆炸

// ❌ 错误:拆得太细
manualChunks(id) {
  // 每个依赖都单独打包
  return id.match(/node_modules\/([^\/]+)/)?.[1]
}
// 结果:产生 200+ 个文件,HTTP/1.1 下性能差

// ✅ 正确:合理分组
manualChunks(id) {
  if (id.includes('node_modules')) {
    if (id.includes('vue')) return 'vendor-vue'
    if (id.includes('lodash')) return 'vendor-utils'
    if (id.includes('echarts')) return 'vendor-charts'
    if (id.includes('monaco')) return 'vendor-monaco'
    return 'vendor-other' // 其他合并
  }
}

问题二:图片压缩后质量下降

// 解决方案:选择性压缩
ViteImageOptimizer({
  // 图标保留较高品质
  'src/assets/icons/**/*': {
    png: { quality: 90 },
    svg: { plugins: ['preset-default'] }
  },
  
  // 背景图可以接受较低品质
  'src/assets/backgrounds/**/*': {
    jpeg: { quality: 65 },
    webp: { quality: 60 }
  },
  
  // 产品图需要平衡
  'src/assets/products/**/*': {
    jpeg: { quality: 80 },
    webp: { quality: 75 }
  }
})

// 或者使用图片 CDN 动态处理
<img src="https://cdn.example.com/image.jpg?x-oss-process=image/resize,w_400/quality,q_80">

问题三:Brotli 压缩太慢

// ✅ 解决方案:选择性使用 Brotli
compression({
  algorithm: 'brotliCompress',
  threshold: 50000,  // 50KB 以上才用 Brotli
  filter: /\.(js|css)$/
})

// 小文件继续用 Gzip
compression({
  algorithm: 'gzip',
  threshold: 10240,  // 10-50KB 用 Gzip
  filter: /\.(js|css)$/
})

问题四:CDN 不支持 Brotli

# ✅ 解决方案:同时生成 Gzip 和 Brotli
location /assets {
    # 优先尝试 Brotli
    try_files $uri.br $uri.gz $uri =404;
    
    # 根据 Accept-Encoding 返回正确的 Content-Encoding
    if ($http_accept_encoding ~* br) {
        add_header Content-Encoding br;
    }
    if ($http_accept_encoding ~* gzip) {
        add_header Content-Encoding gzip;
    }
}

生产环境优化的最佳实践

优化检查清单

  • 使用 visualizer 分析构建产物
  • 配置 manualChunks 合理拆包
  • 图片资源压缩优化
  • 启用 Gzip/Brotli 压缩
  • 配置长期缓存策略
  • 设置性能预算
  • 在 CI/CD 中集成检查
  • 定期监控 Web Vitals

配置文件模板

// vite.config.ts - 生产环境优化完整配置
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import compression from 'vite-plugin-compression'

export default defineConfig(({ mode }) => ({
  plugins: [
    vue(),
    
    // 图片压缩
    ViteImageOptimizer({
      png: { quality: 75 },
      jpeg: { quality: 70 },
      webp: { quality: 70 },
      avif: { quality: 60 }
    }),
    
    // Gzip 压缩
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      threshold: 10240
    }),
    
    // Brotli 压缩
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      threshold: 10240
    }),
    
    // 构建分析(只在需要时开启)
    process.env.ANALYZE && visualizer({
      open: true,
      filename: 'dist/stats.html',
      gzipSize: true,
      brotliSize: true
    })
  ].filter(Boolean),
  
  build: {
    target: 'es2015',
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: mode === 'production',
        drop_debugger: true
      }
    },
    
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name].[hash].js',
        chunkFileNames: 'assets/chunks/[name].[hash].js',
        assetFileNames: 'assets/[ext]/[name].[hash].[ext]',
        
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus') || id.includes('antd')) {
              return 'vendor-ui'
            }
            if (id.includes('echarts') || id.includes('d3')) {
              return 'vendor-charts'
            }
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'
            }
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'
            }
            return 'vendor-other'
          }
          
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) return `page-${match[1]}`
          }
        }
      }
    },
    
    chunkSizeWarningLimit: 500,
    sourcemap: mode !== 'production',
    manifest: true
  }
}))

性能目标参考

指标 优秀 一般
首屏 JS 体积 < 200KB 200-500KB > 500KB
总构建体积 < 2MB 2-5MB > 5MB
图片体积占比 < 30% 30-50% > 50%
压缩率 > 70% 50-70% < 50%
缓存命中率 > 80% 50-80% < 50%
FCP < 1.5s 1.5-2.5s > 2.5s
LCP < 2.5s 2.5-4s > 4s

三个核心原则

  1. 测量优先:没有数据的优化是盲目的
  2. 渐进改进:每次只优化一个指标
  3. 用户优先:始终以用户体验为导向

结语

优化的终极目标是让用户感受不到加载的存在。当用户打开我们的应用时,内容瞬间呈现,交互立即响应,这就说明我们的优化成功了!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

实战:基于 Vue3 与大模型的多模态“拍照记单词”应用构建与思考

作者 ETA8
2026年3月19日 22:31

随着大语言模型(LLM)能力的边界不断拓展,前端开发的范式正在发生微妙的变化。过去我们需要后端提供结构化的数据接口,现在前端可以直接与多模态模型对话,让应用具备“看”和“说”的能力。

今天我想分享一个小型的全栈实践案例:一个“拍照记单词”的应用。它的核心逻辑很简单:用户拍摄或上传一张生活照片,系统识别图片内容,提取一个适合初学者的英文单词,生成例句,并朗读出来。

虽然功能看似简单,但在实现过程中,涉及到了文件处理、多模态 API 调用、音频流处理以及 Prompt 工程等多个技术点。本文将剥离出核心代码逻辑,探讨其中的实现细节、设计考量以及潜在的优化空间。

一、核心交互与文件处理

在传统的文件上传场景中,我们通常将文件直接提交给后端。但在这个应用中,图片需要同时做两件事:

  1. 本地预览:让用户确认上传的内容。
  2. 发送给 LLM:作为多模态模型的输入。

1. 无障碍与样式控制的平衡

PictureCard 组件中,文件上传的实现采用了经典的 input + label 组合模式:

<input type="file" id="selecteImage" class="input" accept="image/*" @change="updateImageData">
<label for="selecteImage" class="upload">
    <img :src="imgPreview" alt="camera" class="img">
</label>

这里有两个细节值得注意:

首先是无障碍访问(Accessibility)。原生的 input[type="file"] 样式难以定制,且在不同浏览器上表现不一。通过 display: none 隐藏 input,并使用 label 关联 id,我们既获得了完全自由的样式控制权,又保留了语义化。当用户点击美观的相机图标时,实际上触发的是原生文件选择器。对于使用读屏器的视障用户,label 标签能准确传达“上传图片”的意图,这是开发中容易忽视但至关重要的细节。

其次是文件读取机制。为了将图片发送给 LLM,我们需要将其转换为 Base64 格式。这里使用了 HTML5 提供的 FileReader API:

const reader = new FileReader(); 
reader.readAsDataURL(file);
reader.onload = () => {
    const data = reader.result as string;
    imgPreview.value = data;
    emit('update-image', data);
}

readAsDataURL 会将文件内容读取为一个包含 MIME 类型的 Base64 字符串(例如 data:image/png;base64,...)。

  • 优点:格式统一,可以直接嵌入 JSON 发送给大多数多模态 API,同时也方便直接赋值给 img 标签的 src 进行预览。
  • 缺点:Base64 编码会使文件体积增加约 33%。如果图片过大,不仅影响传输速度,还可能超出 LLM 的 Token 限制。在实际生产中,通常需要在读取前对图片进行压缩或尺寸限制。

二、与大模型的对话:Prompt 工程与多模态

应用的核心智能来源于对 Kimi(Moonshot)多模态接口的调用。在 App.vue 中,我们构建了请求体。

1. 多模态输入的标准格式

目前主流的多模态模型(如 GPT-4V, Moonshot-v1-vision)在接收图片时,通常要求 messages 中的 content 字段是一个数组,分别包含文本和图片对象:

messages: [
  {
    role: 'user',
    content: [{
      type: 'image_url',
      image_url: { url: imageDate } // 这里是 Base64 或 HTTP URL
    }, {
      type: 'text',
      text: userPrompt
    }]
  }
]

这种设计允许模型同时“看”到图片并“读”到指令。需要注意的是,虽然代码中直接使用了 Base64,但如果图片较大,建议先上传至对象存储(OSS),将 HTTP URL 传给模型,以减少请求包体大小。

2. 结构化输出的重要性

userPrompt 的设计上,我们没有让模型自由发挥,而是严格限制了输出格式:

返回 JSON 数据:
{
  "representative_word": "图片代表的英文单词",
  "example_sentence": "结合英文单词和图片描述,给出一个简单的例句",
  "explaination": "...",
  ...
}

这是开发 AI 应用的一个关键原则:机器与人对话可以自然,但机器与代码对话必须严谨。

通过要求模型返回 JSON,我们可以直接 JSON.parse 结果,将单词、句子、解释分发到不同的 UI 区域。如果让模型自由返回文本,前端就需要编写复杂的正则去提取单词,这不仅脆弱,而且容易出错。此外,Prompt 中明确了词汇难度(A1~A2),这是产品价值的体现——我们不是在做一个翻译工具,而是在做一个适合初学者的教育工具。

三、音频生成与播放机制

当模型返回例句后,应用需要调用 TTS(Text-to-Speech)服务将文本转为音频。这里涉及到了二进制数据的处理。

1. Base64 到 Blob URL 的转换

TTS 接口返回的通常是音频文件的 Base64 数据。在 audio.ts 中,我们实现了一个 createBlobURL 函数:

const byteCharacters = atob(base64AudioData);
// ... 转换为 Uint8Array
const audioBlob = new Blob([new Uint8Array(byteArrays)], { type: 'audio/mp3' });
const blobURL = URL.createObjectURL(audioBlob);

这里有一个常见的疑问:为什么不直接使用 data:audio/mp3;base64,... 赋值给 audio 标签?

虽然 Data URI 可以直接播放,但在处理较长音频或高频调用时,Blob URL 方案更具优势:

  1. 性能:Blob URL 指向的是内存中的二进制对象,浏览器解码效率通常更高。
  2. 内存管理URL.createObjectURL 创建的引用是可以被显式释放的(通过 URL.revokeObjectURL)。虽然示例代码中为了简洁未展示释放逻辑,但在组件卸载时调用释放,可以有效防止内存泄漏。
  3. 类型安全:显式创建 Blob 可以确保 MIME 类型被浏览器正确识别,避免某些移动端浏览器对 Data URI 音频支持不佳的问题。

2. 音频格式的潜在风险

在代码审查中,我发现了一个值得注意的细节:

  • TTS 请求参数中设置的是 encoding: 'ogg_opus'
  • 但在创建 Blob 时,MIME 类型指定的是 audio/mp3

这可能会导致部分浏览器播放失败或无法识别时长。严谨的做法是根据 API 实际返回的音频流格式来设定 Blob 的 type,或者在 API 请求时直接要求返回 MP3 格式。这提醒我们在对接第三方服务时,必须严格核对输入输出的格式规范。

四、架构思考与安全隐患

在复盘整个项目时,除了功能实现,还有几个架构层面的问题需要深入探讨。

1. 前端密钥的安全风险

App.vue 中,我们看到了这样的代码:

'Authorization': `Bearer ${import.meta.env.VITE_KIMI_API_KEY}`

这是一个严重的安全隐患。 将 LLM 的 API Key 直接暴露在前端代码中,意味着任何查看网页源码的用户都可以获取你的密钥,从而盗用你的额度。

改进方案: README 中提到了技术栈包含 NestJS。正确的架构应该是:

  1. 前端发起请求到自有的 NestJS 后端。
  2. 后端在服务器端存储 API Key,并转发请求给 Kimi 和 TTS 服务。
  3. 后端可以做一层代理,同时实现限流、鉴权和日志记录。

目前的实现仅适合本地学习或内部演示,绝不可直接部署到公网。

2. 状态管理的解耦

当前逻辑集中在 App.vue 中,包括图片状态、单词状态、音频状态等。随着功能增加(例如历史记录、生词本),组件会变得臃肿。

建议引入状态管理库(如 Pinia),将“学习会话”作为一个 Store 管理。同时,将 generateAudiofetchLLM 封装为独立的 Service 层,与 UI 组件彻底解耦。这样不仅便于测试,也方便后续将 API 调用迁移到后端时,前端只需修改 Service 层的请求地址。

3. 用户体验的细腻处理

代码中实现了基础的加载状态(如“分析中..."),但在网络波动或 API 报错时,用户体验还可以更好:

  • 重试机制:LLM 接口偶尔会超时,提供“重试”按钮比直接报错更友好。
  • 音频预加载:在生成音频 URL 后,可以实例化 new Audio(url) 进行预加载,确保用户点击播放时无延迟。
  • 图片压缩:如前所述,在 FileReader 读取前,使用 Canvas 对图片进行压缩,能显著提升上传和解析速度。

五、总结

通过这个“拍照记单词”的小应用,我们实践了 Vue3 组合式 API 的组件通信,探索了 FileReader 与 Blob 的二进制处理,并深入体验了多模态大模型的接入流程。

技术本身并不是目的,解决用户痛点才是。在这个案例中,技术的价值在于将“生活中的任意场景”瞬间转化为“可学习的语言素材”,降低了语言学习的门槛。

对于前端开发者而言,拥抱 AI 不仅仅是学会调用 API,更在于理解如何设计 Prompt 以获得稳定的输出,如何处理多媒体数据流,以及如何在享受 AI 便利的同时,守住安全与性能的底线。希望这个案例能为你构建自己的 AI 应用提供一些实在的参考。

Vue-Vue2与Vue3核心差异与进化

2026年3月19日 21:05

前言

从 Vue 2 到 Vue 3,不仅仅是版本的跳跃,更是底层思想的革新。从 Object.definePropertyProxy,从 Options API 到 Composition API,Vue 3 在性能和开发体验上都实现了质的飞跃。本文将带你系统梳理两者的核心区别。

一、 响应式原理:从“属性拦截”到“对象代理”

响应式系统的升级是 Vue 3 性能提升的关键。

1. Vue 2:Object.defineProperty

  • 原理:初始化时通过递归遍历 data,为每个属性设置 gettersetter

  • 局限性

    • 无法检测到对象属性的新增删除
    • 无法直接监听数组索引的变化和 length 属性。
    • 必须使用 this.$set 等特有 API 来弥补。
    • 递归过程在处理大数据量时存在性能瓶颈。

2. Vue 3:ES6 Proxy

  • 原理:直接监听整个代理对象,拦截所有操作(如 get, set, deleteProperty, has 等)。

  • 优势

    • 原生支持:自动支持动态增删属性、数组下标修改。
    • 懒代理(Lazy Tracking) :只有当访问到深层属性时,才会动态将其转为响应式,大大提升了初始化速度。
    • 性能更好:省去了初始化时繁琐的递归遍历。

二、 编写模式:从“碎片化”到“模块化”

代码组织方式的改变直接影响了大型项目的维护成本。

1. Vue 2:选项式 API (Options API)

  • 痛点:逻辑被强行拆分在 datamethodscomputed 等固定选项中。当一个组件功能复杂时,同一个功能的代码会散落在各处,导致开发者反复上下滚动查找,难以维护。

2. Vue 3:组合式 API (Composition API)

  • 优势:通过 <script setup>,开发者可以按照功能逻辑将代码组织在一起。

  • 逻辑复用:可以轻松地将逻辑抽离成独立的 useHooks 函数,解决了 Vue 2 中 mixin 命名冲突和来源不明的问题。


三、 Vue 3 核心新特性与语法糖

1. 响应式新成员:ref vs reactive

  • ref:万能型。支持基本类型和引用类型,通过 .value 访问(模板中自动解包)。
  • reactive:对象型。仅支持引用类型,直接操作属性,无需 .value

2. defineModel:双向绑定的“减法”

在 Vue 3.4+ 中引入的 defineModel 极大地简化了父子组件通信:

  • Vue 2 做法:需要 props 接收值 + this.$emit('update:xxx') 触发更新。
  • Vue 3 新语法:子组件直接使用 const model = defineModel(),修改 model 的值会自动同步到父组件,代码量骤减。

3. 多根节点模板

  • Vue 2:模板内必须有一个唯一的根节点(通常是 <div>),否则报错。
  • Vue 3:原生支持多个根节点,减少了不必要的 DOM 层级,使 HTML 结构更简洁。

4. 异步处理神器:<Suspense>

  • 新增内置组件,专门用于处理异步组件的加载状态。它提供了 defaultfallback 两个插槽,可以优雅地展示“加载中”和“加载完成”的 UI 切换。

四、 总结:为什么要升 Vue 3?

类别 Vue2 Vue3
响应式原理 Object.defineProperty 逐个属性劫持 Proxy 代理整个对象,懒加载
编写模式 选项式API(Options API) 组合式API(Composition API +
模板规范 仅支持单个根节点 支持多个根节点
数据监听 无法监听对象增删、数组索引 原生支持对象增删、数组下标修改
组件双向绑定 props + emit 手动实现 defineModel 语法糖简化
异步加载 手动处理加载状态 内置 Suspense 组件

Vue2:数组/对象操作避坑大全

2026年3月19日 20:49

前言

在 Vue 2 开发中,你是否遇到过“明明数据变了,视图却没动”的诡异情况?这通常不是代码逻辑问题,而是由于 Vue 2 基于 Object.defineProperty 的响应式原理存在天然的局限性。本文将带你攻克这些响应式盲区。

一、 响应式的“硬伤”:为什么会失效?

Vue 2 在初始化阶段,会遍历 data 中的属性并使用 Object.defineProperty 将其转为 getter/setter

它的核心问题在于:

  1. 无法检测对象属性的添加或删除(因为它只在初始化时进行监听)。
  2. 无法检测数组索引的直接修改和长度变化

二、 对象操作:打破“属性新增”的僵局

1. 新增/删除属性

如果你直接通过 this.obj.newKey = value 赋值,Vue 是无法感知的。

  • 新增属性:使用 this.$set (或全局 Vue.set)。

    • 语法:this.$set(target, key, value)
    • 示例:this.$set(this.user, 'age', 18)
  • 删除属性:使用 this.$delete (或全局 Vue.delete)。

2. 批量修改属性

如果你需要一次性增加多个属性,不要写一堆 $setVue2 可以监听对象引用变化,最高效的方法是替换整个对象引用

// 这种方式 Vue 能够通过监听对象的引用变化来触发更新
this.user = Object.assign({}, this.user, {
  age: 18,
  gender: 'male'
});

// 批量更新user对象属性
this.user = {
  ...this.user,
  age: 20,
  gender: '男',
  address: '北京'
}

三、 数组操作:被“重写”的 7 个方法

在 Vue 2 中,直接执行 this.items[0] = 'new' 是不会触发更新的。解决方案同样是使用 this.$set,以及使用vue重写的相关数组方法。

1. 自动触发更新的方法

只要调用以下方法,Vue 就会自动检测到变化并更新视图:

  • push() / pop():队尾操作
  • unshift() / shift():队头操作
  • splice()最万能,可实现增、删、改。
  • sort():排序。
  • reverse():翻转。

2. 数组的特殊场景

  • 根据索引修改值

    • ❌ 错误:this.items[index] = newValue
    • ✅ 正确:this.$set(this.items, index, newValue)this.items.splice(index, 1, newValue)
  • 修改数组长度

    • ❌ 错误:this.items.length = 0 (清空数组失效)
    • ✅ 正确:this.items.splice(0)this.items = []

四、 进阶补充:Vue 3 是如何解决的?

  • Vue 3 使用了 ES6 Proxy:Proxy 代理的是整个对象而不是属性。

  • 优势:Proxy 可以原生监听到属性的动态添加、删除,以及数组索引的变化,因此在 Vue 3 中,你不再需要使用 $set 了!


五、 总结

  1. vue2对象新增属性:首选 this.$set,批量新增选 Object.assign

  2. vue2数组修改:养成使用 splicepush 等 7 个变异方法的习惯。

  3. 调试技巧:如果视图没更新,先用 console.log 确认数据是否变了,再检查是否触碰了上述响应式盲区。

❌
❌