From: Evan You Date: Wed, 29 Jan 2025 11:07:40 +0000 (+0800) Subject: wip(vapor): v-for X-Git-Tag: v3.6.0-alpha.1~16^2~126 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=181d3403f70fd93dbda68db258f3d5ab36647c6d;p=thirdparty%2Fvuejs%2Fcore.git wip(vapor): v-for --- diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index fced66a228..e825bef1ce 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -37,6 +37,7 @@ import { ShapeFlags, def, getGlobalThis, + getSequence, invokeArrayFns, isArray, isReservedProp, @@ -2533,48 +2534,6 @@ export function traverseStaticChildren( } } -// https://en.wikipedia.org/wiki/Longest_increasing_subsequence -function getSequence(arr: number[]): number[] { - const p = arr.slice() - const result = [0] - let i, j, u, v, c - const len = arr.length - for (i = 0; i < len; i++) { - const arrI = arr[i] - if (arrI !== 0) { - j = result[result.length - 1] - if (arr[j] < arrI) { - p[i] = j - result.push(i) - continue - } - u = 0 - v = result.length - 1 - while (u < v) { - c = (u + v) >> 1 - if (arr[result[c]] < arrI) { - u = c + 1 - } else { - v = c - } - } - if (arrI < arr[result[u]]) { - if (u > 0) { - p[i] = result[u - 1] - } - result[u] = i - } - } - } - u = result.length - v = result[u - 1] - while (u-- > 0) { - result[u] = v - v = p[v] - } - return result -} - function locateNonHydratedAsyncRoot( instance: ComponentInternalInstance, ): ComponentInternalInstance | undefined { diff --git a/packages/runtime-vapor/__tests__/for.spec.ts b/packages/runtime-vapor/__tests__/for.spec.ts index 7e1ff38539..13d1c1407a 100644 --- a/packages/runtime-vapor/__tests__/for.spec.ts +++ b/packages/runtime-vapor/__tests__/for.spec.ts @@ -4,7 +4,7 @@ import { makeRender } from './_utils' const define = makeRender() -describe.todo('createFor', () => { +describe('createFor', () => { test('array source', async () => { const list = ref([{ name: '1' }, { name: '2' }, { name: '3' }]) function reverse() { @@ -573,25 +573,4 @@ describe.todo('createFor', () => { await nextTick() expectCalledTimesToBe('Clear rows', 1, 0, 0, 0) }) - - test.todo('withDestructure', () => { - // const list = ref([{ name: 'a' }, { name: 'b' }, { name: 'c' }]) - // const { host } = define(() => { - // const n1 = createFor( - // () => list.value, - // withDestructure( - // ([{ name }, index]) => [name, index], - // ctx => { - // const span = template(`
  • ${ctx[1]}. ${ctx[0]}
  • `)() - // return span - // }, - // ), - // item => item.name, - // ) - // return n1 - // }).render() - // expect(host.innerHTML).toBe( - // '
  • 0. a
  • 1. b
  • 2. c
  • ', - // ) - }) }) diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index e888546c8e..de8767b274 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -1,25 +1,382 @@ -import type { EffectScope, ShallowRef } from '@vue/reactivity' -import type { Block, Fragment } from './block' - -interface ForBlock extends Fragment { - scope: EffectScope - state: [ - item: ShallowRef, - key: ShallowRef, - index: ShallowRef, - ] +import { EffectScope, type ShallowRef, shallowRef } from '@vue/reactivity' +import { getSequence, isArray, isObject, isString } from '@vue/shared' +import { createComment, createTextNode } from './dom/node' +import { type Block, Fragment, insert, remove as removeBlock } from './block' +import { warn } from '@vue/runtime-dom' +import { currentInstance, isVaporComponent } from './component' +import type { DynamicSlot } from './componentSlots' +import { renderEffect } from './renderEffect' + +type ForBlockState = [ + item: ShallowRef, + key: ShallowRef, + index: ShallowRef, +] + +class ForBlock extends Fragment { + scope: EffectScope | undefined + state: ForBlockState key: any + + constructor( + nodes: Block, + scope: EffectScope | undefined, + state: ForBlockState, + key: any, + ) { + super(nodes) + this.scope = scope + this.state = state + this.key = key + } } type Source = any[] | Record | number | Set | Map +/*! #__NO_SIDE_EFFECTS__ */ export const createFor = ( src: () => Source, renderItem: (block: ForBlock['state']) => Block, getKey?: (item: any, key: any, index?: number) => any, - container?: ParentNode, - hydrationNode?: Node, + /** + * Whether this v-for is used directly on a component. If true, we can avoid + * creating an extra fragment / scope for each block + */ + isComponent = false, once?: boolean, + // hydrationNode?: Node, ): Fragment => { - return [] as any + let isMounted = false + let oldBlocks: ForBlock[] = [] + let newBlocks: ForBlock[] + let parent: ParentNode | undefined | null + const parentAnchor = __DEV__ ? createComment('for') : createTextNode() + const ref = new Fragment(oldBlocks) + const instance = currentInstance! + + if (__DEV__ && !instance) { + warn('createFor() can only be used inside setup()') + } + + const renderList = () => { + const source = src() + const newLength = getLength(source) + const oldLength = oldBlocks.length + newBlocks = new Array(newLength) + + if (!isMounted) { + isMounted = true + for (let i = 0; i < newLength; i++) { + mount(source, i) + } + } else { + parent = parent || parentAnchor!.parentNode + if (!oldLength) { + // fast path for all new + for (let i = 0; i < newLength; i++) { + mount(source, i) + } + } else if (!newLength) { + // fast path for clearing + for (let i = 0; i < oldLength; i++) { + unmount(oldBlocks[i]) + } + } else if (!getKey) { + // unkeyed fast path + const commonLength = Math.min(newLength, oldLength) + for (let i = 0; i < commonLength; i++) { + const [item] = getItem(source, i) + update((newBlocks[i] = oldBlocks[i]), item) + } + for (let i = oldLength; i < newLength; i++) { + mount(source, i) + } + for (let i = newLength; i < oldLength; i++) { + unmount(oldBlocks[i]) + } + } else { + let i = 0 + let e1 = oldLength - 1 // prev ending index + let e2 = newLength - 1 // next ending index + + // 1. sync from start + // (a b) c + // (a b) d e + while (i <= e1 && i <= e2) { + if (tryPatchIndex(source, i)) { + i++ + } else { + break + } + } + + // 2. sync from end + // a (b c) + // d e (b c) + while (i <= e1 && i <= e2) { + if (tryPatchIndex(source, i)) { + e1-- + e2-- + } else { + break + } + } + + // 3. common sequence + mount + // (a b) + // (a b) c + // i = 2, e1 = 1, e2 = 2 + // (a b) + // c (a b) + // i = 0, e1 = -1, e2 = 0 + if (i > e1) { + if (i <= e2) { + const nextPos = e2 + 1 + const anchor = + nextPos < newLength + ? normalizeAnchor(newBlocks[nextPos].nodes) + : parentAnchor + while (i <= e2) { + mount(source, i, anchor) + i++ + } + } + } + + // 4. common sequence + unmount + // (a b) c + // (a b) + // i = 2, e1 = 2, e2 = 1 + // a (b c) + // (b c) + // i = 0, e1 = 0, e2 = -1 + else if (i > e2) { + while (i <= e1) { + unmount(oldBlocks[i]) + i++ + } + } + + // 5. unknown sequence + // [i ... e1 + 1]: a b [c d e] f g + // [i ... e2 + 1]: a b [e d c h] f g + // i = 2, e1 = 4, e2 = 5 + else { + const s1 = i // prev starting index + const s2 = i // next starting index + + // 5.1 build key:index map for newChildren + const keyToNewIndexMap = new Map() + for (i = s2; i <= e2; i++) { + keyToNewIndexMap.set(getKey(...getItem(source, i)), i) + } + + // 5.2 loop through old children left to be patched and try to patch + // matching nodes & remove nodes that are no longer present + let j + let patched = 0 + const toBePatched = e2 - s2 + 1 + let moved = false + // used to track whether any node has moved + let maxNewIndexSoFar = 0 + // works as Map + // Note that oldIndex is offset by +1 + // and oldIndex = 0 is a special value indicating the new node has + // no corresponding old node. + // used for determining longest stable subsequence + const newIndexToOldIndexMap = new Array(toBePatched).fill(0) + + for (i = s1; i <= e1; i++) { + const prevBlock = oldBlocks[i] + if (patched >= toBePatched) { + // all new children have been patched so this can only be a removal + unmount(prevBlock) + } else { + const newIndex = keyToNewIndexMap.get(prevBlock.key) + if (newIndex == null) { + unmount(prevBlock) + } else { + newIndexToOldIndexMap[newIndex - s2] = i + 1 + if (newIndex >= maxNewIndexSoFar) { + maxNewIndexSoFar = newIndex + } else { + moved = true + } + update( + (newBlocks[newIndex] = prevBlock), + ...getItem(source, newIndex), + ) + patched++ + } + } + } + + // 5.3 move and mount + // generate longest stable subsequence only when nodes have moved + const increasingNewIndexSequence = moved + ? getSequence(newIndexToOldIndexMap) + : [] + j = increasingNewIndexSequence.length - 1 + // looping backwards so that we can use last patched node as anchor + for (i = toBePatched - 1; i >= 0; i--) { + const nextIndex = s2 + i + const anchor = + nextIndex + 1 < newLength + ? normalizeAnchor(newBlocks[nextIndex + 1].nodes) + : parentAnchor + if (newIndexToOldIndexMap[i] === 0) { + // mount new + mount(source, nextIndex, anchor) + } else if (moved) { + // move if: + // There is no stable subsequence (e.g. a reverse) + // OR current node is not among the stable sequence + if (j < 0 || i !== increasingNewIndexSequence[j]) { + insert(newBlocks[nextIndex].nodes, parent!, anchor) + } else { + j-- + } + } + } + } + } + } + + ref.nodes = [(oldBlocks = newBlocks)] + if (parentAnchor) { + ref.nodes.push(parentAnchor) + } + } + + const mount = ( + source: any, + idx: number, + anchor: Node | undefined = parentAnchor, + ): ForBlock => { + const [item, key, index] = getItem(source, idx) + const state = [ + shallowRef(item), + shallowRef(key), + shallowRef(index), + ] as ForBlock['state'] + + let nodes: Block + let scope: EffectScope | undefined + if (isComponent) { + // component already has its own scope so no outer scope needed + nodes = renderItem(state) + } else { + scope = new EffectScope() + nodes = scope.run(() => renderItem(state))! + } + + const block = (newBlocks[idx] = new ForBlock( + nodes, + scope, + state, + getKey && getKey(item, key, index), + )) + + if (parent) insert(block.nodes, parent, anchor) + + return block + } + + const tryPatchIndex = (source: any, idx: number) => { + const block = oldBlocks[idx] + const [item, key, index] = getItem(source, idx) + if (block.key === getKey!(item, key, index)) { + update((newBlocks[idx] = block), item) + return true + } + } + + const update = ( + block: ForBlock, + newItem: any, + newKey = block.state[1].value, + newIndex = block.state[2].value, + ) => { + const [item, key, index] = block.state + if ( + newItem !== item.value || + newKey !== key.value || + newIndex !== index.value + ) { + item.value = newItem + key.value = newKey + index.value = newIndex + } + } + + const unmount = ({ nodes, scope }: ForBlock) => { + removeBlock(nodes, parent!) + scope && scope.stop() + } + + once ? renderList() : renderEffect(renderList) + return ref +} + +export function createForSlots( + source: any[] | Record | number | Set | Map, + getSlot: (item: any, key: any, index?: number) => DynamicSlot, +): DynamicSlot[] { + const sourceLength = getLength(source) + const slots = new Array(sourceLength) + for (let i = 0; i < sourceLength; i++) { + const [item, key, index] = getItem(source, i) + slots[i] = getSlot(item, key, index) + } + return slots +} + +function getLength(source: any): number { + if (isArray(source) || isString(source)) { + return source.length + } else if (typeof source === 'number') { + if (__DEV__ && !Number.isInteger(source)) { + warn(`The v-for range expect an integer value but got ${source}.`) + } + return source + } else if (isObject(source)) { + if (source[Symbol.iterator as any]) { + return Array.from(source as Iterable).length + } else { + return Object.keys(source).length + } + } + return 0 +} + +function getItem( + source: any, + idx: number, +): [item: any, key: any, index?: number] { + if (isArray(source) || isString(source)) { + return [source[idx], idx, undefined] + } else if (typeof source === 'number') { + return [idx + 1, idx, undefined] + } else if (isObject(source)) { + if (source[Symbol.iterator as any]) { + source = Array.from(source as Iterable) + return [source[idx], idx, undefined] + } else { + const key = Object.keys(source)[idx] + return [source[key], key, idx] + } + } + return null! +} + +function normalizeAnchor(node: Block): Node { + if (node instanceof Node) { + return node + } else if (isArray(node)) { + return normalizeAnchor(node[0]) + } else if (isVaporComponent(node)) { + return normalizeAnchor(node.block!) + } else { + return normalizeAnchor(node.nodes!) + } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 11580a0643..dc56faf8cf 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -12,3 +12,4 @@ export * from './escapeHtml' export * from './looseEqual' export * from './toDisplayString' export * from './typeUtils' +export * from './subSequence' diff --git a/packages/shared/src/subSequence.ts b/packages/shared/src/subSequence.ts new file mode 100644 index 0000000000..8edde79b7e --- /dev/null +++ b/packages/shared/src/subSequence.ts @@ -0,0 +1,41 @@ +// https://en.wikipedia.org/wiki/Longest_increasing_subsequence +export function getSequence(arr: number[]): number[] { + const p = arr.slice() + const result = [0] + let i, j, u, v, c + const len = arr.length + for (i = 0; i < len; i++) { + const arrI = arr[i] + if (arrI !== 0) { + j = result[result.length - 1] + if (arr[j] < arrI) { + p[i] = j + result.push(i) + continue + } + u = 0 + v = result.length - 1 + while (u < v) { + c = (u + v) >> 1 + if (arr[result[c]] < arrI) { + u = c + 1 + } else { + v = c + } + } + if (arrI < arr[result[u]]) { + if (u > 0) { + p[i] = result[u - 1] + } + result[u] = i + } + } + } + u = result.length + v = result[u - 1] + while (u-- > 0) { + result[u] = v + v = p[v] + } + return result +}