Vue3深水区:响应式边界、渲染函数和数据流实战




2020-06-29

blog_main_img

上一篇如果算“把 Vue 3 项目写顺”,这一篇就往更难的地方钻一点:大型状态怎么控、外部对象怎么接、动态页面怎么渲、组件怎么统一释放副作用、SSR怎么避坑、WebSocket数据流怎么接进页面。

这些内容不会天天用,但一旦遇到复杂后台、低代码表单、实时看板、富文本编辑器、地图引擎、SSR页面,就会非常有用。

响应式不是越深越好

Vue 的深层响应式很方便,但复杂对象和大数组不一定适合全量代理。

比如下面这些对象:

  • 地图实例
  • 编辑器实例
  • 大型表格数据
  • WebSocket 连接
  • 第三方图表对象
  • 不需要逐字段追踪的业务缓存

如果把它们直接塞进 reactive,Vue 会尝试递归代理内部结构。对象越复杂,代理越容易变成额外负担。

这时要学会控制响应式边界。

Vue 3 响应式作用域

shallowRef:只追踪外层引用

大列表经常来自接口或数据流。很多场景下,我们并不需要 Vue 追踪每一行里的每个字段,只需要在整份列表替换时触发更新。

import { computed, shallowRef } from 'vue'

type Row = {
  id: number
  name: string
  score: number
}

const rows = shallowRef<Row[]>([])

const topRows = computed(() => {
  return rows.value.slice(0, 10)
})

function replaceRows(nextRows: Row[]) {
  rows.value = nextRows
}

function updateOneRow(id: number, score: number) {
  rows.value = rows.value.map((row) => {
    return row.id === id ? { ...row, score } : row
  })
}

这段代码的特点是:不在原数组内部硬改,而是产生新数组再替换。对大列表来说,这种方式更可预测,也更容易和缓存、快照、撤销恢复搭配。

如果你确实原地改了内部字段,又想手动触发更新,可以用 triggerRef

import { shallowRef, triggerRef } from 'vue'

const rows = shallowRef([{ id: 1, score: 80 }])

rows.value[0].score = 99
triggerRef(rows)

但这个写法要克制使用。更推荐让数据更新保持“替换引用”的风格。

markRaw:第三方对象别让 Vue 代理

第三方实例通常不希望被 Vue 改造。

比如一个图表对象:

import { markRaw, onMounted, onUnmounted, shallowRef } from 'vue'

type ChartLike = {
  setOption: (option: unknown) => void
  dispose: () => void
}

const chart = shallowRef<ChartLike | null>(null)

onMounted(() => {
  const instance = createChartSomehow()
  chart.value = markRaw(instance)
})

onUnmounted(() => {
  chart.value?.dispose()
  chart.value = null
})

markRaw 的意思是:这个对象保持原样,不进入 Vue 的响应式系统。

这个手法适合接入 ECharts、Monaco Editor、Mapbox、Three.js 这类外部对象。Vue 负责生命周期,外部库负责自己的内部状态。

effectScope:一组副作用一起释放

复杂页面里会有很多监听:

  • watch
  • watchEffect
  • 事件订阅
  • 数据流连接
  • 外部 store 订阅

散着写很容易忘记释放。effectScope 可以把一组响应式副作用装进同一个作用域,最后统一停止。

import { effectScope, ref, watch } from 'vue'

const keyword = ref('')
const page = ref(1)

const scope = effectScope()

scope.run(() => {
  watch(keyword, (value) => {
    console.log('keyword changed:', value)
  })

  watch(page, (value) => {
    console.log('page changed:', value)
  })
})

function disposeSearchEffects() {
  scope.stop()
}

如果你在 composable 里创建副作用,可以配合 onScopeDispose

import { onScopeDispose, ref } from 'vue'

export function useSocket(url: string) {
  const message = ref('')
  const socket = new WebSocket(url)

  socket.addEventListener('message', (event) => {
    message.value = event.data
  })

  onScopeDispose(() => {
    socket.close()
  })

  return {
    message,
  }
}

组件卸载时,当前作用域会释放,连接也会被关闭。这个习惯能减少不少“页面离开后还在跑”的问题。

customRef:自己控制依赖触发

搜索框输入时,不一定每敲一个字符都要触发查询。可以用 customRef 做防抖引用。

import { customRef } from 'vue'

export function useDebouncedRef<T>(initialValue: T, delay = 300) {
  let value = initialValue
  let handle: ReturnType<typeof setTimeout> | undefined

  return customRef<T>((track, trigger) => {
    return {
      get() {
        track()
        return value
      },
      set(nextValue) {
        value = nextValue

        if (handle) {
          clearTimeout(handle)
        }

        handle = setTimeout(() => {
          trigger()
        }, delay)
      },
    }
  })
}

