perf: improve scoped slots change detection accuracy (#9371)

Ensure that state mutations that only affect parent scope only trigger parent update and does not affect child components with only scoped slots.
This commit is contained in:
Evan You 2019-01-25 22:34:06 -05:00 committed by GitHub
parent 770c6ed64f
commit f219bedae8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 235 additions and 169 deletions

View File

@ -119,6 +119,7 @@ declare type ASTElement = {
transitionMode?: string | null; transitionMode?: string | null;
slotName?: ?string; slotName?: ?string;
slotTarget?: ?string; slotTarget?: ?string;
slotTargetDynamic?: boolean;
slotScope?: ?string; slotScope?: ?string;
scopedSlots?: { [name: string]: ASTElement }; scopedSlots?: { [name: string]: ASTElement };

View File

@ -354,11 +354,12 @@ function genScopedSlots (
slots: { [key: string]: ASTElement }, slots: { [key: string]: ASTElement },
state: CodegenState state: CodegenState
): string { ): string {
const hasDynamicKeys = Object.keys(slots).some(key => slots[key].slotTargetDynamic)
return `scopedSlots:_u([${ return `scopedSlots:_u([${
Object.keys(slots).map(key => { Object.keys(slots).map(key => {
return genScopedSlot(key, slots[key], state) return genScopedSlot(key, slots[key], state)
}).join(',') }).join(',')
}])` }]${hasDynamicKeys ? `,true` : ``})`
} }
function genScopedSlot ( function genScopedSlot (

View File

@ -586,6 +586,7 @@ function processSlotContent (el) {
const slotTarget = getBindingAttr(el, 'slot') const slotTarget = getBindingAttr(el, 'slot')
if (slotTarget) { if (slotTarget) {
el.slotTarget = slotTarget === '""' ? '"default"' : slotTarget el.slotTarget = slotTarget === '""' ? '"default"' : slotTarget
el.slotTargetDynamic = !!(el.attrsMap[':slot'] || el.attrsMap['v-bind:slot'])
// preserve slot as an attribute for native shadow DOM compat // preserve slot as an attribute for native shadow DOM compat
// only for non-scoped slots. // only for non-scoped slots.
if (el.tag !== 'template' && !el.slotScope) { if (el.tag !== 'template' && !el.slotScope) {
@ -607,8 +608,10 @@ function processSlotContent (el) {
) )
} }
} }
el.slotTarget = getSlotName(slotBinding) const { name, dynamic } = getSlotName(slotBinding)
el.slotScope = slotBinding.value el.slotTarget = name
el.slotTargetDynamic = dynamic
el.slotScope = slotBinding.value || `_` // force it into a scoped slot for perf
} }
} else { } else {
// v-slot on component, denotes default slot // v-slot on component, denotes default slot
@ -637,10 +640,11 @@ function processSlotContent (el) {
} }
// add the component's children to its default slot // add the component's children to its default slot
const slots = el.scopedSlots || (el.scopedSlots = {}) const slots = el.scopedSlots || (el.scopedSlots = {})
const target = getSlotName(slotBinding) const { name, dynamic } = getSlotName(slotBinding)
const slotContainer = slots[target] = createASTElement('template', [], el) const slotContainer = slots[name] = createASTElement('template', [], el)
slotContainer.slotTargetDynamic = dynamic
slotContainer.children = el.children slotContainer.children = el.children
slotContainer.slotScope = slotBinding.value slotContainer.slotScope = slotBinding.value || `_`
// remove children as they are returned from scopedSlots now // remove children as they are returned from scopedSlots now
el.children = [] el.children = []
// mark el non-plain so data gets generated // mark el non-plain so data gets generated
@ -664,9 +668,9 @@ function getSlotName (binding) {
} }
return dynamicKeyRE.test(name) return dynamicKeyRE.test(name)
// dynamic [name] // dynamic [name]
? name.slice(1, -1) ? { name: name.slice(1, -1), dynamic: true }
// static name // static name
: `"${name}"` : { name: `"${name}"`, dynamic: false }
} }
// handle <slot/> outlets // handle <slot/> outlets

View File

