v-memo пропускает обновление DOM-поддерева если зависимости не изменились:
<!-- Обновляется только если item.id ИЛИ selected изменились -->
<div v-for="item in list" :key="item.id"
v-memo="[item.id, item.selected]">
<p>{{ item.name }}</p>
<p>{{ item.description }}</p>
<!-- ...300 строк тяжёлого контента... -->
</div>
<!-- v-memo="[]" — никогда не обновляется (статический) -->
<HeavyStaticContent v-memo="[]" />Идеален для тяжёлых списков, где большинство элементов не меняется при каждом ре-рендере.
import { ref, markRaw } from 'vue'
// Большой объект с 10 000 свойств — дорого делать реактивным
const hugeDataset = markRaw({
points: new Float32Array(300000),
metadata: { ... }
})
// ref хранит объект, но НЕ делает его реактивным
const data = ref(hugeDataset)
// Аналогично с компонентами в динамическом :is
const currentComponent = ref(markRaw(HeavyChartComponent))Рендерить 10 000 строк — катастрофа. Решение: рендерить только видимые:
// С @tanstack/virtual или vue-virtual-scroller
import { useVirtualizer } from '@tanstack/vue-virtual'
const parentRef = ref(null)
const virtualizer = useVirtualizer({
count: items.value.length,
getScrollElement: () => parentRef.value,
estimateSize: () => 50, // высота строки
})<div ref="parentRef" style="height: 500px; overflow-y: auto">
<div :style="{ height: virtualizer.getTotalSize() + 'px', position: 'relative' }">
<div
v-for="row in virtualizer.getVirtualItems()"
:key="row.index"
:style="{ position: 'absolute', top: row.start + 'px', height: row.size + 'px' }"
>
{{ items[row.index].name }}
</div>
</div>
</div>// Разные стратегии ленивой загрузки:
// 1. Загрузка при первом рендере
const HeavyEditor = defineAsyncComponent(() => import('./HeavyEditor.vue'))
// 2. Предзагрузка при idle (после основной загрузки)
const prefetchEditor = () => import('./HeavyEditor.vue')
onMounted(() => {
if ('requestIdleCallback' in window) {
requestIdleCallback(prefetchEditor)
}
})
// 3. Загрузка при видимости (Intersection Observer)
const { stop } = useIntersectionObserver(targetEl, ([{ isIntersecting }]) => {
if (isIntersecting) {
prefetchEditor()
stop()
}
})// ❌ Функция пересчитывается при каждом обращении
const expensiveValue = () => heavyComputation(data.value)
// ✅ computed кэширует результат — пересчёт только при изменении data
const expensiveValue = computed(() => heavyComputation(data.value))
// Множественные computed вместо одного большого
const filteredItems = computed(() => items.value.filter(...))
const sortedItems = computed(() => [...filteredItems.value].sort(...))
const paginatedItems = computed(() => sortedItems.value.slice(offset, offset + pageSize))// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
visualizer({ open: true }) // открывает treemap бандла
],
build: {
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'ui-lib': ['element-plus'],
'charts': ['chart.js', 'echarts'],
}
}
}
}
})1. **Профилируй сначала** — Chrome DevTools, Vue DevTools Profiler
2. v-memo для тяжёлых v-for списков
3. markRaw для больших неизменяемых объектов
4. shallowRef / shallowReactive вместо глубокой реактивности
5. Виртуализация для списков > 100-200 элементов
6. defineAsyncComponent для компонентов > 20кб
7. v-once для полностью статичного контента
8. Анализ бандла + manualChunks
Мемоизация, виртуализация и ленивая загрузка — три ключевые техники оптимизации на чистом JS
// Три техники оптимизации Vue приложений — реализуем на JS.
// =====================================================
// 1. Мемоизация (аналог v-memo / computed)
// =====================================================
function createMemoizedComputed(computeFn) {
let cachedResult = undefined
let cachedDeps = []
let callCount = 0
return {
get(deps) {
// Глубокое сравнение зависимостей
const depsChanged = deps.some((dep, i) =>
JSON.stringify(dep) !== JSON.stringify(cachedDeps[i])
)
if (depsChanged || cachedResult === undefined) {
cachedDeps = [...deps]
cachedResult = computeFn(...deps)
callCount++
console.log(` [computed] пересчитан (вызовов: ${callCount})`)
} else {
console.log(' [computed] кэш (без пересчёта)')
}
return cachedResult
},
getCallCount: () => callCount
}
}
// =====================================================
// 2. Виртуализация списка
// =====================================================
class VirtualScroller {
constructor({ containerHeight = 400, itemHeight = 50, overscan = 3 } = {}) {
this.containerHeight = containerHeight
this.itemHeight = itemHeight
this.overscan = overscan
this.scrollTop = 0
this.items = []
this._renderCount = 0
}
setItems(items) { this.items = items }
setScrollTop(top) { this.scrollTop = Math.max(0, top) }
getVirtualItems() {
const startIndex = Math.max(0,
Math.floor(this.scrollTop / this.itemHeight) - this.overscan
)
const visibleCount = Math.ceil(this.containerHeight / this.itemHeight)
const endIndex = Math.min(
this.items.length - 1,
Math.floor(this.scrollTop / this.itemHeight) + visibleCount + this.overscan
)
this._renderCount++
const rendered = []
for (let i = startIndex; i <= endIndex; i++) {
rendered.push({
index: i,
data: this.items[i],
offsetTop: i * this.itemHeight,
height: this.itemHeight,
})
}
return rendered
}
get totalHeight() { return this.items.length * this.itemHeight }
stats(virtualItems) {
const savings = ((1 - virtualItems.length / this.items.length) * 100).toFixed(1)
return {
total: this.items.length,
rendered: virtualItems.length,
savings: savings + '%',
totalHeightPx: this.totalHeight + 'px',
}
}
}
// =====================================================
// 3. Ленивая загрузка с очередью приоритетов
// =====================================================
class LazyLoader {
constructor({ concurrency = 2 } = {}) {
this.queue = []
this.running = 0
this.concurrency = concurrency
this.loaded = new Map()
}
// Добавить в очередь с приоритетом (меньше = выше)
enqueue(key, loader, priority = 5) {
if (this.loaded.has(key)) {
console.log(`[LazyLoader] "${key}" уже загружен`)
return Promise.resolve(this.loaded.get(key))
}
return new Promise((resolve, reject) => {
this.queue.push({ key, loader, priority, resolve, reject })
this.queue.sort((a, b) => a.priority - b.priority)
this._process()
})
}
async _process() {
while (this.running < this.concurrency && this.queue.length > 0) {
const { key, loader, priority, resolve, reject } = this.queue.shift()
this.running++
console.log(`[LazyLoader] загружаем "${key}" (приоритет: ${priority})`)
try {
const result = await loader()
this.loaded.set(key, result)
resolve(result)
console.log(`[LazyLoader] "${key}" загружен`)
} catch(e) {
reject(e)
} finally {
this.running--
this._process()
}
}
}
}
// === Демонстрация ===
console.log('=== 1. Мемоизация (v-memo) ===')
const expensiveSort = createMemoizedComputed((items, order) => {
return [...items].sort((a, b) => order === 'asc' ? a - b : b - a)
})
const items = [3, 1, 4, 1, 5, 9, 2, 6]
const result1 = expensiveSort.get([items, 'asc'])
const result2 = expensiveSort.get([items, 'asc']) // кэш!
const result3 = expensiveSort.get([items, 'desc']) // пересчёт
console.log('Результат:', result3)
console.log('Реальных вычислений:', expensiveSort.getCallCount()) // 2
console.log('\n=== 2. Виртуализация ===')
const scroller = new VirtualScroller({ containerHeight: 300, itemHeight: 40 })
scroller.setItems(Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Item ${i}` })))
scroller.setScrollTop(0)
let vis = scroller.getVirtualItems()
console.log('Scroll=0:', scroller.stats(vis))
scroller.setScrollTop(50000)
vis = scroller.getVirtualItems()
console.log('Scroll=50000:', scroller.stats(vis))
console.log('Первый видимый:', vis[0].data.name)
console.log('\n=== 3. Ленивая загрузка ===')
const loader = new LazyLoader({ concurrency: 2 })
const makeLoader = (name, ms) => async () => {
await new Promise(r => setTimeout(r, ms))
return { component: name }
}
// Добавляем 4 задачи, concurrency=2 — две параллельно
Promise.all([
loader.enqueue('Header', makeLoader('Header', 50), 1),
loader.enqueue('Sidebar', makeLoader('Sidebar', 30), 2),
loader.enqueue('Charts', makeLoader('Charts', 80), 5),
loader.enqueue('Footer', makeLoader('Footer', 20), 3),
]).then(results => {
console.log('Загружено:', results.map(r => r.component))
// Повторный вызов — из кэша
loader.enqueue('Header', makeLoader('Header', 50), 1)
})
v-memo пропускает обновление DOM-поддерева если зависимости не изменились:
<!-- Обновляется только если item.id ИЛИ selected изменились -->
<div v-for="item in list" :key="item.id"
v-memo="[item.id, item.selected]">
<p>{{ item.name }}</p>
<p>{{ item.description }}</p>
<!-- ...300 строк тяжёлого контента... -->
</div>
<!-- v-memo="[]" — никогда не обновляется (статический) -->
<HeavyStaticContent v-memo="[]" />Идеален для тяжёлых списков, где большинство элементов не меняется при каждом ре-рендере.
import { ref, markRaw } from 'vue'
// Большой объект с 10 000 свойств — дорого делать реактивным
const hugeDataset = markRaw({
points: new Float32Array(300000),
metadata: { ... }
})
// ref хранит объект, но НЕ делает его реактивным
const data = ref(hugeDataset)
// Аналогично с компонентами в динамическом :is
const currentComponent = ref(markRaw(HeavyChartComponent))Рендерить 10 000 строк — катастрофа. Решение: рендерить только видимые:
// С @tanstack/virtual или vue-virtual-scroller
import { useVirtualizer } from '@tanstack/vue-virtual'
const parentRef = ref(null)
const virtualizer = useVirtualizer({
count: items.value.length,
getScrollElement: () => parentRef.value,
estimateSize: () => 50, // высота строки
})<div ref="parentRef" style="height: 500px; overflow-y: auto">
<div :style="{ height: virtualizer.getTotalSize() + 'px', position: 'relative' }">
<div
v-for="row in virtualizer.getVirtualItems()"
:key="row.index"
:style="{ position: 'absolute', top: row.start + 'px', height: row.size + 'px' }"
>
{{ items[row.index].name }}
</div>
</div>
</div>// Разные стратегии ленивой загрузки:
// 1. Загрузка при первом рендере
const HeavyEditor = defineAsyncComponent(() => import('./HeavyEditor.vue'))
// 2. Предзагрузка при idle (после основной загрузки)
const prefetchEditor = () => import('./HeavyEditor.vue')
onMounted(() => {
if ('requestIdleCallback' in window) {
requestIdleCallback(prefetchEditor)
}
})
// 3. Загрузка при видимости (Intersection Observer)
const { stop } = useIntersectionObserver(targetEl, ([{ isIntersecting }]) => {
if (isIntersecting) {
prefetchEditor()
stop()
}
})// ❌ Функция пересчитывается при каждом обращении
const expensiveValue = () => heavyComputation(data.value)
// ✅ computed кэширует результат — пересчёт только при изменении data
const expensiveValue = computed(() => heavyComputation(data.value))
// Множественные computed вместо одного большого
const filteredItems = computed(() => items.value.filter(...))
const sortedItems = computed(() => [...filteredItems.value].sort(...))
const paginatedItems = computed(() => sortedItems.value.slice(offset, offset + pageSize))// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
visualizer({ open: true }) // открывает treemap бандла
],
build: {
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'ui-lib': ['element-plus'],
'charts': ['chart.js', 'echarts'],
}
}
}
}
})1. **Профилируй сначала** — Chrome DevTools, Vue DevTools Profiler
2. v-memo для тяжёлых v-for списков
3. markRaw для больших неизменяемых объектов
4. shallowRef / shallowReactive вместо глубокой реактивности
5. Виртуализация для списков > 100-200 элементов
6. defineAsyncComponent для компонентов > 20кб
7. v-once для полностью статичного контента
8. Анализ бандла + manualChunks
Мемоизация, виртуализация и ленивая загрузка — три ключевые техники оптимизации на чистом JS
// Три техники оптимизации Vue приложений — реализуем на JS.
// =====================================================
// 1. Мемоизация (аналог v-memo / computed)
// =====================================================
function createMemoizedComputed(computeFn) {
let cachedResult = undefined
let cachedDeps = []
let callCount = 0
return {
get(deps) {
// Глубокое сравнение зависимостей
const depsChanged = deps.some((dep, i) =>
JSON.stringify(dep) !== JSON.stringify(cachedDeps[i])
)
if (depsChanged || cachedResult === undefined) {
cachedDeps = [...deps]
cachedResult = computeFn(...deps)
callCount++
console.log(` [computed] пересчитан (вызовов: ${callCount})`)
} else {
console.log(' [computed] кэш (без пересчёта)')
}
return cachedResult
},
getCallCount: () => callCount
}
}
// =====================================================
// 2. Виртуализация списка
// =====================================================
class VirtualScroller {
constructor({ containerHeight = 400, itemHeight = 50, overscan = 3 } = {}) {
this.containerHeight = containerHeight
this.itemHeight = itemHeight
this.overscan = overscan
this.scrollTop = 0
this.items = []
this._renderCount = 0
}
setItems(items) { this.items = items }
setScrollTop(top) { this.scrollTop = Math.max(0, top) }
getVirtualItems() {
const startIndex = Math.max(0,
Math.floor(this.scrollTop / this.itemHeight) - this.overscan
)
const visibleCount = Math.ceil(this.containerHeight / this.itemHeight)
const endIndex = Math.min(
this.items.length - 1,
Math.floor(this.scrollTop / this.itemHeight) + visibleCount + this.overscan
)
this._renderCount++
const rendered = []
for (let i = startIndex; i <= endIndex; i++) {
rendered.push({
index: i,
data: this.items[i],
offsetTop: i * this.itemHeight,
height: this.itemHeight,
})
}
return rendered
}
get totalHeight() { return this.items.length * this.itemHeight }
stats(virtualItems) {
const savings = ((1 - virtualItems.length / this.items.length) * 100).toFixed(1)
return {
total: this.items.length,
rendered: virtualItems.length,
savings: savings + '%',
totalHeightPx: this.totalHeight + 'px',
}
}
}
// =====================================================
// 3. Ленивая загрузка с очередью приоритетов
// =====================================================
class LazyLoader {
constructor({ concurrency = 2 } = {}) {
this.queue = []
this.running = 0
this.concurrency = concurrency
this.loaded = new Map()
}
// Добавить в очередь с приоритетом (меньше = выше)
enqueue(key, loader, priority = 5) {
if (this.loaded.has(key)) {
console.log(`[LazyLoader] "${key}" уже загружен`)
return Promise.resolve(this.loaded.get(key))
}
return new Promise((resolve, reject) => {
this.queue.push({ key, loader, priority, resolve, reject })
this.queue.sort((a, b) => a.priority - b.priority)
this._process()
})
}
async _process() {
while (this.running < this.concurrency && this.queue.length > 0) {
const { key, loader, priority, resolve, reject } = this.queue.shift()
this.running++
console.log(`[LazyLoader] загружаем "${key}" (приоритет: ${priority})`)
try {
const result = await loader()
this.loaded.set(key, result)
resolve(result)
console.log(`[LazyLoader] "${key}" загружен`)
} catch(e) {
reject(e)
} finally {
this.running--
this._process()
}
}
}
}
// === Демонстрация ===
console.log('=== 1. Мемоизация (v-memo) ===')
const expensiveSort = createMemoizedComputed((items, order) => {
return [...items].sort((a, b) => order === 'asc' ? a - b : b - a)
})
const items = [3, 1, 4, 1, 5, 9, 2, 6]
const result1 = expensiveSort.get([items, 'asc'])
const result2 = expensiveSort.get([items, 'asc']) // кэш!
const result3 = expensiveSort.get([items, 'desc']) // пересчёт
console.log('Результат:', result3)
console.log('Реальных вычислений:', expensiveSort.getCallCount()) // 2
console.log('\n=== 2. Виртуализация ===')
const scroller = new VirtualScroller({ containerHeight: 300, itemHeight: 40 })
scroller.setItems(Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Item ${i}` })))
scroller.setScrollTop(0)
let vis = scroller.getVirtualItems()
console.log('Scroll=0:', scroller.stats(vis))
scroller.setScrollTop(50000)
vis = scroller.getVirtualItems()
console.log('Scroll=50000:', scroller.stats(vis))
console.log('Первый видимый:', vis[0].data.name)
console.log('\n=== 3. Ленивая загрузка ===')
const loader = new LazyLoader({ concurrency: 2 })
const makeLoader = (name, ms) => async () => {
await new Promise(r => setTimeout(r, ms))
return { component: name }
}
// Добавляем 4 задачи, concurrency=2 — две параллельно
Promise.all([
loader.enqueue('Header', makeLoader('Header', 50), 1),
loader.enqueue('Sidebar', makeLoader('Sidebar', 30), 2),
loader.enqueue('Charts', makeLoader('Charts', 80), 5),
loader.enqueue('Footer', makeLoader('Footer', 20), 3),
]).then(results => {
console.log('Загружено:', results.map(r => r.component))
// Повторный вызов — из кэша
loader.enqueue('Header', makeLoader('Header', 50), 1)
})
Реализуй класс `MemoList`, который эмулирует v-memo для списка. Конструктор принимает массив items и функцию `getDeps(item)` — возвращает массив зависимостей для элемента. Метод `update(newItems)` — обновляет список; элемент "перерисовывается" (вызывается renderFn) только если его deps изменились (глубокое сравнение через JSON.stringify). Метод `setRenderer(renderFn)` — устанавливает функцию рендера `(item, index) => string`. Метод `render()` — возвращает массив отрендеренных строк. Метод `getStats()` — возвращает `{ total, rerendered, skipped }` последнего update().
В constructor: this._prevDeps = new Map(), this._renderedCache = new Map(). В update: для каждого i вычисли const depsStr = JSON.stringify(this.getDeps(newItems[i])). Если this._prevDeps.get(i) === depsStr → _stats.skipped++, иначе → const r = this._renderFn(newItems[i], i); this._renderedCache.set(i, r); this._prevDeps.set(i, depsStr); _stats.rerendered++. В render(): return this.items.map((item, i) => this._renderedCache.get(i) || "").
Токены для AI-помощника закончились
Купи токены чтобы задавать вопросы AI прямо в уроке