Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/batch-registry-ownership-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'grid-layout-plus': patch
---

Read all item ownership facts before updating registry state and styles, reducing repeated layout calculations for rejected items.
5 changes: 5 additions & 0 deletions .changeset/coalesce-registry-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'grid-layout-plus': patch
---

Coalesce GridItem registry validation into one pass per flush and look up layout items by id, removing a cubic slowdown with many items.
5 changes: 5 additions & 0 deletions .changeset/copy-sealed-layouts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'grid-layout-plus': patch
---

Copy the engine's internal frozen layouts without re-validating every item, reducing per-drag-step cloning cost.
5 changes: 5 additions & 0 deletions .changeset/keep-equal-item-styles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'grid-layout-plus': patch
---

Keep the previous style object when a GridItem's computed position style is unchanged, so moving one item no longer re-renders every item.
5 changes: 5 additions & 0 deletions .changeset/optimistic-first-placement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'grid-layout-plus': patch
---

Render a newly mounted GridItem at its committed position from the first frame, so items no longer fly in from the container corner.
5 changes: 5 additions & 0 deletions .changeset/report-registry-validation-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'grid-layout-plus': patch
---

Report deferred registry validation errors through the application error handler without interrupting Vue's scheduler.
5 changes: 5 additions & 0 deletions .changeset/same-tick-col-num.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'grid-layout-plus': patch
---

Validate a layout that changes in the same tick as `colNum` against the incoming column count, avoiding transient `invalid-layout` and `invalid-registration` errors.
5 changes: 5 additions & 0 deletions .changeset/sweep-collision-checks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'grid-layout-plus': patch
---

Speed up overlap detection with a top-edge sweep before the pairwise diagnostic loop, especially for vertically stacked collision-free layouts.
5 changes: 5 additions & 0 deletions .changeset/write-item-position-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'grid-layout-plus': patch
---

Write GridItem position styles directly to the element instead of re-rendering the component on every move.
76 changes: 69 additions & 7 deletions src/components/grid-item.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import {
computed,
inject,
markRaw,
onBeforeMount,
onBeforeUnmount,
onMounted,
Expand All @@ -34,6 +35,7 @@ import { createCoreData, getControlPosition } from '../helpers/draggable'
import { getColsFromBreakpoint } from '../helpers/responsive'
import { getDocumentDir } from '../helpers/dom'
import { snapshotInteractOption, snapshotInteractSelector } from '../helpers/interact-options'
import { sameStyle } from '../helpers/style-equality'
import { gridToPixelRect, pixelSizeToGridSize, pointerToGridPosition } from '../core/utils'

import interact from 'interactjs'
Expand Down Expand Up @@ -121,6 +123,8 @@ const state = reactive({
rtl: false,
})

// 首次放置是否仍在等待注册表结论,见 createStyle。
let placementPending = !props.decorative && !props.internal
let dragEventSet = false
let resizeEventSet = false

Expand Down Expand Up @@ -223,6 +227,7 @@ const instance = reactive({
finishDragInteraction,
finishResizeInteraction,
refreshPositionStyle: createStyle,
clearPositionStyle: () => clearPositionStyle(),
disableInteractionBinding,
})

Expand Down Expand Up @@ -444,7 +449,8 @@ watch(
return
}
state.registered = false
state.style = {}
placementPending = false
assignStyle({})
layout.updateItem(instance, previousId)
},
)
Expand Down Expand Up @@ -643,15 +649,66 @@ watch(
},
)

// 内容相同时保留原样式对象:父布局每次提交都会让所有 GridItem 重新求值样式,
// 若为盒子未变的元素写入新对象,下方的同步 watcher 会做一次无意义的 DOM 写入。
function assignStyle(next: Record<string, string>) {
if (!sameStyle(state.style, next)) state.style = next
}

