Skip to content

v-for Directive

Consider the following component:

vue
<script setup>
import { ref } from "vue";
const items = ref([
  { id: 1, name: "a" },
  { id: 2, name: "b" },
  { id: 3, name: "c" },
]);
</script>

<template>
  <div v-for="item in items" :key="item.id">{{ item.name }}</div>
</template>

Compilation Result and Overview

The compilation result is as follows:

js
import {
  renderEffect as _renderEffect,
  setText as _setText,
  createFor as _createFor,
  template as _template,
} from "vue/vapor";

const t0 = _template("<div></div>");

function _sfc_render(_ctx) {
  const n0 = _createFor(
    () => _ctx.items,
    (_ctx0) => {
      const n2 = t0();
      _renderEffect(() => _setText(n2, _ctx0[0].name));
      return n2;
    },
    (item) => item.id
  );
  return n0;
}

createFor takes the following arguments:

  1. source: The source to loop over (() => _ctx.items)
  2. renderItem: The function to render each item
  3. getKey: The function to get the key (optional)

_ctx0 is an array of [item, key, index], where _ctx0[0] is item, _ctx0[1] is key, and _ctx0[2] is index.

Various Patterns

Nested v-for

vue
<template>
  <div v-for="item in list">
    <span v-for="child in item">{{ child + item }}</span>
  </div>
</template>
js
const n0 = _createFor(
  () => _ctx.list,
  (_ctx0) => {
    const n5 = t1();
    const n2 = _createFor(
      () => _ctx0[0],
      (_ctx1) => {
        const n4 = t0();
        _renderEffect(() => _setText(n4, _ctx1[0] + _ctx0[0]));
        return n4;
      }
    );
    _insert(n2, n5);
    return n5;
  }
);

When nested, _ctx0 refers to the outer v-for's context, and _ctx1 refers to the inner v-for's context.

Destructuring

vue
<template>
  <div v-for="{ id, ...other } in list" :key="id">
    {{ id + other }}
  </div>
</template>
js
const n0 = _createFor(
  () => _ctx.list,
  _withDestructure(
    ([{ id, ...other }, index]) => [id, other, index],
    (_ctx0) => {
      const n2 = t0();
      _renderEffect(() => _setText(n2, _ctx0[0] + _ctx0[1] + _ctx0[2]));
      return n2;
    }
  ),
  ({ id, ...other }, index) => id
);

For destructuring, the withDestructure helper is used to convert the destructured values into an array.

Reading the Compiler

IR

First, let's look at the IR for v-for.

75export interface IRFor {
76  source: SimpleExpressionNode
77  value?: SimpleExpressionNode
78  key?: SimpleExpressionNode
79  index?: SimpleExpressionNode
80}
81
82export interface ForIRNode extends BaseIRNode, IRFor {
83  type: IRNodeTypes.FOR
84  id: number
85  keyProp?: SimpleExpressionNode
86  render: BlockIRNode
87  once: boolean
88}
ts
export interface IRFor {
  source: SimpleExpressionNode;
  value?: SimpleExpressionNode;
  key?: SimpleExpressionNode;
  index?: SimpleExpressionNode;
}

export interface ForIRNode extends BaseIRNode, IRFor {
  type: IRNodeTypes.FOR;
  id: number;
  keyProp?: SimpleExpressionNode;
  render: BlockIRNode;
  once: boolean;
}
  • source: The source expression to loop over
  • value: The loop variable (item)
  • key: The key variable (for object loops)
  • index: The index variable
  • keyProp: The :key prop expression
  • render: The rendering block for each item
  • once: When combined with v-once

Transformer

transformVFor is defined using createStructuralDirectiveTransform.