@ -224,12 +224,22 @@ export function updateChildComponent (
} }
// determine whether component has slot children // determine whether component has slot children
// we need to do this before overwriting $options._renderChildren // we need to do this before overwriting $options._renderChildren.
const hasChildren = !!(
// check if there are dynamic scopedSlots (hand-written or compiled but with
// dynamic slot names). Static scoped slots compiled from template has the
// "$stable" marker.
const hasDynamicScopedSlot = !!(
(parentVnode.data.scopedSlots && !parentVnode.data.scopedSlots.$stable) ||
(vm.$scopedSlots !== emptyObject && !vm.$scopedSlots.$stable)
)
// Any static slot children from the parent may have changed during parent's
// update. Dynamic scoped slots may also have changed. In such cases, a forced
// update is necessary to ensure correctness.
const needsForceUpdate = !!(
renderChildren || // has new static slots renderChildren || // has new static slots
vm.$options._renderChildren || // has old static slots vm.$options._renderChildren || // has old static slots
parentVnode.data.scopedSlots || // has new scoped slots hasDynamicScopedSlot
vm.$scopedSlots !== emptyObject // has old scoped slots
) )
vm.$options._parentVnode = parentVnode vm.$options._parentVnode = parentVnode
@ -268,7 +278,7 @@ export function updateChildComponent (
updateComponentListeners(vm, listeners, oldListeners) updateComponentListeners(vm, listeners, oldListeners)
// resolve slots + force update if has children // resolve slots + force update if has children
if (hasChildren) { if (needsForceUpdate) {
vm.$slots = resolveSlots(renderChildren, parentVnode.context) vm.$slots = resolveSlots(renderChildren, parentVnode.context)
vm.$forceUpdate() vm.$forceUpdate()
} }

View File

@ -51,13 +51,14 @@ function isWhitespace (node: VNode): boolean {
export function resolveScopedSlots ( export function resolveScopedSlots (
fns: ScopedSlotsData, // see flow/vnode fns: ScopedSlotsData, // see flow/vnode
hasDynamicKeys?: boolean,
res?: Object res?: Object
): { [key: string]: Function } { ): { [key: string]: Function, $stable: boolean } {
res = res || {} res = res || { $stable: !hasDynamicKeys }
for (let i = 0; i < fns.length; i++) { for (let i = 0; i < fns.length; i++) {
const slot = fns[i] const slot = fns[i]
if (Array.isArray(slot)) { if (Array.isArray(slot)) {
resolveScopedSlots(slot, res) resolveScopedSlots(slot, hasDynamicKeys, res)
} else { } else {
res[slot.key] = slot.fn res[slot.key] = slot.fn
} }

View File

@ -14,7 +14,7 @@ export function normalizeScopedSlots (
} else { } else {
res = {} res = {}
for (const key in slots) { for (const key in slots) {
if (slots[key]) { if (slots[key] && key[0] !== '$') {
res[key] = normalizeScopedSlot(slots[key]) res[key] = normalizeScopedSlot(slots[key])
} }
} }
@ -26,6 +26,7 @@ export function normalizeScopedSlots (
} }
} }
res._normalized = true res._normalized = true
res.$stable = slots && slots.$stable
return res return res
} }

View File

@ -633,131 +633,69 @@ describe('Component scoped slot', () => {
}) })
// 2.6 new slot syntax // 2.6 new slot syntax
if (process.env.NEW_SLOT_SYNTAX) { describe('v-slot syntax', () => {
describe('v-slot syntax', () => { const Foo = {
const Foo = { render(h) {
render(h) { return h('div', [
return h('div', [ this.$scopedSlots.default && this.$scopedSlots.default('from foo default'),
this.$scopedSlots.default && this.$scopedSlots.default('from foo default'), this.$scopedSlots.one && this.$scopedSlots.one('from foo one'),
this.$scopedSlots.one && this.$scopedSlots.one('from foo one'), this.$scopedSlots.two && this.$scopedSlots.two('from foo two')
this.$scopedSlots.two && this.$scopedSlots.two('from foo two') ])
])
}
} }
}
const Bar = { const Bar = {
render(h) { render(h) {
return this.$scopedSlots.default && this.$scopedSlots.default('from bar') return this.$scopedSlots.default && this.$scopedSlots.default('from bar')
}
} }
}
const Baz = { const Baz = {
render(h) { render(h) {
return this.$scopedSlots.default && this.$scopedSlots.default('from baz') return this.$scopedSlots.default && this.$scopedSlots.default('from baz')
}
} }
}
const toNamed = (syntax, name) => syntax[0] === '#' const toNamed = (syntax, name) => syntax[0] === '#'
? `#${name}` // shorthand ? `#${name}` // shorthand
: `${syntax}:${name}` // full syntax : `${syntax}:${name}` // full syntax
function runSuite(syntax) { function runSuite(syntax) {
it('default slot', () => { it('default slot', () => {
const vm = new Vue({ const vm = new Vue({
template: `<foo ${syntax}="foo">{{ foo }}<div>{{ foo }}</div></foo>`, template: `<foo ${syntax}="foo">{{ foo }}<div>{{ foo }}</div></foo>`,
components: { Foo } components: { Foo }
}).$mount() }).$mount()
expect(vm.$el.innerHTML).toBe(`from foo default<div>from foo default</div>`) expect(vm.$el.innerHTML).toBe(`from foo default<div>from foo default</div>`)
}) })
it('nested default slots', () => { it('nested default slots', () => {
const vm = new Vue({ const vm = new Vue({
template: ` template: `
<foo ${syntax}="foo"> <foo ${syntax}="foo">
<bar ${syntax}="bar"> <bar ${syntax}="bar">
<baz ${syntax}="baz"> <baz ${syntax}="baz">
{{ foo }} | {{ bar }} | {{ baz }} {{ foo }} | {{ bar }} | {{ baz }}
</baz> </baz>
</bar> </bar>
</foo> </foo>
`, `,
components: { Foo, Bar, Baz } components: { Foo, Bar, Baz }
}).$mount() }).$mount()
expect(vm.$el.innerHTML.trim()).toBe(`from foo default | from bar | from baz`) expect(vm.$el.innerHTML.trim()).toBe(`from foo default | from bar | from baz`)
}) })
it('named slots', () => { it('named slots', () => {
const vm = new Vue({
template: `
<foo>
<template ${toNamed(syntax, 'default')}="foo">
{{ foo }}
</template>
<template ${toNamed(syntax, 'one')}="one">
{{ one }}
</template>
<template ${toNamed(syntax, 'two')}="two">
{{ two }}
</template>
</foo>
`,
components: { Foo }
}).$mount()
expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo default from foo one from foo two`)
})
it('nested + named + default slots', () => {
const vm = new Vue({
template: `
<foo>
<template ${toNamed(syntax, 'one')}="one">
<bar ${syntax}="bar">
{{ one }} {{ bar }}
</bar>
</template>
<template ${toNamed(syntax, 'two')}="two">
<baz ${syntax}="baz">
{{ two }} {{ baz }}
</baz>
</template>
</foo>
`,
components: { Foo, Bar, Baz }
}).$mount()
expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo one from bar from foo two from baz`)
})
it('should warn v-slot usage on non-component elements', () => {
const vm = new Vue({
template: `<div ${syntax}="foo"/>`
}).$mount()
expect(`v-slot can only be used on components or <template>`).toHaveBeenWarned()
})
it('should warn mixed usage', () => {
const vm = new Vue({
template: `<foo><bar slot="one" slot-scope="bar" ${syntax}="bar"></bar></foo>`,
components: { Foo, Bar }
}).$mount()
expect(`Unexpected mixed usage of different slot syntaxes`).toHaveBeenWarned()
})
}
// run tests for both full syntax and shorthand
runSuite('v-slot')
runSuite('#default')
it('shorthand named slots', () => {
const vm = new Vue({ const vm = new Vue({
template: ` template: `
<foo> <foo>
<template #default="foo"> <template ${toNamed(syntax, 'default')}="foo">
{{ foo }} {{ foo }}
</template> </template>
<template #one="one"> <template ${toNamed(syntax, 'one')}="one">
{{ one }} {{ one }}
</template> </template>
<template #two="two"> <template ${toNamed(syntax, 'two')}="two">
{{ two }} {{ two }}
</template> </template>
</foo> </foo>
@ -767,62 +705,165 @@ describe('Component scoped slot', () => {
expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo default from foo one from foo two`) expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo default from foo one from foo two`)
}) })
it('should warn mixed root-default and named slots', () => { it('nested + named + default slots', () => {
const vm = new Vue({ const vm = new Vue({
template: ` template: `
<foo #default="foo"> <foo>
{{ foo }} <template ${toNamed(syntax, 'one')}="one">
<template #one="one"> <bar ${syntax}="bar">
{{ one }} {{ one }} {{ bar }}
</bar>
</template>
<template ${toNamed(syntax, 'two')}="two">
<baz ${syntax}="baz">
{{ two }} {{ baz }}
</baz>
</template> </template>
</foo> </foo>
`, `,
components: { Foo } components: { Foo, Bar, Baz }
}).$mount() }).$mount()
expect(`default slot should also use <template>`).toHaveBeenWarned() expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo one from bar from foo two from baz`)
}) })
it('shorthand without scope variable', () => { it('should warn v-slot usage on non-component elements', () => {
const vm = new Vue({ const vm = new Vue({
template: ` template: `<div ${syntax}="foo"/>`
<foo>
<template #one>one</template>
<template #two>two</template>
</foo>
`,
components: { Foo }
}).$mount() }).$mount()
expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`onetwo`) expect(`v-slot can only be used on components or <template>`).toHaveBeenWarned()
}) })
it('shorthand named slots on root', () => { it('should warn mixed usage', () => {
const vm = new Vue({ const vm = new Vue({
template: ` template: `<foo><bar slot="one" slot-scope="bar" ${syntax}="bar"></bar></foo>`,
<foo #one="one"> components: { Foo, Bar }
}).$mount()
expect(`Unexpected mixed usage of different slot syntaxes`).toHaveBeenWarned()
})
}
// run tests for both full syntax and shorthand
runSuite('v-slot')
runSuite('#default')
it('shorthand named slots', () => {
const vm = new Vue({
template: `
<foo>
<template #default="foo">
{{ foo }}
</template>
<template #one="one">
{{ one }} {{ one }}
</foo> </template>
`, <template #two="two">
components: { Foo } {{ two }}
}).$mount() </template>
expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo one`) </foo>
}) `,
components: { Foo }
it('dynamic slot name', () => { }).$mount()
const vm = new Vue({ expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo default from foo one from foo two`)
data: {
a: 'one',
b: 'two'
},
template: `
<foo>
<template #[a]="one">{{ one }} </template>
<template v-slot:[b]="two">{{ two }}</template>
</foo>
`,
components: { Foo }
}).$mount()
expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo one from foo two`)
})
}) })
}
it('should warn mixed root-default and named slots', () => {
const vm = new Vue({
template: `
<foo #default="foo">
{{ foo }}
<template #one="one">
{{ one }}
</template>
</foo>
`,
components: { Foo }
}).$mount()
expect(`default slot should also use <template>`).toHaveBeenWarned()
})
it('shorthand without scope variable', () => {
const vm = new Vue({
template: `
<foo>
<template #one>one</template>
<template #two>two</template>
</foo>
`,
components: { Foo }
}).$mount()
expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`onetwo`)
})
it('shorthand named slots on root', () => {
const vm = new Vue({
template: `
<foo #one="one">
{{ one }}
</foo>
`,
components: { Foo }
}).$mount()
expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo one`)
})
it('dynamic slot name', done => {
const vm = new Vue({
data: {
a: 'one',
b: 'two'
},
template: `
<foo>
<template #[a]="one">a {{ one }} </template>
<template v-slot:[b]="two">b {{ two }} </template>
</foo>
`,
components: { Foo }
}).$mount()
expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`a from foo one b from foo two`)
vm.a = 'two'
vm.b = 'one'
waitForUpdate(() => {
expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`b from foo one a from foo two `)
}).then(done)
})
})
// 2.6 scoped slot perf optimization
it('should have accurate tracking for scoped slots', done => {
const parentUpdate = jasmine.createSpy()
const childUpdate = jasmine.createSpy()
const vm = new Vue({
template: `
<div>{{ parentCount }}<foo #default>{{ childCount }}</foo></div>
`,
data: {
parentCount: 0,
childCount: 0
},
updated: parentUpdate,
components: {
foo: {
template: `<div><slot/></div>`,
updated: childUpdate
}
}
}).$mount()
expect(vm.$el.innerHTML).toMatch(`0<div>0</div>`)
vm.parentCount++
waitForUpdate(() => {
expect(vm.$el.innerHTML).toMatch(`1<div>0</div>`)
// should only trigger parent update
expect(parentUpdate.calls.count()).toBe(1)
expect(childUpdate.calls.count()).toBe(0)
vm.childCount++
}).then(() => {
expect(vm.$el.innerHTML).toMatch(`1<div>1</div>`)
// should only trigger child update
expect(parentUpdate.calls.count()).toBe(1)
expect(childUpdate.calls.count()).toBe(1)
}).then(done)
})
}) })

View File

@ -229,6 +229,13 @@ describe('codegen', () => {
) )
}) })
it('generate dynamic scoped slot', () => {
assertCodegen(
'<foo><template :slot="foo" slot-scope="bar">{{ bar }}</template></foo>',
`with(this){return _c('foo',{scopedSlots:_u([{key:foo,fn:function(bar){return [_v(_s(bar))]}}],true)})}`
)
})
it('generate scoped slot with multiline v-if', () => { it('generate scoped slot with multiline v-if', () => {
assertCodegen( assertCodegen(
'<foo><template v-if="\nshow\n" slot-scope="bar">{{ bar }}</template></foo>', '<foo><template v-if="\nshow\n" slot-scope="bar">{{ bar }}</template></foo>',