Skip to content

v-for ディレクティブ

以下のようなコンポーネントを考えます.

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>

コンパイル結果と概要

コンパイル結果は以下のようになります.

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 は以下の引数を取ります:

  1. source: ループ対象のソース (() => _ctx.items)
  2. renderItem: 各アイテムをレンダリングする関数
  3. getKey: key を取得する関数 (オプション)

_ctx0[item, key, index] の配列で,_ctx0[0]item_ctx0[1]key_ctx0[2]index です.

様々なパターン

ネストした 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;
  }
);

ネストした場合は _ctx0 が外側の v-for のコンテキスト,_ctx1 が内側の v-for のコンテキストとして参照されます.

分割代入

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
);

分割代入の場合は withDestructure ヘルパーを使って,分割代入された値を配列に変換しています.

コンパイラを読む

IR

まず,v-for 用の IR を確認しましょう.

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: ループ対象のソース式
  • value: ループ変数 (item)
  • key: キー変数 (オブジェクトのループ時)
  • index: インデックス変数
  • keyProp: :key prop の式
  • render: 各アイテムのレンダリングブロック
  • once: v-once と組み合わせた場合

Transformer

transformVForcreateStructuralDirectiveTransform を使って定義されています.

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@vue/compiler-dom のパーサーによって解析された結果で,sourcevaluekeyindex を含みます.

Codegen

genFor 関数でコード生成を行います.

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}

主要な処理:

  1. ソース式の生成: () => (_ctx.items) の形式
  2. レンダリング関数の生成: _ctx0 を引数に取る関数
  3. key 関数の生成: :key prop がある場合
  4. 分割代入の処理: 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"
  ),
];

ランタイムを読む

createForruntime-vaporapiCreateFor.ts に実装されています.

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}

概要

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 構造

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

各アイテムは ForBlock として管理され:

  • scope: エフェクトスコープ
  • state: [item, key, index]ShallowRef 配列
  • key: key プロパティの値
  • memo: v-memo 用のキャッシュ

Diff アルゴリズム

createFor は Vue 3 の Virtual DOM と同様の Diff アルゴリズムを使用しています:

  1. 先頭からの同期: 先頭から同じ key のアイテムを処理
  2. 末尾からの同期: 末尾から同じ key のアイテムを処理
  3. 共通シーケンス + マウント: 新しいアイテムの追加
  4. 共通シーケンス + アンマウント: 古いアイテムの削除
  5. 未知のシーケンス: LIS (Longest Increasing Subsequence) を使った最小限の DOM 移動
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!);
  });
}

proxyRefs による state のプロキシ

renderItem に渡される stateproxyRefs でラップされています.
これにより,テンプレート内で .value を使わずに itemkeyindex にアクセスできます.

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

コンパイル結果では _ctx0[0]_ctx0[1]_ctx0[2] としてアクセスしていますが,
proxyRefs により自動的に .value が解決されます.


v-for は Vapor Mode でも Virtual DOM と同様の Diff アルゴリズムを採用しており,
key を使った効率的な更新が可能です.
BlockEffectScopeShallowRef を組み合わせることで,
リアクティブな更新と適切なライフサイクル管理を実現しています.