21export const transformVFor: NodeTransform = createStructuralDirectiveTransform(
22  'for',
23  processFor,
24)
25
26export function processFor(
27  node: ElementNode,
28  dir: VaporDirectiveNode,
29  context: TransformContext<ElementNode>,
30) {
31  if (!dir.exp) {
32    context.options.onError(
33      createCompilerError(ErrorCodes.X_V_FOR_NO_EXPRESSION, dir.loc),
34    )
35    return
36  }
37  const parseResult = dir.forParseResult
38  if (!parseResult) {
39    context.options.onError(
40      createCompilerError(ErrorCodes.X_V_FOR_MALFORMED_EXPRESSION, dir.loc),
41    )
42    return
43  }
44
45  const { source, value, key, index } = parseResult
46
47  const keyProp = findProp(node, 'key')
48  const keyProperty = keyProp && propToExpression(keyProp)
49  context.node = node = wrapTemplate(node, ['for'])
50  context.dynamic.flags |= DynamicFlag.NON_TEMPLATE | DynamicFlag.INSERT
51  const id = context.reference()
52  const render: BlockIRNode = newBlock(node)
53  const exitBlock = context.enterBlock(render, true)
54  context.reference()
55
56  return (): void => {
57    exitBlock()
58    context.registerOperation({
59      type: IRNodeTypes.FOR,
60      id,
61      source: source as SimpleExpressionNode,
62      value: value as SimpleExpressionNode | undefined,
63      key: key as SimpleExpressionNode | undefined,
64      index: index as SimpleExpressionNode | undefined,
65      keyProp: keyProperty,
66      render,
67      once: context.inVOnce,
68    })
69  }
70}
ts
export const transformVFor: NodeTransform = createStructuralDirectiveTransform(
  "for",
  processFor
);

export function processFor(
  node: ElementNode,
  dir: VaporDirectiveNode,
  context: TransformContext<ElementNode>
) {
  // ...
  const parseResult = dir.forParseResult;
  const { source, value, key, index } = parseResult;

  const keyProp = findProp(node, "key");
  const keyProperty = keyProp && propToExpression(keyProp);
  // ...

  const render: BlockIRNode = newBlock(node);
  const exitBlock = context.enterBlock(render, true);

  return (): void => {
    exitBlock();
    context.registerOperation({
      type: IRNodeTypes.FOR,
      id,
      source: source as SimpleExpressionNode,
      value: value as SimpleExpressionNode | undefined,
      key: key as SimpleExpressionNode | undefined,
      index: index as SimpleExpressionNode | undefined,
      keyProp: keyProperty,
      render,
      once: context.inVOnce,
    });
  };
}

forParseResult is the result parsed by the @vue/compiler-dom parser, containing source, value, key, and index.

Codegen

The genFor function handles code generation.

