diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap index e3631f15890..141d3e410dc 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap @@ -47,6 +47,21 @@ export function render(_ctx) { }" `; +exports[`compiler: v-for > key only binding pattern 1`] = ` +"import { child as _child, toDisplayString as _toDisplayString, setText as _setText, createFor as _createFor, template as _template } from 'vue'; +const t0 = _template(" ", true) + +export function render(_ctx) { + const n0 = _createFor(() => (_ctx.rows), (_for_item0) => { + const n2 = t0() + const x2 = _child(n2) + _setText(x2, _toDisplayString(_for_item0.value.id + _for_item0.value.id)) + return n2 + }, (row) => (row.id)) + return n0 +}" +`; + exports[`compiler: v-for > multi effect 1`] = ` "import { setProp as _setProp, renderEffect as _renderEffect, createFor as _createFor, template as _template } from 'vue'; const t0 = _template("
", true) @@ -130,6 +145,75 @@ export function render(_ctx) { }" `; +exports[`compiler: v-for > selector pattern 1`] = ` +"import { child as _child, toDisplayString as _toDisplayString, setText as _setText, createFor as _createFor, template as _template } from 'vue'; +const t0 = _template(" ", true) + +export function render(_ctx) { + const n0 = _createFor(() => (_ctx.rows), (_for_item0) => { + const n2 = t0() + const x2 = _child(n2) + _selector0_0(() => { + _setText(x2, _toDisplayString(_ctx.selected === _for_item0.value.id ? 'danger' : '')) + }) + return n2 + }, (row) => (row.id)) + const _selector0_0 = n0.useSelector(() => _ctx.selected) + return n0 +}" +`; + +exports[`compiler: v-for > selector pattern 2`] = ` +"import { setClass as _setClass, createFor as _createFor, template as _template } from 'vue'; +const t0 = _template("", true) + +export function render(_ctx) { + const n0 = _createFor(() => (_ctx.rows), (_for_item0) => { + const n2 = t0() + _selector0_0(() => { + _setClass(n2, _ctx.selected === _for_item0.value.id ? 'danger' : '') + }) + return n2 + }, (row) => (row.id)) + const _selector0_0 = n0.useSelector(() => _ctx.selected) + return n0 +}" +`; + +exports[`compiler: v-for > selector pattern 3`] = ` +"import { setClass as _setClass, renderEffect as _renderEffect, createFor as _createFor, template as _template } from 'vue'; +const t0 = _template("", true) + +export function render(_ctx) { + const n0 = _createFor(() => (_ctx.rows), (_for_item0) => { + const n2 = t0() + _renderEffect(() => { + const _row = _for_item0.value + _setClass(n2, _row.label === _row.id ? 'danger' : '') + }) + return n2 + }, (row) => (row.id)) + return n0 +}" +`; + +exports[`compiler: v-for > selector pattern 4`] = ` +"import { setClass as _setClass, createFor as _createFor, template as _template } from 'vue'; +const t0 = _template("", true) + +export function render(_ctx) { + const n0 = _createFor(() => (_ctx.rows), (_for_item0) => { + const n2 = t0() + _selector0_0(() => { + _setClass(n2, { danger: _for_item0.value.id === _ctx.selected }) + }) + return n2 + }, (row) => (row.id)) + const _selector0_0 = n0.useSelector(() => _ctx.selected) + return n0 +}" +`; + exports[`compiler: v-for > v-for aliases w/ complex expressions 1`] = ` "import { getDefaultValue as _getDefaultValue, child as _child, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, createFor as _createFor, template as _template } from 'vue'; const t0 = _template("
", true) diff --git a/packages/compiler-vapor/__tests__/transforms/vFor.spec.ts b/packages/compiler-vapor/__tests__/transforms/vFor.spec.ts index d22981c1e30..7357ad84fef 100644 --- a/packages/compiler-vapor/__tests__/transforms/vFor.spec.ts +++ b/packages/compiler-vapor/__tests__/transforms/vFor.spec.ts @@ -67,6 +67,73 @@ describe('compiler: v-for', () => { ).lengthOf(1) }) + test('key only binding pattern', () => { + expect( + compileWithVFor( + ` + + {{ row.id + row.id }} + + `, + ).code, + ).matchSnapshot() + }) + + test('selector pattern', () => { + expect( + compileWithVFor( + ` + + {{ selected === row.id ? 'danger' : '' }} + + `, + ).code, + ).matchSnapshot() + + expect( + compileWithVFor( + ` + + `, + ).code, + ).matchSnapshot() + + // Should not be optimized because row.label is not from parent scope + expect( + compileWithVFor( + ` + + `, + ).code, + ).matchSnapshot() + + expect( + compileWithVFor( + ` + + `, + ).code, + ).matchSnapshot() + }) + test('multi effect', () => { const { code } = compileWithVFor( `
`, diff --git a/packages/compiler-vapor/src/generators/block.ts b/packages/compiler-vapor/src/generators/block.ts index ff240dd6eac..30347394756 100644 --- a/packages/compiler-vapor/src/generators/block.ts +++ b/packages/compiler-vapor/src/generators/block.ts @@ -19,14 +19,13 @@ export function genBlock( context: CodegenContext, args: CodeFragment[] = [], root?: boolean, - customReturns?: (returns: CodeFragment[]) => CodeFragment[], ): CodeFragment[] { return [ '(', ...args, ') => {', INDENT_START, - ...genBlockContent(oper, context, root, customReturns), + ...genBlockContent(oper, context, root), INDENT_END, NEWLINE, '}', @@ -37,7 +36,7 @@ export function genBlockContent( block: BlockIRNode, context: CodegenContext, root?: boolean, - customReturns?: (returns: CodeFragment[]) => CodeFragment[], + genEffectsExtraFrag?: () => CodeFragment[], ): CodeFragment[] { const [frag, push] = buildCodeFragment() const { dynamic, effect, operation, returns } = block @@ -70,7 +69,7 @@ export function genBlockContent( } push(...genOperations(operation, context)) - push(...genEffects(effect, context)) + push(...genEffects(effect, context, genEffectsExtraFrag)) push(NEWLINE, `return `) @@ -79,7 +78,7 @@ export function genBlockContent( returnNodes.length > 1 ? genMulti(DELIMITERS_ARRAY, ...returnNodes) : [returnNodes[0] || 'null'] - push(...(customReturns ? customReturns(returnsCode) : returnsCode)) + push(...returnsCode) resetBlock() return frag diff --git a/packages/compiler-vapor/src/generators/expression.ts b/packages/compiler-vapor/src/generators/expression.ts index a8fbc8f8300..aa7edf658f5 100644 --- a/packages/compiler-vapor/src/generators/expression.ts +++ b/packages/compiler-vapor/src/generators/expression.ts @@ -233,6 +233,7 @@ function canPrefix(name: string) { type DeclarationResult = { ids: Record frag: CodeFragment[] + varNames: string[] } type DeclarationValue = { name: string @@ -246,6 +247,7 @@ type DeclarationValue = { export function processExpressions( context: CodegenContext, expressions: SimpleExpressionNode[], + shouldDeclare: boolean, ): DeclarationResult { // analyze variables const { @@ -277,7 +279,11 @@ export function processExpressions( expToVariableMap, ) - return genDeclarations([...varDeclarations, ...expDeclarations], context) + return genDeclarations( + [...varDeclarations, ...expDeclarations], + context, + shouldDeclare, + ) } function analyzeExpressions(expressions: SimpleExpressionNode[]) { @@ -592,15 +598,21 @@ function processRepeatedExpressions( function genDeclarations( declarations: DeclarationValue[], context: CodegenContext, + shouldDeclare: boolean, ): DeclarationResult { const [frag, push] = buildCodeFragment() const ids: Record = Object.create(null) + const varNames = new Set() // process identifiers first as expressions may rely on them declarations.forEach(({ name, isIdentifier, value }) => { if (isIdentifier) { const varName = (ids[name] = `_${name}`) - push(`const ${varName} = `, ...genExpression(value, context), NEWLINE) + varNames.add(varName) + if (shouldDeclare) { + push(`const `) + } + push(`${varName} = `, ...genExpression(value, context), NEWLINE) } }) @@ -608,15 +620,19 @@ function genDeclarations( declarations.forEach(({ name, isIdentifier, value }) => { if (!isIdentifier) { const varName = (ids[name] = `_${name}`) + varNames.add(varName) + if (shouldDeclare) { + push(`const `) + } push( - `const ${varName} = `, + `${varName} = `, ...context.withId(() => genExpression(value, context), ids), NEWLINE, ) } }) - return { ids, frag } + return { ids, frag, varNames: [...varNames] } } function escapeRegExp(string: string) { diff --git a/packages/compiler-vapor/src/generators/for.ts b/packages/compiler-vapor/src/generators/for.ts index fbb72c61d47..40f002a8536 100644 --- a/packages/compiler-vapor/src/generators/for.ts +++ b/packages/compiler-vapor/src/generators/for.ts @@ -1,16 +1,32 @@ import { type SimpleExpressionNode, createSimpleExpression, + isStaticNode, walkIdentifiers, } from '@vue/compiler-dom' -import { genBlock } from './block' +import { genBlockContent } from './block' import { genExpression } from './expression' import type { CodegenContext } from '../generate' -import type { ForIRNode } from '../ir' -import { type CodeFragment, NEWLINE, genCall, genMulti } from './utils' -import type { Identifier } from '@babel/types' +import type { BlockIRNode, ForIRNode, IREffect } from '../ir' +import { + type CodeFragment, + INDENT_END, + INDENT_START, + NEWLINE, + genCall, + genMulti, +} from './utils' +import { + type Expression, + type Identifier, + type Node, + isNodesEquivalent, +} from '@babel/types' import { parseExpression } from '@babel/parser' import { VaporVForFlags } from '../../../shared/src/vaporFlags' +import { walk } from 'estree-walker' +import { genOperation } from './operation' +import { extend, isGloballyAllowed } from '@vue/shared' export function genFor( oper: ForIRNode, @@ -78,7 +94,62 @@ export function genFor( idMap[indexVar] = null } - const blockFn = context.withId(() => genBlock(render, context, args), idMap) + const { selectorPatterns, keyOnlyBindingPatterns } = matchPatterns( + render, + keyProp, + idMap, + ) + const patternFrag: CodeFragment[] = [] + + for (let i = 0; i < selectorPatterns.length; i++) { + const { selector } = selectorPatterns[i] + const selectorName = `_selector${id}_${i}` + patternFrag.push( + NEWLINE, + `const ${selectorName} = `, + ...genCall(`n${id}.useSelector`, [ + `() => `, + ...genExpression(selector, context), + ]), + ) + } + + const blockFn = context.withId(() => { + const frag: CodeFragment[] = [] + frag.push('(', ...args, ') => {', INDENT_START) + if (selectorPatterns.length || keyOnlyBindingPatterns.length) { + frag.push( + ...genBlockContent(render, context, false, () => { + const patternFrag: CodeFragment[] = [] + + for (let i = 0; i < selectorPatterns.length; i++) { + const { effect } = selectorPatterns[i] + patternFrag.push( + NEWLINE, + `_selector${id}_${i}(() => {`, + INDENT_START, + ) + for (const oper of effect.operations) { + patternFrag.push(...genOperation(oper, context)) + } + patternFrag.push(INDENT_END, NEWLINE, `})`) + } + + for (const { effect } of keyOnlyBindingPatterns) { + for (const oper of effect.operations) { + patternFrag.push(...genOperation(oper, context)) + } + } + + return patternFrag + }), + ) + } else { + frag.push(...genBlockContent(render, context)) + } + frag.push(INDENT_END, NEWLINE, '}') + return frag + }, idMap) exitScope() let flags = 0 @@ -103,6 +174,7 @@ export function genFor( flags ? String(flags) : undefined, // todo: hydrationNode ), + ...patternFrag, ] // construct a id -> accessor path map. @@ -234,3 +306,223 @@ export function genFor( return idMap } } + +function matchPatterns( + render: BlockIRNode, + keyProp: SimpleExpressionNode | undefined, + idMap: Record, +) { + const selectorPatterns: NonNullable< + ReturnType + >[] = [] + const keyOnlyBindingPatterns: NonNullable< + ReturnType + >[] = [] + + render.effect = render.effect.filter(effect => { + if (keyProp !== undefined) { + const selector = matchSelectorPattern(effect, keyProp.ast, idMap) + if (selector) { + selectorPatterns.push(selector) + return false + } + const keyOnly = matchKeyOnlyBindingPattern(effect, keyProp.ast) + if (keyOnly) { + keyOnlyBindingPatterns.push(keyOnly) + return false + } + } + + return true + }) + + return { + keyOnlyBindingPatterns, + selectorPatterns, + } +} + +function matchKeyOnlyBindingPattern( + effect: IREffect, + keyAst: any, +): + | { + effect: IREffect + } + | undefined { + // TODO: expressions can be multiple? + if (effect.expressions.length === 1) { + const ast = effect.expressions[0].ast + if (typeof ast === 'object' && ast !== null) { + if (isKeyOnlyBinding(ast, keyAst)) { + return { effect } + } + } + } +} + +function matchSelectorPattern( + effect: IREffect, + keyAst: any, + idMap: Record, +): + | { + effect: IREffect + selector: SimpleExpressionNode + } + | undefined { + // TODO: expressions can be multiple? + if (effect.expressions.length === 1) { + const ast = effect.expressions[0].ast + if (typeof ast === 'object' && ast) { + const matcheds: [key: Expression, selector: Expression][] = [] + + walk(ast, { + enter(node) { + if ( + typeof node === 'object' && + node && + node.type === 'BinaryExpression' && + node.operator === '===' && + node.left.type !== 'PrivateName' + ) { + const { left, right } = node + for (const [a, b] of [ + [left, right], + [right, left], + ]) { + const aIsKey = isKeyOnlyBinding(a, keyAst) + const bIsKey = isKeyOnlyBinding(b, keyAst) + const bVars = analyzeVariableScopes(b, idMap) + if (aIsKey && !bIsKey && !bVars.locals.length) { + matcheds.push([a, b]) + } + } + } + }, + }) + + if (matcheds.length === 1) { + const [key, selector] = matcheds[0] + const content = effect.expressions[0].content + + let hasExtraId = false + const parentStackMap = new Map() + const parentStack: Node[] = [] + walkIdentifiers( + ast, + id => { + if (id.start !== key.start && id.start !== selector.start) { + hasExtraId = true + } + parentStackMap.set(id, parentStack.slice()) + }, + false, + parentStack, + ) + + if (!hasExtraId) { + const name = content.slice(selector.start! - 1, selector.end! - 1) + return { + effect, + // @ts-expect-error + selector: { + content: name, + ast: extend({}, selector, { + start: 1, + end: name.length + 1, + }), + loc: selector.loc as any, + isStatic: false, + }, + } + } + } + } + + const content = effect.expressions[0].content + if ( + typeof ast === 'object' && + ast && + ast.type === 'ConditionalExpression' && + ast.test.type === 'BinaryExpression' && + ast.test.operator === '===' && + ast.test.left.type !== 'PrivateName' && + isStaticNode(ast.consequent) && + isStaticNode(ast.alternate) + ) { + const left = ast.test.left + const right = ast.test.right + for (const [a, b] of [ + [left, right], + [right, left], + ]) { + const aIsKey = isKeyOnlyBinding(a, keyAst) + const bIsKey = isKeyOnlyBinding(b, keyAst) + const bVars = analyzeVariableScopes(b, idMap) + if (aIsKey && !bIsKey && !bVars.locals.length) { + return { + effect, + // @ts-expect-error + selector: { + content: content.slice(b.start! - 1, b.end! - 1), + ast: b, + loc: b.loc as any, + isStatic: false, + }, + } + } + } + } + } +} + +function analyzeVariableScopes( + ast: Node, + idMap: Record, +) { + let globals: string[] = [] + let locals: string[] = [] + + const ids: Identifier[] = [] + const parentStackMap = new Map() + const parentStack: Node[] = [] + walkIdentifiers( + ast, + id => { + ids.push(id) + parentStackMap.set(id, parentStack.slice()) + }, + false, + parentStack, + ) + + for (const id of ids) { + if (isGloballyAllowed(id.name)) { + continue + } + if (idMap[id.name]) { + locals.push(id.name) + } else { + globals.push(id.name) + } + } + + return { globals, locals } +} + +function isKeyOnlyBinding(expr: Node, keyAst: any) { + let only = true + walk(expr, { + enter(node) { + if (isNodesEquivalent(node, keyAst)) { + this.skip() + return + } + if (node.type === 'Identifier') { + only = false + } + }, + }) + return only +} diff --git a/packages/compiler-vapor/src/generators/operation.ts b/packages/compiler-vapor/src/generators/operation.ts index 563d72f1ee1..13ce5477cc1 100644 --- a/packages/compiler-vapor/src/generators/operation.ts +++ b/packages/compiler-vapor/src/generators/operation.ts @@ -98,15 +98,18 @@ export function genOperation( export function genEffects( effects: IREffect[], context: CodegenContext, + genExtraFrag?: () => CodeFragment[], ): CodeFragment[] { const { helper } = context const expressions = effects.flatMap(effect => effect.expressions) const [frag, push, unshift] = buildCodeFragment() + const shouldDeclare = genExtraFrag === undefined let operationsCount = 0 - const { ids, frag: declarationFrags } = processExpressions( - context, - expressions, - ) + const { + ids, + frag: declarationFrags, + varNames, + } = processExpressions(context, expressions, shouldDeclare) push(...declarationFrags) for (let i = 0; i < effects.length; i++) { const effect = effects[i] @@ -123,6 +126,9 @@ export function genEffects( if (newLineCount > 1 || operationsCount > 1 || declarationFrags.length > 0) { unshift(`{`, INDENT_START, NEWLINE) push(INDENT_END, NEWLINE, '}') + if (!effects.length) { + unshift(NEWLINE) + } } if (effects.length) { @@ -130,6 +136,14 @@ export function genEffects( push(`)`) } + if (!shouldDeclare && varNames.length) { + unshift(NEWLINE, `let `, varNames.join(', ')) + } + + if (genExtraFrag) { + push(...context.withId(genExtraFrag, ids)) + } + return frag } diff --git a/packages/runtime-vapor/__tests__/for.spec.ts b/packages/runtime-vapor/__tests__/for.spec.ts index db91b6a62da..dc247b6d4ca 100644 --- a/packages/runtime-vapor/__tests__/for.spec.ts +++ b/packages/runtime-vapor/__tests__/for.spec.ts @@ -101,7 +101,7 @@ describe('createFor', () => { }) return span }, - item => item.name, + item => item, ) return n1 }).render() diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index 62529149ad4..b1aef20e094 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -10,6 +10,7 @@ import { shallowRef, toReactive, toReadonly, + watch, } from '@vue/reactivity' import { getSequence, isArray, isObject, isString } from '@vue/shared' import { createComment, createTextNode } from './dom/node' @@ -87,12 +88,18 @@ export const createFor = ( let oldBlocks: ForBlock[] = [] let newBlocks: ForBlock[] let parent: ParentNode | undefined | null + // useSelector only + let currentKey: any // TODO handle this in hydration const parentAnchor = __DEV__ ? createComment('for') : createTextNode() const frag = new VaporFragment(oldBlocks) const instance = currentInstance! - const canUseFastRemove = flags & VaporVForFlags.FAST_REMOVE - const isComponent = flags & VaporVForFlags.IS_COMPONENT + const canUseFastRemove = !!(flags & VaporVForFlags.FAST_REMOVE) + const isComponent = !!(flags & VaporVForFlags.IS_COMPONENT) + const selectors: { + deregister: (key: any) => void + cleanup: () => void + }[] = [] if (__DEV__ && !instance) { warn('createFor() can only be used inside setup()') @@ -120,9 +127,12 @@ export const createFor = ( } } else if (!newLength) { // fast path for clearing all + for (const selector of selectors) { + selector.cleanup() + } const doRemove = !canUseFastRemove for (let i = 0; i < oldLength; i++) { - unmount(oldBlocks[i], doRemove) + unmount(oldBlocks[i], doRemove, false) } if (canUseFastRemove) { parent!.textContent = '' @@ -362,9 +372,18 @@ export const createFor = ( } } - const unmount = ({ nodes, scope }: ForBlock, doRemove = true) => { - scope && scope.stop() - doRemove && removeBlock(nodes, parent!) + const unmount = (block: ForBlock, doRemove = true, doDeregister = true) => { + if (!isComponent) { + block.scope!.stop() + } + if (doRemove) { + removeBlock(block.nodes, parent!) + } + if (doDeregister) { + for (const selector of selectors) { + selector.deregister(block.key) + } + } } if (flags & VaporVForFlags.ONCE) { @@ -377,7 +396,59 @@ export const createFor = ( insert(frag, _insertionParent, _insertionAnchor) } + // @ts-expect-error + frag.useSelector = useSelector + return frag + + function useSelector(source: () => any): (key: any, cb: () => void) => void { + let operMap = new Map void)[]>() + let activeKey = source() + let activeOpers: (() => void)[] | undefined + + watch(source, newValue => { + if (activeOpers !== undefined) { + for (const oper of activeOpers) { + oper() + } + } + activeOpers = operMap.get(newValue) + if (activeOpers !== undefined) { + for (const oper of activeOpers) { + oper() + } + } + }) + + selectors.push({ deregister, cleanup }) + return register + + function cleanup() { + operMap = new Map() + activeOpers = undefined + } + + function register(oper: () => void) { + oper() + let opers = operMap.get(currentKey) + if (opers !== undefined) { + opers.push(oper) + } else { + opers = [oper] + operMap.set(currentKey, opers) + if (currentKey === activeKey) { + activeOpers = opers + } + } + } + + function deregister(key: any) { + operMap.delete(key) + if (key === activeKey) { + activeOpers = undefined + } + } + } } export function createForSlots( diff --git a/rollup.config.js b/rollup.config.js index 7f2ecb8c864..1fa345f87fc 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -314,6 +314,7 @@ function createConfig(format, output, plugins = []) { const treeShakenDeps = [ 'source-map-js', '@babel/parser', + '@babel/types', 'estree-walker', 'entities/lib/decode.js', ]