页面里使用:

<script setup lang="ts">
import { watch } from 'vue'
import { useDebouncedRef } from '@/composables/useDebouncedRef'

const keyword = useDebouncedRef('', 400)

watch(keyword, (value) => {
  console.log('search:', value)
})
</script>

<template>
  <input v-model="keyword" placeholder="输入关键词" />
</template>

customRef 的价值不是炫技,而是把“什么时候追踪、什么时候触发”交给你自己决定。

渲染函数:动态界面不用硬拼模板

普通页面用模板就够了。但下面这类场景,渲染函数会更灵活:

  • schema 表单
  • 动态看板
  • 低代码页面
  • 权限驱动按钮区
  • 后端配置组件树

模板是写死结构,渲染函数可以根据数据生成结构。

Vue 3 schema 渲染

一个极简 schema renderer:

import { defineComponent, h } from 'vue'

type NodeSchema =
  | {
      type: 'text'
      value: string
    }
  | {
      type: 'button'
      label: string
      action: string
    }
  | {
      type: 'card'
      title: string
      children: NodeSchema[]
    }

export const SchemaRenderer = defineComponent({
  name: 'SchemaRenderer',
  props: {
    schema: {
      type: Object as () => NodeSchema,
      required: true,
    },
  },
  emits: ['action'],
  setup(props, { emit }) {
    function renderNode(node: NodeSchema) {
      if (node.type === 'text') {
        return h('p', { class: 'schema-text' }, node.value)
      }

      if (node.type === 'button') {
        return h(
          'button',
          {
            class: 'schema-button',
            onClick: () => emit('action', node.action),
          },
          node.label,
        )
      }

      return h('section', { class: 'schema-card' }, [
        h('h3', node.title),
        ...node.children.map(renderNode),
      ])
    }

    return () => renderNode(props.schema)
  },
})

使用它:

<script setup lang="ts">
import { SchemaRenderer } from '@/components/SchemaRenderer'

const schema = {
  type: 'card',
  title: '操作面板',
  children: [
    { type: 'text', value: '请选择一个动作' },
    { type: 'button', label: '刷新数据', action: 'reload' },
  ],
} as const

function handleAction(action: string) {
  console.log('action:', action)
}
</script>

<template>
  <SchemaRenderer :schema="schema" @action="handleAction" />
</template>

这类方案的重点不在 h() 本身,而在“组件映射规则”。真实项目里通常会维护一个 registry:

import type { Component } from 'vue'
import TextBlock from './TextBlock.vue'
import UserPicker from './UserPicker.vue'
import DateRange from './DateRange.vue'

const registry: Record<string, Component> = {
  text: TextBlock,
  userPicker: UserPicker,
  dateRange: DateRange,
}

后端只下发组件类型和参数,前端负责映射到安全、受控的组件。不要把远端字符串直接当代码执行。

动态组件和缓存:KeepAlive 要有边界

动态组件可以这样写:

<script setup lang="ts">
import { ref } from 'vue'
import AuditPanel from './AuditPanel.vue'
import ChartPanel from './ChartPanel.vue'
import LogPanel from './LogPanel.vue'

const panels = {
  audit: AuditPanel,
  chart: ChartPanel,
  log: LogPanel,
}

const active = ref<keyof typeof panels>('audit')
</script>

<template>
  <KeepAlive include="AuditPanel,ChartPanel">
    <component :is="panels[active]" />
  </KeepAlive>
</template>

KeepAlive 可以保留组件状态,切换回来不用重新创建。但它不是“全都缓存”的按钮。

适合缓存的组件:

  • 切换频繁
  • 创建成本高
  • 用户输入需要保留

不适合缓存的组件:

  • 数据强依赖路由参数
  • 内部连接很多
  • 离开后应该释放资源

如果被缓存组件需要在进入和离开时处理逻辑,可以用:

import { onActivated, onDeactivated } from 'vue'

onActivated(() => {
  console.log('panel active')
})

onDeactivated(() => {
  console.log('panel inactive')
})

这和普通的 onMountedonUnmounted 不一样。被 KeepAlive 缓存时,组件只是停用,不一定卸载。

大列表:少让 DOM 和响应式系统硬扛

大列表卡顿一般不是 Vue 某一个 API 的锅,而是几件事叠在一起:

  • 数据量大
  • 节点多
  • 每行组件重
  • 每个字段都被深层代理
  • 更新范围太大