14export function genFor(
15  oper: ForIRNode,
16  context: CodegenContext,
17): CodeFragment[] {
18  const { vaporHelper } = context
19  const { source, value, key, index, render, keyProp, once, id } = oper
20
21  let isDestructureAssignment = false
22  let rawValue: string | null = null
23  const rawKey = key && key.content
24  const rawIndex = index && index.content
25
26  const sourceExpr = ['() => (', ...genExpression(source, context), ')']
27
28  const idsOfValue = new Set<string>()
29  if (value) {
30    rawValue = value && value.content
31    if ((isDestructureAssignment = !!value.ast)) {
32      walkIdentifiers(
33        value.ast,
34        (id, _, __, ___, isLocal) => {
35          if (isLocal) idsOfValue.add(id.name)
36        },
37        true,
38      )
39    } else {
40      idsOfValue.add(rawValue)
41    }
42  }
43
44  const [depth, exitScope] = context.enterScope()
45  let propsName: string
46  const idMap: Record<string, string | null> = {}
47  if (context.options.prefixIdentifiers) {
48    propsName = `_ctx${depth}`
49    Array.from(idsOfValue).forEach(
50      (id, idIndex) => (idMap[id] = `${propsName}[${idIndex}]`),
51    )
52    if (rawKey) idMap[rawKey] = `${propsName}[${idsOfValue.size}]`
53    if (rawIndex) idMap[rawIndex] = `${propsName}[${idsOfValue.size + 1}]`
54  } else {
55    propsName = `[${[rawValue || ((rawKey || rawIndex) && '_'), rawKey || (rawIndex && '__'), rawIndex].filter(Boolean).join(', ')}]`
56  }
57
58  let blockFn = context.withId(
59    () => genBlock(render, context, [propsName]),
60    idMap,
61  )
62  exitScope()
63
64  let getKeyFn: CodeFragment[] | false = false
65  if (keyProp) {
66    const idMap: Record<string, null> = {}
67    if (rawKey) idMap[rawKey] = null
68    if (rawIndex) idMap[rawIndex] = null
69    idsOfValue.forEach(id => (idMap[id] = null))
70
71    const expr = context.withId(() => genExpression(keyProp, context), idMap)
72    getKeyFn = [
73      ...genMulti(
74        ['(', ')', ', '],
75        rawValue ? rawValue : rawKey || rawIndex ? '_' : undefined,
76        rawKey ? rawKey : rawIndex ? '__' : undefined,
77        rawIndex,
78      ),
79      ' => (',
80      ...expr,
81      ')',
82    ]
83  }
84
85  if (isDestructureAssignment) {
86    const idMap: Record<string, null> = {}
87    idsOfValue.forEach(id => (idMap[id] = null))
88    if (rawKey) idMap[rawKey] = null
89    if (rawIndex) idMap[rawIndex] = null
90    const destructureAssignmentFn: CodeFragment[] = [
91      '(',
92      ...genMulti(
93        DELIMITERS_ARRAY,
94        rawValue ? rawValue : rawKey || rawIndex ? '_' : undefined,
95        rawKey ? rawKey : rawIndex ? '__' : undefined,
96        rawIndex,
97      ),
98      ') => ',
99      ...genMulti(DELIMITERS_ARRAY, ...idsOfValue, rawKey, rawIndex),
100    ]
101
102    blockFn = genCall(
103      vaporHelper('withDestructure'),
104      destructureAssignmentFn,
105      blockFn,
106    )
107  }
108
109  return [
110    NEWLINE,
111    `const n${id} = `,
112    ...genCall(
113      vaporHelper('createFor'),
114      sourceExpr,
115      blockFn,
116      getKeyFn,
117      false, // todo: getMemo
118      false, // todo: hydrationNode
119      once && 'true',
120    ),
121  ]
122}

Key processes:

  1. Source expression generation: In the form () => (_ctx.items)
  2. Render function generation: A function that takes _ctx0 as argument
  3. Key function generation: When :key prop exists
  4. Destructuring handling: Using withDestructure
ts
if (isDestructureAssignment) {
  blockFn = genCall(
    vaporHelper("withDestructure"),
    destructureAssignmentFn,
    blockFn
  );
}

return [
  NEWLINE,
  `const n${id} = `,
  ...genCall(
    vaporHelper("createFor"),
    sourceExpr,
    blockFn,
    getKeyFn,
    false, // getMemo
    false, // hydrationNode
    once && "true"
  ),
];

Reading the Runtime

createFor is implemented in apiCreateFor.ts in runtime-vapor.