// 定位样式不触发 GridItem 重新渲染:模板绑定的 renderStyle 是非响应式对象,
// state.style 变化时由同步 watcher 同时改写它与根元素的内联样式。
// 只有盒子变化时因此没有 render,也没有 onUpdated → 注册表校验(校验读取 offsetParent,
// 会强制浏览器计算样式与布局)。定位样式的键与值由 validatePositionStyleResult 限定,
// 改变它们不会改变本元素的 containing block,跳过这一轮校验不削弱归属保证。
// renderStyle 始终等于已写入的样式:SSR、首次挂载和其他原因引起的重新渲染都输出当前值,
// Vue 的 style patch(含与父级透传 style 的合并)不会写回过期的定位。
// 模板根节点以 v-if="renderStyle" 渲染:不在 GridLayout 下时 setup 在此之前抛错,renderStyle 不存在,
// 组件不输出任何 DOM(E2E-26 契约;此前由模板读取 state.style 时的渲染异常间接保证)。
const renderStyle: Record<string, string> = markRaw({})

function toCssPropertyName(key: string): string {
return key.startsWith('--') ? key : key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)
}

function writePositionStyle(next: Readonly<Record<string, string>>) {
const element = wrapper.value
for (const key of Object.keys(renderStyle)) {
if (Object.prototype.hasOwnProperty.call(next, key)) continue
delete renderStyle[key]
element?.style.removeProperty(toCssPropertyName(key))
}
for (const [key, value] of Object.entries(next)) {
if (renderStyle[key] === value) continue
renderStyle[key] = value
element?.style.setProperty(toCssPropertyName(key), value)
}
}

watch(
() => state.style,
next => writePositionStyle(next),
{ flush: 'sync' },
)

// 首次放置在注册表给出结论之前处于待定状态。待定期间直接使用父布局已提交的位置样式(乐观首帧),
// 否则元素先以无样式渲染、下一轮才得到 transform,期间任何强制样式计算都会让过渡从原点"飞入"。
// registered 保持 false:归属映射、交互绑定与 emitContainerResized 等副作用仍只在确认后发生。
// 注册表拒绝时调用 clearPositionStyle 结束待定并清空样式;id 变化不属于首次放置。
function clearPositionStyle() {
placementPending = false
assignStyle({})
}

function createStyle() {
if (!state.registered) {
state.style = {}
assignStyle(placementPending ? { ...layout.getPositionStyle(props.i) } : {})
return
}
placementPending = false

// 非装饰且未处于拖拽或缩放状态的元素直接使用父布局原子提交的样式;只有交互暂态和占位项在本地重新计算。
if (!props.decorative && !state.isDragging && !state.isResizing) {
state.style = { ...layout.getPositionStyle(props.i) }
assignStyle({ ...layout.getPositionStyle(props.i) })
return
}
const renderedWidth = resolvedContainerWidth()
Expand All @@ -666,7 +723,7 @@ function createStyle() {
!Number.isSafeInteger(effectiveH.value) ||
effectiveH.value <= 0))
) {
state.style = {}
assignStyle({})
return
}

Expand Down Expand Up @@ -724,7 +781,7 @@ function createStyle() {
value => typeof value === 'number' && Number.isFinite(value),
)
) {
state.style = props.decorative ? {} : { ...layout.getPositionStyle(props.i) }
assignStyle(props.decorative ? {} : { ...layout.getPositionStyle(props.i) })
return
}
const direction = renderRtl.value ? 'rtl' : 'ltr'
Expand Down Expand Up @@ -757,7 +814,7 @@ function createStyle() {
}
}

state.style = style
assignStyle(style)
}