一个轻量策略是:大数组用 shallowRef,可视区域做切片。

import { computed, ref, shallowRef } from 'vue'

type Row = {
  id: number
  title: string
}

const rows = shallowRef<Row[]>([])
const start = ref(0)
const size = 40

const visibleRows = computed(() => {
  return rows.value.slice(start.value, start.value + size)
})

function onScroll(event: Event) {
  const target = event.target as HTMLElement
  const rowHeight = 36
  start.value = Math.floor(target.scrollTop / rowHeight)
}

模板:

<template>
  <div class="list-view" @scroll="onScroll">
    <div class="spacer" :style="{ height: `${rows.length * 36}px` }">
      <div
        class="visible"
        :style="{ transform: `translateY(${start * 36}px)` }"
      >
        <div v-for="row in visibleRows" :key="row.id" class="row">
          {{ row.title }}
        </div>
      </div>
    </div>
  </div>
</template>

这只是演示思路。项目里更建议用成熟虚拟列表库,因为真实场景会碰到动态高度、滚动恢复、键盘导航、可访问性等细节。

还有一个实用优化:稳定 props。

不要让每一行都接收一个不断变化的大对象:

<RowItem
  v-for="row in rows"
  :key="row.id"
  :row="row"
  :active-id="activeId"
/>

可以改成让每行只接收自己真正关心的布尔值:

<RowItem
  v-for="row in rows"
  :key="row.id"
  :row="row"
  :active="row.id === activeId"
/>

这样 activeId 变化时,不相关的行更容易跳过更新。

v-memo:让稳定片段少更新

当一块模板依赖很少,并且外层频繁重渲染,可以考虑 v-memo

<div
  v-for="row in rows"
  :key="row.id"
  v-memo="[row.id, row.version]"
>
  <HeavyRow :row="row" />
</div>

只要 row.idrow.version 不变,这块内容就可以复用之前的结果。

它不是万能优化开关。依赖写错了,页面可能不会按预期刷新。用它之前,先确认瓶颈确实在重复渲染。

SSR 水合:首屏输出必须对得上

SSR 的基本思路是:服务端先输出 HTML,浏览器再接管这份 HTML,让它变成可交互页面。

水合不一致通常来自这些操作:

  • 首屏渲染直接读 window
  • 模板里直接用随机值
  • 服务端和客户端拿到的数据不同
  • 根据浏览器宽度输出不同 DOM
  • 第三方库在首屏阶段改了结构

解决思路很朴素:首屏要稳定,浏览器专属逻辑放到 onMounted

<script setup lang="ts">
import { onMounted, ref } from 'vue'

const mounted = ref(false)
const width = ref(0)

onMounted(() => {
  mounted.value = true
  width.value = window.innerWidth
})
</script>

<template>
  <ClientOnlyPanel v-if="mounted" :width="width" />
  <div v-else class="placeholder">加载交互模块</div>
</template>

如果你用的是 Nuxt,会有更完整的客户端组件能力。纯 Vue SSR 里,也要遵守同一个原则:别让服务端和客户端首屏结构打架。

Python WebSocket:给 Vue 看板推数据

这次不写普通 GET 接口,换一个实时看板常见玩法:Python 提供 WebSocket,Vue 负责订阅数据流。

Vue 3 数据流和 SSR

Python 服务:

import asyncio
import random

from fastapi import FastAPI, WebSocket, WebSocketDisconnect

app = FastAPI()


@app.websocket("/ws/metrics")
async def metrics_socket(websocket: WebSocket):
    await websocket.accept()

    try:
        while True:
            payload = {
                "cpu": round(random.uniform(20, 90), 2),
                "memory": round(random.uniform(30, 95), 2),
                "queue": random.randint(0, 120),
            }
            await websocket.send_json(payload)
            await asyncio.sleep(1)
    except WebSocketDisconnect:
        return

启动:

uvicorn api:app --reload --host 127.0.0.1 --port 8000

Vue composable:

import { onScopeDispose, shallowRef } from 'vue'

export type Metrics = {
  cpu: number
  memory: number
  queue: number
}

export function useMetricsSocket(url: string) {
  const metrics = shallowRef<Metrics | null>(null)
  const connected = shallowRef(false)
  const error = shallowRef('')

  const socket = new WebSocket(url)

  socket.addEventListener('open', () => {
    connected.value = true
    error.value = ''
  })

  socket.addEventListener('message', (event) => {
    metrics.value = JSON.parse(event.data) as Metrics
  })

  socket.addEventListener('close', () => {
    connected.value = false
  })

  socket.addEventListener('error', () => {
    error.value = 'WebSocket 连接异常'
  })

  onScopeDispose(() => {
    socket.close()
  })

  return {
    metrics,
    connected,
    error,
  }
}