44export const createFor = (
45  src: () => Source,
46  renderItem: (block: any) => Block,
47  getKey?: (item: any, key: any, index?: number) => any,
48  getMemo?: (item: any, key: any, index?: number) => any[],
49  hydrationNode?: Node,
50  once?: boolean,
51): Fragment => {
52  let isMounted = false
53  let oldBlocks: ForBlock[] = []
54  let newBlocks: ForBlock[]
55  let parent: ParentNode | undefined | null
56  const update = getMemo ? updateWithMemo : updateWithoutMemo
57  const parentScope = getCurrentScope()!
58  const parentAnchor = __DEV__ ? createComment('for') : createTextNode()
59  const ref: Fragment = {
60    nodes: oldBlocks,
61    [fragmentKey]: true,
62  }
63
64  const instance = currentInstance!
65  if (__DEV__ && (!instance || !isRenderEffectScope(parentScope))) {
66    warn('createFor() can only be used inside setup()')
67  }
68
69  createChildFragmentDirectives(
70    parentAnchor,
71    () => oldBlocks.map(b => b.scope),
72    // source getter
73    () => traverse(src(), 1),
74    // init cb
75    getValue => doFor(getValue()),
76    // effect cb
77    getValue => doFor(getValue()),
78    once,
79  )
80
81  return ref
82
83  function doFor(source: any) {
84    const newLength = getLength(source)
85    const oldLength = oldBlocks.length
86    newBlocks = new Array(newLength)
87
88    if (!isMounted) {
89      isMounted = true
90      mountList(source)
91    } else {
92      parent = parent || parentAnchor.parentNode
93      if (!oldLength) {
94        // fast path for all new
95        mountList(source)
96      } else if (!newLength) {
97        // fast path for clearing
98        for (let i = 0; i < oldLength; i++) {
99          unmount(oldBlocks[i])
100        }
101      } else if (!getKey) {
102        // unkeyed fast path
103        const commonLength = Math.min(newLength, oldLength)
104        for (let i = 0; i < commonLength; i++) {
105          const [item] = getItem(source, i)
106          update((newBlocks[i] = oldBlocks[i]), item)
107        }
108        mountList(source, oldLength)
109        for (let i = newLength; i < oldLength; i++) {
110          unmount(oldBlocks[i])
111        }
112      } else {
113        let i = 0
114        let e1 = oldLength - 1 // prev ending index
115        let e2 = newLength - 1 // next ending index
116
117        // 1. sync from start
118        // (a b) c
119        // (a b) d e
120        while (i <= e1 && i <= e2) {
121          if (tryPatchIndex(source, i)) {
122            i++
123          } else {
124            break
125          }
126        }
127
128        // 2. sync from end
129        // a (b c)
130        // d e (b c)
131        while (i <= e1 && i <= e2) {
132          if (tryPatchIndex(source, i)) {
133            e1--
134            e2--
135          } else {
136            break
137          }
138        }
139
140        // 3. common sequence + mount
141        // (a b)
142        // (a b) c
143        // i = 2, e1 = 1, e2 = 2
144        // (a b)
145        // c (a b)
146        // i = 0, e1 = -1, e2 = 0
147        if (i > e1) {
148          if (i <= e2) {
149            const nextPos = e2 + 1
150            const anchor =
151              nextPos < newLength
152                ? normalizeAnchor(newBlocks[nextPos].nodes)
153                : parentAnchor
154            while (i <= e2) {
155              mount(source, i, anchor)
156              i++
157            }
158          }
159        }
160
161        // 4. common sequence + unmount
162        // (a b) c
163        // (a b)
164        // i = 2, e1 = 2, e2 = 1
165        // a (b c)
166        // (b c)
167        // i = 0, e1 = 0, e2 = -1
168        else if (i > e2) {
169          while (i <= e1) {
170            unmount(oldBlocks[i])
171            i++
172          }
173        }
174
175        // 5. unknown sequence
176        // [i ... e1 + 1]: a b [c d e] f g
177        // [i ... e2 + 1]: a b [e d c h] f g
178        // i = 2, e1 = 4, e2 = 5
179        else {
180          const s1 = i // prev starting index
181          const s2 = i // next starting index
182
183          // 5.1 build key:index map for newChildren
184          const keyToNewIndexMap = new Map()
185          for (i = s2; i <= e2; i++) {
186            keyToNewIndexMap.set(getKey(...getItem(source, i)), i)
187          }
188
189          // 5.2 loop through old children left to be patched and try to patch
190          // matching nodes & remove nodes that are no longer present
191          let j
192          let patched = 0
193          const toBePatched = e2 - s2 + 1
194          let moved = false
195          // used to track whether any node has moved
196          let maxNewIndexSoFar = 0
197          // works as Map<newIndex, oldIndex>
198          // Note that oldIndex is offset by +1
199          // and oldIndex = 0 is a special value indicating the new node has
200          // no corresponding old node.
201          // used for determining longest stable subsequence
202          const newIndexToOldIndexMap = new Array(toBePatched).fill(0)
203
204          for (i = s1; i <= e1; i++) {
205            const prevBlock = oldBlocks[i]
206            if (patched >= toBePatched) {
207              // all new children have been patched so this can only be a removal
208              unmount(prevBlock)
209            } else {
210              const newIndex = keyToNewIndexMap.get(prevBlock.key)
211              if (newIndex == null) {
212                unmount(prevBlock)
213              } else {
214                newIndexToOldIndexMap[newIndex - s2] = i + 1
215                if (newIndex >= maxNewIndexSoFar) {
216                  maxNewIndexSoFar = newIndex
217                } else {
218                  moved = true
219                }
220                update(
221                  (newBlocks[newIndex] = prevBlock),
222                  ...getItem(source, newIndex),
223                )
224                patched++
225              }
226            }
227          }
228
229          // 5.3 move and mount
230          // generate longest stable subsequence only when nodes have moved
231          const increasingNewIndexSequence = moved
232            ? getSequence(newIndexToOldIndexMap)
233            : []
234          j = increasingNewIndexSequence.length - 1
235          // looping backwards so that we can use last patched node as anchor
236          for (i = toBePatched - 1; i >= 0; i--) {
237            const nextIndex = s2 + i
238            const anchor =
239              nextIndex + 1 < newLength
240                ? normalizeAnchor(newBlocks[nextIndex + 1].nodes)
241                : parentAnchor
242            if (newIndexToOldIndexMap[i] === 0) {
243              // mount new
244              mount(source, nextIndex, anchor)
245            } else if (moved) {
246              // move if:
247              // There is no stable subsequence (e.g. a reverse)
248              // OR current node is not among the stable sequence
249              if (j < 0 || i !== increasingNewIndexSequence[j]) {
250                insert(newBlocks[nextIndex].nodes, parent!, anchor)
251              } else {
252                j--
253              }
254            }
255          }
256        }
257      }
258    }
259
260    ref.nodes = [(oldBlocks = newBlocks), parentAnchor]
261  }
262
263  function mount(
264    source: any,
265    idx: number,
266    anchor: Node = parentAnchor,
267  ): ForBlock {
268    const scope = new BlockEffectScope(instance, parentScope)
269
270    const [item, key, index] = getItem(source, idx)
271    const state = [
272      shallowRef(item),
273      shallowRef(key),
274      shallowRef(index),
275    ] as ForBlock['state']
276    const block: ForBlock = (newBlocks[idx] = {
277      nodes: null!, // set later
278      scope,
279      state,
280      key: getKey && getKey(item, key, index),
281      memo: getMemo && getMemo(item, key, index),
282      [fragmentKey]: true,
283    })
284    block.nodes = scope.run(() => renderItem(proxyRefs(state)))!
285
286    invokeWithMount(scope, () => {
287      // TODO v-memo
288      // if (getMemo) block.update()
289      if (parent) insert(block.nodes, parent, anchor)
290    })
291
292    return block
293  }
294
295  function mountList(source: any, offset = 0) {
296    for (let i = offset; i < getLength(source); i++) {
297      mount(source, i)
298    }
299  }
300
301  function tryPatchIndex(source: any, idx: number) {
302    const block = oldBlocks[idx]
303    const [item, key, index] = getItem(source, idx)
304    if (block.key === getKey!(item, key, index)) {
305      update((newBlocks[idx] = block), item)
306      return true
307    }
308  }
309
310  function updateWithMemo(
311    block: ForBlock,
312    newItem: any,
313    newKey = block.state[1].value,
314    newIndex = block.state[2].value,
315  ) {
316    const [, key, index] = block.state
317    let needsUpdate = newKey !== key.value || newIndex !== index.value
318    if (!needsUpdate) {
319      const oldMemo = block.memo!
320      const newMemo = (block.memo = getMemo!(newItem, newKey, newIndex))
321      for (let i = 0; i < newMemo.length; i++) {
322        if ((needsUpdate = newMemo[i] !== oldMemo[i])) {
323          break
324        }
325      }
326    }
327
328    if (needsUpdate) setState(block, newItem, newKey, newIndex)
329    invokeWithUpdate(block.scope)
330  }
331
332  function updateWithoutMemo(
333    block: ForBlock,
334    newItem: any,
335    newKey = block.state[1].value,
336    newIndex = block.state[2].value,
337  ) {
338    const [item, key, index] = block.state
339    let needsUpdate =
340      newItem !== item.value ||
341      newKey !== key.value ||
342      newIndex !== index.value ||
343      // shallowRef list
344      (!isReactive(newItem) && isObject(newItem))
345
346    if (needsUpdate) setState(block, newItem, newKey, newIndex)
347    invokeWithUpdate(block.scope)
348  }
349
350  function unmount({ nodes, scope }: ForBlock) {
351    invokeWithUnmount(scope, () => {
352      removeBlock(nodes, parent!)
353    })
354  }
355}