function resetInteractionState(type?: 'drag' | 'resize'): void {
Expand Down Expand Up @@ -1619,7 +1676,12 @@ function syncAutoHeightTarget(): void {
</script>

<template>
<section ref="wrapper" :class="className" :style="state.style">
<section
v-if="renderStyle"
ref="wrapper"
:class="className"
:style="renderStyle"
>
<slot></slot>
<template v-if="resizableAndNotStatic">
<span
Expand Down
98 changes: 83 additions & 15 deletions src/components/grid-layout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@
* 连接各模块、派发公共事件,并向直属 GridItem 提供已提交的运行时配置。
*/
import {
ErrorCodes,
computed,
getCurrentInstance,
handleError,
nextTick,
onBeforeMount,
onBeforeUnmount,
onMounted,
provide,
queuePostFlushCb,
reactive,
ref,
shallowRef,
Expand All @@ -25,6 +29,7 @@ import GridItem from './grid-item.vue'
import { createEventEmitter } from '@vexip-ui/utils'
import { useContainerWidth } from '../composables/useContainerWidth'
import { EMITTER_KEY, LAYOUT_KEY, bottom, cloneLayout, getLayoutItem } from '../helpers/common'
import { sameStyle } from '../helpers/style-equality'
import { getDocumentDir } from '../helpers/dom'
import {
cloneResponsiveLayouts,
Expand Down Expand Up @@ -287,7 +292,7 @@ const effectiveConfig = computed(() => ({

const state = reactive({
width: initialWidth as number | null,
mergedStyle: {},
mergedStyle: {} as { height?: string },
lastLayoutLength: 0,
isDragging: false,
suppressTransitions: true,
Expand Down Expand Up @@ -321,6 +326,26 @@ const registrationEpisodes = new WeakMap<object, string | null>()
const currentLayout = ref(cloneLayout(props.layout))
let committedLayout = cloneLayout(currentLayout.value)

// 按 id 查找(注册表成员校验、GridItem 读取自身布局项)走原始数组:在深层响应式代理上线性查找,
// 每个元素和字段都会触发一次代理拦截,一轮校验即 O(n²)。currentLayout 只会被整体替换、从不原地修改,
// 因此每次替换只需重建一次索引。
let currentLayoutIndexSource: ReadonlyLayout | null = null
let currentLayoutIndex = new Map<LayoutItem['i'], ReadonlyLayoutItem>()

function getCurrentLayoutIndex(): ReadonlyMap<LayoutItem['i'], ReadonlyLayoutItem> {
// 读取 `.value` 使 computed 调用方仍订阅布局替换。
const layout = toRaw(currentLayout.value)
if (layout !== currentLayoutIndexSource) {
currentLayoutIndex = new Map()
// 与 getLayoutItem 一致:同一 id 取第一个元素。
for (const item of layout) {
if (!currentLayoutIndex.has(item.i)) currentLayoutIndex.set(item.i, item)
}
currentLayoutIndexSource = layout
}
return currentLayoutIndex
}

const itemZIndexRanks = computed(() => {
const ordered = currentLayout.value
.map((item, index) => ({ id: item.i, index, zIndex: item.zIndex ?? 0 }))
Expand Down Expand Up @@ -393,13 +418,17 @@ function resolveEngineConfig(

let engineConfig = resolveEngineConfig()
const appliedEngineConfig = shallowRef(engineConfig)
const renderedLayoutStyle = computed(() => ({
...state.mergedStyle,
'--vgl-layout-interaction-z-index':
appliedEngineConfig.value.collisionMode === 'overlap'
? '0'
: String(currentLayout.value.length),
}))
// 内容相同时沿用上一次的样式对象,根节点样式不因布局提交而产生新的响应式变化。
const renderedLayoutStyle = computed<Record<string, string | undefined>>(previous => {
const next = {
...state.mergedStyle,
'--vgl-layout-interaction-z-index':
appliedEngineConfig.value.collisionMode === 'overlap'
? '0'
: String(currentLayout.value.length),
}
return previous && sameStyle(previous, next) ? previous : next
})
const canNormalizeInitialLayout = !responsiveMode.value || initialProvisionalBreakpoint !== null
// 响应式宽度未解析时暂不收紧横向边界,待选出断点和列数后再做完整规范化。
const initialEngine = canNormalizeInitialLayout
Expand Down Expand Up @@ -700,14 +729,24 @@ const itemRegistry = createGridItemRegistry({
registrationEpisodes,
isUnavailable: () => disposing || sealedError !== null,
getRoot: () => wrapper.value,
hasLayoutItem: id => getLayoutItem(currentLayout.value, id) !== undefined,
hasLayoutItem: id => getCurrentLayoutIndex().has(id),
getActiveInteractionId: () => interaction.getActive()?.id ?? null,
prepareActiveForTerminal,
finishActiveForExternalUpdate: () => {
finishInteraction('cancelled', 'external-update', { nativeEvent: null })
},
// 在当前 flush 的 post 阶段末尾执行(排在本轮所有 updated/mounted 钩子之后),
// 校验结果触发的 watcher 仍在同一次 flush 内完成,与原先 onUpdated 内同步校验的时序一致。
// Vue 执行 post 回调时没有 try/finally:回调抛出会让调度器停在 post 阶段,页面上所有应用的
// updated/mounted 钩子和 flush: 'post' watcher 都不再执行。因此异常在此捕获并交给应用的 errorHandler。
scheduleValidation: callback => {
nextTick(() => runAsyncBoundary(callback))
queuePostFlushCb(() => {
try {
runAsyncBoundary(callback)
} catch (error) {
reportPostFlushError(error)
}
})
},
nextEvaluationId,
emitError: error => emit('error', error),
Expand Down Expand Up @@ -966,7 +1005,21 @@ const layoutSync = useGridLayoutSync<Breakpoint>({
nextRevision,
})
layoutSyncRef.current = layoutSync
const observeLayoutProp = layoutSync.observeLayoutProp
// 非响应式模式下 colNum 与 layout 可能在同一 tick 变化,而 layout watcher 先于配置
// watcher 运行;因此用随 layout 一起到达的 colNum 观察新布局,布局未被接纳时恢复原列数。
function observeLayoutProp(): void {
if (responsiveMode.value || currentColNum.value === effectiveColNum.value) {
layoutSync.observeLayoutProp()
return
}
const previousColNum = currentColNum.value
currentColNum.value = effectiveColNum.value
try {
layoutSync.observeLayoutProp()
} finally {
if (engineConfig.cols !== currentColNum.value) currentColNum.value = previousColNum
}
}

function replaceEngineLayout(layout: ReadonlyLayout, config = resolveEngineConfig()): boolean {
const hadActiveInteraction = interaction.hasActive()
Expand Down Expand Up @@ -1030,6 +1083,20 @@ function sealCounter(error: GridLayoutValidationError, emitRuntime: boolean): vo
}
}

const layoutInstance = getCurrentInstance()

/** 把 post 回调中的异常交给应用 errorHandler(无 errorHandler 时仅打印),不向调度器抛出。 */
function reportPostFlushError(error: unknown): void {
try {
handleError(error, layoutInstance, ErrorCodes.SCHEDULER, false)
} catch (handlerError) {
// errorHandler 自身抛出时,在调度器之外重新抛出,保留未捕获异常的可见性。
queueMicrotask(() => {
throw handlerError
})
}
}

function runAsyncBoundary<T>(callback: () => T): T | undefined {
if (disposing || sealedError) return undefined
try {
Expand Down Expand Up @@ -1543,7 +1610,7 @@ const exposed = {
defineExpose<GridLayoutExpose>(exposed)

function getInjectedLayoutItem(id: LayoutItem['i']): ReadonlyLayoutItem | undefined {
const item = getLayoutItem(currentLayout.value, id)
const item = getCurrentLayoutIndex().get(id)
return item ? cloneLayout([item])[0] : undefined
}

Expand Down Expand Up @@ -1638,9 +1705,10 @@ function layoutUpdate() {
}

function updateHeight() {
state.mergedStyle = {
height: containerHeight(),
}
const height = containerHeight()
// 高度未变时保留原对象:每次提交都会调用此处,写入新对象会让根节点样式重新求值。
if ('height' in state.mergedStyle && state.mergedStyle.height === height) return
state.mergedStyle = { height }
}

function containerHeight() {
Expand Down
Loading
Loading