页面使用:

<script setup lang="ts">
import { computed } from 'vue'
import { useMetricsSocket } from '@/composables/useMetricsSocket'

const { metrics, connected, error } = useMetricsSocket(
  'ws://127.0.0.1:8000/ws/metrics',
)

const status = computed(() => {
  if (error.value) return error.value
  return connected.value ? '已连接' : '未连接'
})
</script>

<template>
  <section class="metrics-board">
    <p>{{ status }}</p>

    <template v-if="metrics">
      <strong>CPU:{{ metrics.cpu }}%</strong>
      <strong>Memory:{{ metrics.memory }}%</strong>
      <strong>Queue:{{ metrics.queue }}</strong>
    </template>
  </section>
</template>

这里用 shallowRef 是因为每次都替换一份新指标对象,不需要深层追踪。

WebSocket 和 SSR 一起用时的边界

WebSocket 是浏览器能力,服务端渲染阶段不能直接创建连接。

如果你的页面可能跑在 SSR 环境,写法要更谨慎:

import { onMounted, shallowRef } from 'vue'
import type { Metrics } from '@/composables/useMetricsSocket'

const metrics = shallowRef<Metrics | null>(null)
const connected = shallowRef(false)

onMounted(() => {
  const socket = new WebSocket('ws://127.0.0.1:8000/ws/metrics')

  socket.addEventListener('open', () => {
    connected.value = true
  })

  socket.addEventListener('message', (event) => {
    metrics.value = JSON.parse(event.data) as Metrics
  })
})

也可以把连接逻辑封装成只在客户端调用的 composable。核心原则还是那句:浏览器专属对象放到客户端阶段。

高阶组件:把能力注入,而不是到处 import

有些能力全项目都会用,比如权限判断、埋点、特性开关。可以用 provide/inject 做依赖注入。

import type { App, InjectionKey } from 'vue'

type FeatureFlags = {
  isEnabled: (name: string) => boolean
}

export const featureFlagsKey: InjectionKey<FeatureFlags> = Symbol('feature-flags')

export function createFeatureFlags(flags: Record<string, boolean>) {
  return {
    install(app: App) {
      app.provide(featureFlagsKey, {
        isEnabled(name: string) {
          return Boolean(flags[name])
        },
      })
    },
  }
}

入口注册:

import { createApp } from 'vue'
import App from './App.vue'
import { createFeatureFlags } from './plugins/featureFlags'

createApp(App)
  .use(createFeatureFlags({ dashboardV2: true }))
  .mount('#app')

组件里读取:

import { inject } from 'vue'
import { featureFlagsKey } from '@/plugins/featureFlags'

const featureFlags = inject(featureFlagsKey)

if (!featureFlags) {
  throw new Error('feature flags plugin is missing')
}

const enabled = featureFlags.isEnabled('dashboardV2')

这种做法适合“全局能力,但不想挂到全局变量”的场景。比到处 import 单例更容易测试,也更容易在不同应用实例里注入不同配置。

异步组件:把重模块拆出去

富文本编辑器、图表面板、代码编辑器这类组件通常很重。不要让它们压在首屏包里。

import { defineAsyncComponent } from 'vue'

export const AsyncEditor = defineAsyncComponent({
  loader: () => import('@/components/RichEditor.vue'),
  delay: 120,
  timeout: 10000,
})

使用:

<template>
  <Suspense>
    <AsyncEditor />

    <template #fallback>
      <div class="editor-loading">编辑器加载中</div>
    </template>
  </Suspense>
</template>

这适合“用户不一定会打开”的重模块。首屏只加载必要内容,后续再按需加载。

调试复杂更新:看依赖,而不是看感觉

页面莫名多次刷新时,可以先从这几处查:

  • 模板里调用了会产生新对象的函数
  • props 每次渲染都生成新引用
  • 大对象被深层代理
  • watch 里又修改了被监听状态
  • computed 做了带副作用的事情

一个常见坏味道:

<Child :config="{ pageSize: 20, theme: currentTheme }" />

每次父组件渲染,config 都是新对象。可以改成:

const childConfig = computed(() => {
  return {
    pageSize: 20,
    theme: currentTheme.value,
  }
})

再传:

<Child :config="childConfig" />

复杂页面里,不稳定引用会制造很多无意义更新。先让输入稳定,后面再谈别的优化。