Overview

ts
export const createFor = (
  src: () => Source,
  renderItem: (block: any) => Block,
  getKey?: (item: any, key: any, index?: number) => any,
  getMemo?: (item: any, key: any, index?: number) => any[],
  hydrationNode?: Node,
  once?: boolean
): Fragment => {
  let oldBlocks: ForBlock[] = [];
  // ...
};

ForBlock Structure

ts
interface ForBlock extends Fragment {
  scope: BlockEffectScope;
  state: [
    item: ShallowRef<any>,
    key: ShallowRef<any>,
    index: ShallowRef<number | undefined>
  ];
  key: any;
  memo: any[] | undefined;
}

Each item is managed as a ForBlock:

  • scope: Effect scope
  • state: Array of [item, key, index] as ShallowRef
  • key: The key property value
  • memo: Cache for v-memo

Diff Algorithm

createFor uses the same Diff algorithm as Vue 3's Virtual DOM:

  1. Sync from start: Process items with the same key from the beginning
  2. Sync from end: Process items with the same key from the end
  3. Common sequence + mount: Add new items
  4. Common sequence + unmount: Remove old items
  5. Unknown sequence: Minimal DOM movement using LIS (Longest Increasing Subsequence)
ts
// 5.3 move and mount
// generate longest stable subsequence only when nodes have moved
const increasingNewIndexSequence = moved
  ? getSequence(newIndexToOldIndexMap)
  : [];

