v-for ディレクティブ
以下のようなコンポーネントを考えます.
<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>コンパイル結果と概要
コンパイル結果は以下のようになります.
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 は以下の引数を取ります:
- source: ループ対象のソース (
() => _ctx.items) - renderItem: 各アイテムをレンダリングする関数
- getKey: key を取得する関数 (オプション)
_ctx0 は [item, key, index] の配列で,_ctx0[0] が item,_ctx0[1] が key,_ctx0[2] が index です.
様々なパターン
ネストした v-for
<template>
<div v-for="item in list">
<span v-for="child in item">{{ child + item }}</span>
</div>
</template>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 のコンテキストとして参照されます.
分割代入
<template>
<div v-for="{ id, ...other } in list" :key="id">
{{ id + other }}
</div>
</template>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}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::keyprop の式render: 各アイテムのレンダリングブロックonce:v-onceと組み合わせた場合
Transformer
transformVFor は 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}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 のパーサーによって解析された結果で,source,value,key,index を含みます.
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}主要な処理:
- ソース式の生成:
() => (_ctx.items)の形式 - レンダリング関数の生成:
_ctx0を引数に取る関数 - key 関数の生成:
:keyprop がある場合 - 分割代入の処理:
withDestructureを使用
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"
),
];ランタイムを読む
createFor は runtime-vapor の apiCreateFor.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}概要
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 構造
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 アルゴリズムを使用しています:
- 先頭からの同期: 先頭から同じ key のアイテムを処理
- 末尾からの同期: 末尾から同じ key のアイテムを処理
- 共通シーケンス + マウント: 新しいアイテムの追加
- 共通シーケンス + アンマウント: 古いアイテムの削除
- 未知のシーケンス: LIS (Longest Increasing Subsequence) を使った最小限の DOM 移動
// 5.3 move and mount
// generate longest stable subsequence only when nodes have moved
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: [];mount/unmount
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 に渡される state は proxyRefs でラップされています.
これにより,テンプレート内で .value を使わずに item,key,index にアクセスできます.
block.nodes = scope.run(() => renderItem(proxyRefs(state)))!;コンパイル結果では _ctx0[0],_ctx0[1],_ctx0[2] としてアクセスしていますが,proxyRefs により自動的に .value が解決されます.
v-for は Vapor Mode でも Virtual DOM と同様の Diff アルゴリズムを採用しており,
key を使った効率的な更新が可能です.BlockEffectScope と ShallowRef を組み合わせることで,
リアクティブな更新と適切なライフサイクル管理を実現しています.
