wip(compiler-ssr): dynamic v-bind + class/style merging

This commit is contained in:
Evan You 2020-02-04 18:37:23 -05:00
parent c059fc88b9
commit ebf920e6af
8 changed files with 234 additions and 58 deletions

View File

@ -29,7 +29,6 @@ export interface ParserOptions {
export interface TransformOptions { export interface TransformOptions {
nodeTransforms?: NodeTransform[] nodeTransforms?: NodeTransform[]
directiveTransforms?: Record<string, DirectiveTransform | undefined> directiveTransforms?: Record<string, DirectiveTransform | undefined>
ssrDirectiveTransforms?: Record<string, DirectiveTransform | undefined>
isBuiltInComponent?: (tag: string) => symbol | void isBuiltInComponent?: (tag: string) => symbol | void
// Transform expressions like {{ foo }} to `_ctx.foo`. // Transform expressions like {{ foo }} to `_ctx.foo`.
// If this option is false, the generated code will be wrapped in a // If this option is false, the generated code will be wrapped in a

View File

@ -114,7 +114,6 @@ function createTransformContext(
cacheHandlers = false, cacheHandlers = false,
nodeTransforms = [], nodeTransforms = [],
directiveTransforms = {}, directiveTransforms = {},
ssrDirectiveTransforms = {},
isBuiltInComponent = NOOP, isBuiltInComponent = NOOP,
ssr = false, ssr = false,
onError = defaultOnError onError = defaultOnError
@ -127,7 +126,6 @@ function createTransformContext(
cacheHandlers, cacheHandlers,
nodeTransforms, nodeTransforms,
directiveTransforms, directiveTransforms,
ssrDirectiveTransforms,
isBuiltInComponent, isBuiltInComponent,
ssr, ssr,
onError, onError,

View File

@ -233,7 +233,8 @@ export type PropsExpression = ObjectExpression | CallExpression | ExpressionNode
export function buildProps( export function buildProps(
node: ElementNode, node: ElementNode,
context: TransformContext, context: TransformContext,
props: ElementNode['props'] = node.props props: ElementNode['props'] = node.props,
ssr = false
): { ): {
props: PropsExpression | undefined props: PropsExpression | undefined
directives: DirectiveNode[] directives: DirectiveNode[]
@ -320,9 +321,15 @@ export function buildProps(
continue continue
} }
// special case for v-bind and v-on with no argument
const isBind = name === 'bind' const isBind = name === 'bind'
const isOn = name === 'on' const isOn = name === 'on'
// skip v-on in SSR compilation
if (ssr && isOn) {
continue
}
// special case for v-bind and v-on with no argument
if (!arg && (isBind || isOn)) { if (!arg && (isBind || isOn)) {
hasDynamicKeys = true hasDynamicKeys = true
if (exp) { if (exp) {
@ -360,7 +367,7 @@ export function buildProps(
if (directiveTransform) { if (directiveTransform) {
// has built-in directive transform. // has built-in directive transform.
const { props, needRuntime } = directiveTransform(prop, node, context) const { props, needRuntime } = directiveTransform(prop, node, context)
props.forEach(analyzePatchFlag) !ssr && props.forEach(analyzePatchFlag)
properties.push(...props) properties.push(...props)
if (needRuntime) { if (needRuntime) {
runtimeDirectives.push(prop) runtimeDirectives.push(prop)
@ -446,12 +453,7 @@ function dedupeProperties(properties: Property[]): Property[] {
const name = prop.key.content const name = prop.key.content
const existing = knownProps.get(name) const existing = knownProps.get(name)
if (existing) { if (existing) {
if ( if (name === 'style' || name === 'class' || name.startsWith('on')) {
name === 'style' ||
name === 'class' ||
name.startsWith('on') ||
name.startsWith('vnode')
) {
mergeAsArray(existing, prop) mergeAsArray(existing, prop)
} }
// unexpected duplicate, should have emitted error during parse // unexpected duplicate, should have emitted error during parse

View File

@ -56,5 +56,6 @@ export function parse(template: string, options: ParserOptions = {}): RootNode {
}) })
} }
export { transformStyle } from './transforms/transformStyle'
export { DOMErrorCodes } from './errors' export { DOMErrorCodes } from './errors'
export * from '@vue/compiler-core' export * from '@vue/compiler-core'

View File

@ -1,7 +1,9 @@
import { import {
NodeTransform, NodeTransform,
NodeTypes, NodeTypes,
createSimpleExpression createSimpleExpression,
SimpleExpressionNode,
SourceLocation
} from '@vue/compiler-core' } from '@vue/compiler-core'
// Parse inline CSS strings for static style attributes into an object. // Parse inline CSS strings for static style attributes into an object.
@ -15,8 +17,7 @@ export const transformStyle: NodeTransform = (node, context) => {
node.props.forEach((p, i) => { node.props.forEach((p, i) => {
if (p.type === NodeTypes.ATTRIBUTE && p.name === 'style' && p.value) { if (p.type === NodeTypes.ATTRIBUTE && p.name === 'style' && p.value) {
// replace p with an expression node // replace p with an expression node
const parsed = JSON.stringify(parseInlineCSS(p.value.content)) const exp = context.hoist(parseInlineCSS(p.value.content, p.loc))
const exp = context.hoist(createSimpleExpression(parsed, false, p.loc))
node.props[i] = { node.props[i] = {
type: NodeTypes.DIRECTIVE, type: NodeTypes.DIRECTIVE,
name: `bind`, name: `bind`,
@ -33,7 +34,10 @@ export const transformStyle: NodeTransform = (node, context) => {
const listDelimiterRE = /;(?![^(]*\))/g const listDelimiterRE = /;(?![^(]*\))/g
const propertyDelimiterRE = /:(.+)/ const propertyDelimiterRE = /:(.+)/
function parseInlineCSS(cssText: string): Record<string, string> { function parseInlineCSS(
cssText: string,
loc: SourceLocation
): SimpleExpressionNode {
const res: Record<string, string> = {} const res: Record<string, string> = {}
cssText.split(listDelimiterRE).forEach(item => { cssText.split(listDelimiterRE).forEach(item => {
if (item) { if (item) {
@ -41,5 +45,5 @@ function parseInlineCSS(cssText: string): Record<string, string> {
tmp.length > 1 && (res[tmp[0].trim()] = tmp[1].trim()) tmp.length > 1 && (res[tmp[0].trim()] = tmp[1].trim())
} }
}) })
return res return createSimpleExpression(JSON.stringify(res), false, loc)
} }

View File

@ -46,6 +46,10 @@ describe('ssr: element', () => {
getCompiledString(`<textarea value="fo&gt;o"/>`) getCompiledString(`<textarea value="fo&gt;o"/>`)
).toMatchInlineSnapshot(`"\`<textarea>fo&gt;o</textarea>\`"`) ).toMatchInlineSnapshot(`"\`<textarea>fo&gt;o</textarea>\`"`)
}) })
test('<textarea> with dynamic v-bind', () => {
// TODO
})
}) })
describe('attrs', () => { describe('attrs', () => {
@ -63,6 +67,14 @@ describe('ssr: element', () => {
) )
}) })
test('static class + v-bind:class', () => {
expect(
getCompiledString(`<div class="foo" :class="bar"></div>`)
).toMatchInlineSnapshot(
`"\`<div\${_renderClass([_ctx.bar, \\"foo\\"])}></div>\`"`
)
})
test('v-bind:style', () => { test('v-bind:style', () => {
expect( expect(
getCompiledString(`<div id="foo" :style="bar"></div>`) getCompiledString(`<div id="foo" :style="bar"></div>`)
@ -71,6 +83,14 @@ describe('ssr: element', () => {
) )
}) })
test('static style + v-bind:style', () => {
expect(
getCompiledString(`<div style="color:red;" :style="bar"></div>`)
).toMatchInlineSnapshot(
`"\`<div\${_renderStyle([_hoisted_1, _ctx.bar])}></div>\`"`
)
})
test('v-bind:key (boolean)', () => { test('v-bind:key (boolean)', () => {
expect( expect(
getCompiledString(`<input type="checkbox" :checked="checked">`) getCompiledString(`<input type="checkbox" :checked="checked">`)
@ -86,5 +106,85 @@ describe('ssr: element', () => {
`"\`<div\${_renderAttr(\\"id\\", _ctx.id)} class=\\"bar\\"></div>\`"` `"\`<div\${_renderAttr(\\"id\\", _ctx.id)} class=\\"bar\\"></div>\`"`
) )
}) })
test('v-bind:[key]', () => {
expect(
getCompiledString(`<div v-bind:[key]="value"></div>`)
).toMatchInlineSnapshot(
`"\`<div\${_renderAttrs({ [_ctx.key]: _ctx.value })}></div>\`"`
)
expect(getCompiledString(`<div class="foo" v-bind:[key]="value"></div>`))
.toMatchInlineSnapshot(`
"\`<div\${_renderAttrs({
class: \\"foo\\",
[_ctx.key]: _ctx.value
})}></div>\`"
`)
expect(getCompiledString(`<div :id="id" v-bind:[key]="value"></div>`))
.toMatchInlineSnapshot(`
"\`<div\${_renderAttrs({
id: _ctx.id,
[_ctx.key]: _ctx.value
})}></div>\`"
`)
})
test('v-bind="obj"', () => {
expect(
getCompiledString(`<div v-bind="obj"></div>`)
).toMatchInlineSnapshot(`"\`<div\${_renderAttrs(_ctx.obj)}></div>\`"`)
expect(
getCompiledString(`<div class="foo" v-bind="obj"></div>`)
).toMatchInlineSnapshot(
`"\`<div\${_renderAttrs(mergeProps({ class: \\"foo\\" }, _ctx.obj))}></div>\`"`
)
expect(
getCompiledString(`<div :id="id" v-bind="obj"></div>`)
).toMatchInlineSnapshot(
`"\`<div\${_renderAttrs(mergeProps({ id: _ctx.id }, _ctx.obj))}></div>\`"`
)
// dynamic key + v-bind="object"
expect(
getCompiledString(`<div :[key]="id" v-bind="obj"></div>`)
).toMatchInlineSnapshot(
`"\`<div\${_renderAttrs(mergeProps({ [_ctx.key]: _ctx.id }, _ctx.obj))}></div>\`"`
)
// should merge class and :class
expect(getCompiledString(`<div class="a" :class="b" v-bind="obj"></div>`))
.toMatchInlineSnapshot(`
"\`<div\${_renderAttrs(mergeProps({
class: [\\"a\\", _ctx.b]
}, _ctx.obj))}></div>\`"
`)
// should merge style and :style
expect(
getCompiledString(
`<div style="color:red;" :style="b" v-bind="obj"></div>`
)
).toMatchInlineSnapshot(`
"\`<div\${_renderAttrs(mergeProps({
style: [_hoisted_1, _ctx.b]
}, _ctx.obj))}></div>\`"
`)
})
test('should ignore v-on', () => {
expect(
getCompiledString(`<div id="foo" @click="bar"/>`)
).toMatchInlineSnapshot(`"\`<div id=\\"foo\\"></div>\`"`)
expect(
getCompiledString(`<div id="foo" v-on="bar"/>`)
).toMatchInlineSnapshot(`"\`<div id=\\"foo\\"></div>\`"`)
expect(
getCompiledString(`<div v-bind="foo" v-on="bar"/>`)
).toMatchInlineSnapshot(`"\`<div\${_renderAttrs(_ctx.foo)}></div>\`"`)
})
}) })
}) })

View File

@ -9,7 +9,8 @@ import {
trackVForSlotScopes, trackVForSlotScopes,
trackSlotScopes, trackSlotScopes,
noopDirectiveTransform, noopDirectiveTransform,
transformBind transformBind,
transformStyle
} from '@vue/compiler-dom' } from '@vue/compiler-dom'
import { ssrCodegenTransform } from './ssrCodegenTransform' import { ssrCodegenTransform } from './ssrCodegenTransform'
import { ssrTransformElement } from './transforms/ssrTransformElement' import { ssrTransformElement } from './transforms/ssrTransformElement'
@ -49,15 +50,20 @@ export function compile(
ssrTransformElement, ssrTransformElement,
ssrTransformComponent, ssrTransformComponent,
trackSlotScopes, trackSlotScopes,
transformStyle,
...(options.nodeTransforms || []) // user transforms ...(options.nodeTransforms || []) // user transforms
], ],
ssrDirectiveTransforms: { directiveTransforms: {
on: noopDirectiveTransform, // reusing core v-bind
cloak: noopDirectiveTransform, bind: transformBind,
bind: transformBind, // reusing core v-bind // model and show has dedicated SSR handling
model: ssrTransformModel, model: ssrTransformModel,
show: ssrTransformShow, show: ssrTransformShow,
...(options.ssrDirectiveTransforms || {}) // user transforms // the following are ignored during SSR
on: noopDirectiveTransform,
cloak: noopDirectiveTransform,
once: noopDirectiveTransform,
...(options.directiveTransforms || {}) // user transforms
} }
}) })

View File

@ -7,7 +7,16 @@ import {
createInterpolation, createInterpolation,
createCallExpression, createCallExpression,
createConditionalExpression, createConditionalExpression,
createSimpleExpression createSimpleExpression,
buildProps,
DirectiveNode,
PlainElementNode,
createCompilerError,
ErrorCodes,
CallExpression,
createArrayExpression,
ExpressionNode,
JSChildNode
} from '@vue/compiler-dom' } from '@vue/compiler-dom'
import { escapeHtml, isBooleanAttr, isSSRSafeAttrName } from '@vue/shared' import { escapeHtml, isBooleanAttr, isSSRSafeAttrName } from '@vue/shared'
import { createSSRCompilerError, SSRErrorCodes } from '../errors' import { createSSRCompilerError, SSRErrorCodes } from '../errors'
@ -15,7 +24,8 @@ import {
SSR_RENDER_ATTR, SSR_RENDER_ATTR,
SSR_RENDER_CLASS, SSR_RENDER_CLASS,
SSR_RENDER_STYLE, SSR_RENDER_STYLE,
SSR_RENDER_DYNAMIC_ATTR SSR_RENDER_DYNAMIC_ATTR,
SSR_RENDER_ATTRS
} from '../runtimeHelpers' } from '../runtimeHelpers'
export const ssrTransformElement: NodeTransform = (node, context) => { export const ssrTransformElement: NodeTransform = (node, context) => {
@ -40,11 +50,22 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
p.arg.type !== NodeTypes.SIMPLE_EXPRESSION || // v-bind:[_ctx.foo] p.arg.type !== NodeTypes.SIMPLE_EXPRESSION || // v-bind:[_ctx.foo]
!p.arg.isStatic) // v-bind:[foo] !p.arg.isStatic) // v-bind:[foo]
) )
if (hasDynamicVBind) { if (hasDynamicVBind) {
// TODO const { props } = buildProps(node, context, node.props, true /* ssr */)
if (props) {
openTag.push(
createCallExpression(context.helper(SSR_RENDER_ATTRS), [props])
)
}
} }
// book keeping static/dynamic class merging.
let dynamicClassBinding: CallExpression | undefined = undefined
let staticClassBinding: string | undefined = undefined
// all style bindings are converted to dynamic by transformStyle.
// but we need to make sure to merge them.
let dynamicStyleBinding: CallExpression | undefined = undefined
for (let i = 0; i < node.props.length; i++) { for (let i = 0; i < node.props.length; i++) {
const prop = node.props[i] const prop = node.props[i]
// special cases with children override // special cases with children override
@ -54,22 +75,28 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
rawChildren = prop.exp rawChildren = prop.exp
} else if (prop.name === 'text' && prop.exp) { } else if (prop.name === 'text' && prop.exp) {
node.children = [createInterpolation(prop.exp, prop.loc)] node.children = [createInterpolation(prop.exp, prop.loc)]
} else if ( } else if (prop.name === 'slot') {
// v-bind:value on textarea context.onError(
node.tag === 'textarea' && createCompilerError(ErrorCodes.X_V_SLOT_MISPLACED, prop.loc)
prop.name === 'bind' && )
prop.exp && } else if (isTextareaWithValue(node, prop) && prop.exp) {
prop.arg && if (!hasDynamicVBind) {
prop.arg.type === NodeTypes.SIMPLE_EXPRESSION && node.children = [createInterpolation(prop.exp, prop.loc)]
prop.arg.isStatic && } else {
prop.arg.content === 'value' // TODO handle <textrea> with dynamic v-bind
) { }
node.children = [createInterpolation(prop.exp, prop.loc)] } else {
// TODO handle <textrea> with dynamic v-bind
} else if (!hasDynamicVBind) {
// Directive transforms. // Directive transforms.
const directiveTransform = context.ssrDirectiveTransforms[prop.name] const directiveTransform = context.directiveTransforms[prop.name]
if (directiveTransform) { if (!directiveTransform) {
// no corresponding ssr directive transform found.
context.onError(
createSSRCompilerError(
SSRErrorCodes.X_SSR_CUSTOM_DIRECTIVE_NO_TRANSFORM,
prop.loc
)
)
} else if (!hasDynamicVBind) {
const { props } = directiveTransform(prop, node, context) const { props } = directiveTransform(prop, node, context)
for (let j = 0; j < props.length; j++) { for (let j = 0; j < props.length; j++) {
const { key, value } = props[j] const { key, value } = props[j]
@ -78,16 +105,23 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
// static key attr // static key attr
if (attrName === 'class') { if (attrName === 'class') {
openTag.push( openTag.push(
createCallExpression(context.helper(SSR_RENDER_CLASS), [ (dynamicClassBinding = createCallExpression(
value context.helper(SSR_RENDER_CLASS),
]) [value]
))
) )
} else if (attrName === 'style') { } else if (attrName === 'style') {
openTag.push( if (dynamicStyleBinding) {
createCallExpression(context.helper(SSR_RENDER_STYLE), [ // already has style binding, merge into it.
value mergeCall(dynamicStyleBinding, value)
]) } else {
) openTag.push(
(dynamicStyleBinding = createCallExpression(
context.helper(SSR_RENDER_STYLE),
[value]
))
)
}
} else if (isBooleanAttr(attrName)) { } else if (isBooleanAttr(attrName)) {
openTag.push( openTag.push(
createConditionalExpression( createConditionalExpression(
@ -126,14 +160,6 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
) )
} }
} }
} else {
// no corresponding ssr directive transform found.
context.onError(
createSSRCompilerError(
SSRErrorCodes.X_SSR_CUSTOM_DIRECTIVE_NO_TRANSFORM,
prop.loc
)
)
} }
} }
} else { } else {
@ -143,6 +169,9 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
rawChildren = escapeHtml(prop.value.content) rawChildren = escapeHtml(prop.value.content)
} else if (!hasDynamicVBind) { } else if (!hasDynamicVBind) {
// static prop // static prop
if (prop.name === 'class' && prop.value) {
staticClassBinding = JSON.stringify(prop.value.content)
}
openTag.push( openTag.push(
` ${prop.name}` + ` ${prop.name}` +
(prop.value ? `="${escapeHtml(prop.value.content)}"` : ``) (prop.value ? `="${escapeHtml(prop.value.content)}"` : ``)
@ -151,6 +180,12 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
} }
} }
// handle co-existence of dynamic + static class bindings
if (dynamicClassBinding && staticClassBinding) {
mergeCall(dynamicClassBinding, staticClassBinding)
removeStaticBinding(openTag, 'class')
}
openTag.push(`>`) openTag.push(`>`)
if (rawChildren) { if (rawChildren) {
openTag.push(rawChildren) openTag.push(rawChildren)
@ -159,3 +194,34 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
} }
} }
} }
function isTextareaWithValue(
node: PlainElementNode,
prop: DirectiveNode
): boolean {
return !!(
node.tag === 'textarea' &&
prop.name === 'bind' &&
prop.arg &&
prop.arg.type === NodeTypes.SIMPLE_EXPRESSION &&
prop.arg.isStatic &&
prop.arg.content === 'value'
)
}
function mergeCall(call: CallExpression, arg: string | JSChildNode) {
const existing = call.arguments[0] as ExpressionNode
call.arguments[0] = createArrayExpression([existing, arg])
}
function removeStaticBinding(
tag: TemplateLiteral['elements'],
binding: string
) {
const i = tag.findIndex(
e => typeof e === 'string' && e.startsWith(` ${binding}=`)
)
if (i > -1) {
tag.splice(i, 1)
}
}