mount/unmount

ts
function mount(source: any, idx: number, anchor: Node = parentAnchor): ForBlock {
  const scope = new BlockEffectScope(instance, parentScope);
  const [item, key, index] = getItem(source, idx);
  const state = [shallowRef(item), shallowRef(key), shallowRef(index)];
  const block: ForBlock = {
    nodes: null!,
    scope,
    state,
    key: getKey && getKey(item, key, index),
    // ...
  };
  block.nodes = scope.run(() => renderItem(proxyRefs(state)))!;
  // ...
  return block;
}

function unmount({ nodes, scope }: ForBlock) {
  invokeWithUnmount(scope, () => {
    removeBlock(nodes, parent!);
  });
}

State Proxying with proxyRefs

The state passed to renderItem is wrapped with proxyRefs.
This allows accessing item, key, and index without using .value in the template.

ts
block.nodes = scope.run(() => renderItem(proxyRefs(state)))!;

In the compilation result, they are accessed as _ctx0[0], _ctx0[1], _ctx0[2],
but proxyRefs automatically resolves .value.


v-for in Vapor Mode also adopts the same Diff algorithm as Virtual DOM,
enabling efficient updates using keys.
By combining BlockEffectScope and ShallowRef,
reactive updates and proper lifecycle management are achieved.