Explorar o código

feat(compiler-vapor): fold constant native bindings into templates (#14840)

edison hai 4 semanas
pai
achega
12a5c77339

+ 2 - 3
packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap

@@ -676,12 +676,11 @@ export function render(_ctx) {
 `;
 
 exports[`compiler: element transform > props merging: style 1`] = `
-"import { setStyle as _setStyle, template as _template } from 'vue';
-const t0 = _template("<div>", 1)
+"import { template as _template } from 'vue';
+const t0 = _template("<div style=color:red;>", 3)
 
 export function render(_ctx) {
   const n0 = t0()
-  _setStyle(n0, ["color: green", { color: 'red' }])
   return n0
 }"
 `;

+ 51 - 0
packages/compiler-vapor/__tests__/transforms/__snapshots__/vBind.spec.ts.snap

@@ -436,6 +436,36 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler v-bind > constant boolean class and style bindings are emitted in template 1`] = `
+"import { template as _template } from 'vue';
+const t0 = _template("<div disabled class=active style=color:red;margin-top:4px;>", 3)
+
+export function render(_ctx) {
+  const n0 = t0()
+  return n0
+}"
+`;
+
+exports[`compiler v-bind > constant false boolean bindings are omitted from template 1`] = `
+"import { template as _template } from 'vue';
+const t0 = _template("<button>", 3)
+
+export function render(_ctx) {
+  const n0 = t0()
+  return n0
+}"
+`;
+
+exports[`compiler v-bind > constant folded class and style escape html entities 1`] = `
+"import { template as _template } from 'vue';
+const t0 = _template("<div class=&amp;nbsp; style=--x:&amp;copy;>", 3)
+
+export function render(_ctx) {
+  const n0 = t0()
+  return n0
+}"
+`;
+
 exports[`compiler v-bind > dynamic arg 1`] = `
 "import { setDynamicProps as _setDynamicProps, renderEffect as _renderEffect, template as _template } from 'vue';
 const t0 = _template("<div>", 1)
@@ -519,6 +549,16 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler v-bind > pure static class stays in template without runtime setter 1`] = `
+"import { template as _template } from 'vue';
+const t0 = _template("<div class=\\"foo bar\\">", 3)
+
+export function render(_ctx) {
+  const n0 = t0()
+  return n0
+}"
+`;
+
 exports[`compiler v-bind > reverse ternary string className helper 1`] = `
 "import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
 const t0 = _template("<div>", 1)
@@ -639,6 +679,17 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler v-bind > unsafe constant object style stays on runtime setter 1`] = `
+"import { setStyle as _setStyle, template as _template } from 'vue';
+const t0 = _template("<div>", 1)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _setStyle(n0, { '--x': 'a;b' })
+  return n0
+}"
+`;
+
 exports[`compiler v-bind > v-bind w/ svg elements 1`] = `
 "import { setDynamicProps as _setDynamicProps, renderEffect as _renderEffect, template as _template } from 'vue';
 const t0 = _template("<svg>", 1, 1)

+ 1 - 25
packages/compiler-vapor/__tests__/transforms/transformElement.spec.ts

@@ -912,31 +912,7 @@ describe('compiler: element transform', () => {
     )
     expect(code).toMatchSnapshot()
 
-    expect(ir.block.operation).toMatchObject([
-      {
-        type: IRNodeTypes.SET_PROP,
-        element: 0,
-        prop: {
-          key: {
-            type: NodeTypes.SIMPLE_EXPRESSION,
-            content: 'style',
-            isStatic: true,
-          },
-          values: [
-            {
-              type: NodeTypes.SIMPLE_EXPRESSION,
-              content: 'color: green',
-              isStatic: true,
-            },
-            {
-              type: NodeTypes.SIMPLE_EXPRESSION,
-              content: `{ color: 'red' }`,
-              isStatic: false,
-            },
-          ],
-        },
-      },
-    ])
+    expect(ir.block.operation).toMatchObject([])
   })
 
   test('props merging: class', () => {

+ 80 - 0
packages/compiler-vapor/__tests__/transforms/vBind.spec.ts

@@ -721,6 +721,64 @@ describe('compiler v-bind', () => {
     expect(code).contains('_setClass(n0, _ctx.cls, true))')
   })
 
+  test('constant boolean class and style bindings are emitted in template', () => {
+    const { code } = compileWithVBind(`
+      <div
+        :disabled="true"
+        :class="{ active: true, hidden: false }"
+        :style="{ color: 'red', marginTop: '4px' }"
+      />
+    `)
+
+    expect(code).matchSnapshot()
+    expect(code).contains(
+      'const t0 = _template("<div disabled class=active style=color:red;margin-top:4px;>", 3)',
+    )
+  })
+
+  test('unsafe constant object style stays on runtime setter', () => {
+    const { code } = compileWithVBind(`
+      <div :style="{ '--x': 'a;b' }" />
+    `)
+
+    expect(code).matchSnapshot()
+    expect(code).contains('const t0 = _template("<div>", 1)')
+    expect(code).contains("_setStyle(n0, { '--x': 'a;b' })")
+    expect(code).not.contains('style=')
+  })
+
+  test('constant folded class and style escape html entities', () => {
+    const { code } = compileWithVBind(`
+      <div :class="{ '&amp;nbsp;': true }" :style="{ '--x': '&amp;copy' }" />
+    `)
+
+    expect(code).matchSnapshot()
+    expect(code).contains(
+      'const t0 = _template("<div class=&amp;nbsp; style=--x:&amp;copy;>", 3)',
+    )
+  })
+
+  test('constant false boolean bindings are omitted from template', () => {
+    const { code } = compileWithVBind(`
+      <button :disabled="false" :hidden="null" :multiple="undefined" />
+    `)
+
+    expect(code).matchSnapshot()
+    expect(code).contains('const t0 = _template("<button>", 3)')
+  })
+
+  test('pure static class stays in template without runtime setter', () => {
+    const { code } = compileWithVBind(`
+      <div class=" foo  bar " />
+    `)
+
+    expect(code).matchSnapshot()
+    expect(code).contains(
+      'const t0 = _template("<div class=\\"foo bar\\">", 3)',
+    )
+    expect(code).not.contains('_setClass')
+  })
+
   test('simple object className helper', () => {
     const { code } = compileWithVBind(`
       <div :class="{ active: isActive }"/>
@@ -771,6 +829,28 @@ describe('compiler v-bind', () => {
     expect(code).not.contains('{ bar:')
   })
 
+  test('mixed static and dynamic class keeps static class in runtime merge', () => {
+    const { code } = compileWithVBind(`
+      <div class="base" :class="{ active: ok, hidden: false, fixed: true }"/>
+    `)
+
+    expect(code).contains('const t0 = _template("<div>", 1)')
+    expect(code).contains('_setClassName(n0,')
+    expect(code).contains('"base"')
+    expect(code).not.contains('class=\\"base')
+  })
+
+  test('static class with unanalyzable dynamic class keeps runtime merge', () => {
+    const { code } = compileWithVBind(`
+      <div class="base" :class="cls"/>
+    `)
+
+    expect(code).contains('const t0 = _template("<div>", 1)')
+    expect(code).contains('_setClass(n0, ["base", _ctx.cls])')
+    expect(code).not.contains('class=base')
+    expect(code).not.contains('_setClassName')
+  })
+
   test('static class in reverse order uses className helper with suffix', () => {
     const { code } = compileWithVBind(`
       <div :class="{ bar: isBar }" class="foo"/>

+ 271 - 10
packages/compiler-vapor/src/transforms/transformElement.ts

@@ -20,13 +20,18 @@ import {
   camelize,
   capitalize,
   extend,
+  includeBooleanAttr,
   isAlwaysCloseTag,
   isBlockTag,
+  isBooleanAttr,
   isBuiltInDirective,
   isFormattingTag,
   isInlineTag,
   isVoidTag,
   makeMap,
+  normalizeClass,
+  normalizeStyle,
+  stringifyStyle,
 } from '@vue/shared'
 import type {
   DirectiveTransformResult,
@@ -54,6 +59,7 @@ import {
 } from '../utils'
 import { IMPORT_EXP_END, IMPORT_EXP_START } from '../generators/utils'
 import { normalizeBindShorthand } from './vBind'
+import type { Expression, ObjectExpression, ObjectProperty } from '@babel/types'
 
 export const isReservedProp: (key: string) => boolean = /*#__PURE__*/ makeMap(
   // the leading comma is intentional so empty string "" is also included
@@ -431,6 +437,26 @@ function transformNativeElement(
     // tracks if previous attribute was quoted, allowing space omission
     // e.g. `class="foo"id="bar"` is valid, `class=foo id=bar` needs space
     let prevWasQuoted = false
+    const appendTemplateProp = (
+      key: string,
+      value: string = '',
+      generated: boolean = false,
+    ) => {
+      if (!prevWasQuoted) template += ` `
+      template += key
+
+      if (value) {
+        const escapedValue = generated
+          ? escapeGeneratedAttrValue(value)
+          : value.replace(/"/g, '&quot;')
+        template += (prevWasQuoted = NEEDS_QUOTES_RE.test(value))
+          ? `="${escapedValue}"`
+          : `=${escapedValue}`
+      } else {
+        prevWasQuoted = false
+      }
+    }
+
     for (const prop of propsResult[1]) {
       const { key, values } = prop
       // handling asset imports
@@ -444,23 +470,67 @@ function transformNativeElement(
         // with string concatenation in the generator, see genTemplates
         template += `${key.content}="${IMPORT_EXP_START}${values[0].content}${IMPORT_EXP_END}"`
         prevWasQuoted = true
+      } else if (key.isStatic && !prop.modifier && isBooleanAttr(key.content)) {
+        if (
+          values.length === 1 &&
+          (values[0].isStatic || values[0].content === "''") &&
+          !dynamicKeys.includes(key.content)
+        ) {
+          const value = values[0].content === "''" ? '' : values[0].content
+          appendTemplateProp(key.content, value)
+        } else {
+          const include = foldBooleanAttrValue(values)
+          if (include != null) {
+            if (include) {
+              appendTemplateProp(key.content)
+            }
+          } else {
+            dynamicProps.push(key.content)
+            context.registerEffect(
+              values,
+              {
+                type: IRNodeTypes.SET_PROP,
+                element: context.reference(),
+                prop,
+                tag,
+              },
+              getEffectIndex,
+            )
+          }
+        }
+      } else if (key.isStatic && !prop.modifier && hasBoundValue(values)) {
+        let foldedValue: string | undefined
+        if (key.content === 'class') {
+          foldedValue = foldClassValues(values)
+        } else if (key.content === 'style') {
+          foldedValue = foldStyleValues(values)
+        }
+
+        if (foldedValue != null) {
+          if (foldedValue) {
+            appendTemplateProp(key.content, foldedValue, true)
+          }
+        } else {
+          dynamicProps.push(key.content)
+          context.registerEffect(
+            values,
+            {
+              type: IRNodeTypes.SET_PROP,
+              element: context.reference(),
+              prop,
+              tag,
+            },
+            getEffectIndex,
+          )
+        }
       } else if (
         key.isStatic &&
         values.length === 1 &&
         (values[0].isStatic || values[0].content === "''") &&
         !dynamicKeys.includes(key.content)
       ) {
-        if (!prevWasQuoted) template += ` `
         const value = values[0].content === "''" ? '' : values[0].content
-        template += key.content
-
-        if (value) {
-          template += (prevWasQuoted = NEEDS_QUOTES_RE.test(value))
-            ? `="${value.replace(/"/g, '&quot;')}"`
-            : `=${value}`
-        } else {
-          prevWasQuoted = false
-        }
+        appendTemplateProp(key.content, value)
       } else {
         dynamicProps.push(key.content)
         context.registerEffect(
@@ -501,6 +571,197 @@ function transformNativeElement(
   }
 }
 
+interface ConstantValue {
+  value: unknown
+}
+
+function escapeGeneratedAttrValue(value: string): string {
+  return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;')
+}
+
+function foldBooleanAttrValue(
+  values: SimpleExpressionNode[],
+): boolean | undefined {
+  if (values.length !== 1) return
+
+  const evaluated = evaluateConstantExpression(values[0])
+  if (!evaluated) return
+
+  const value = evaluated.value
+  if (value === true || value === false || value == null) {
+    return includeBooleanAttr(value)
+  }
+}
+
+function foldStyleValues(values: SimpleExpressionNode[]): string | undefined {
+  const evaluatedValues: unknown[] = []
+  for (const value of values) {
+    const evaluated = evaluateConstantExpression(value)
+    if (!evaluated || !isStaticStyleValue(evaluated.value)) {
+      return
+    }
+    evaluatedValues.push(evaluated.value)
+  }
+
+  const normalized = normalizeStyle(
+    evaluatedValues.length === 1 ? evaluatedValues[0] : evaluatedValues,
+  )
+  return stringifyStyle(normalized)
+}
+
+function isStaticStyleValue(value: unknown): boolean {
+  if (typeof value === 'string') {
+    return true
+  }
+  if (!value || typeof value !== 'object' || Array.isArray(value)) {
+    return false
+  }
+  for (const key in value as Record<string, unknown>) {
+    const propValue = (value as Record<string, unknown>)[key]
+    if (!isSafeStylePropertyName(key) || !isSafeStylePropertyValue(propValue)) {
+      return false
+    }
+  }
+  return true
+}
+
+function isSafeStylePropertyName(key: string): boolean {
+  return !!key && !/[;:]/.test(key)
+}
+
+function isSafeStylePropertyValue(value: unknown): boolean {
+  return (
+    typeof value === 'number' ||
+    (typeof value === 'string' && !value.includes(';'))
+  )
+}
+
+function hasBoundValue(values: SimpleExpressionNode[]): boolean {
+  return values.some(value => !value.isStatic && value.content !== "''")
+}
+
+function foldClassValues(values: SimpleExpressionNode[]): string | undefined {
+  let templateValue = ''
+  let changed = false
+
+  for (const value of values) {
+    const evaluated = evaluateConstantExpression(value)
+    if (evaluated) {
+      const normalized = normalizeClass(evaluated.value)
+      if (normalized) {
+        templateValue = appendClass(templateValue, normalized)
+      } else {
+        changed = true
+      }
+      continue
+    }
+
+    return
+  }
+
+  return changed || templateValue ? templateValue : undefined
+}
+
+function appendClass(base: string, value: string): string {
+  return base ? (value ? `${base} ${value}` : base) : value
+}
+
+function getObjectPropertyName(prop: ObjectProperty): string | undefined {
+  const key = prop.key
+  if (key.type === 'Identifier') {
+    return key.name
+  } else if (key.type === 'StringLiteral') {
+    return key.value
+  } else if (key.type === 'NumericLiteral') {
+    return String(key.value)
+  }
+}
+
+function evaluateConstantExpression(
+  node: SimpleExpressionNode,
+): ConstantValue | undefined {
+  if (node.isStatic) {
+    return { value: node.content }
+  }
+
+  const ast = node.ast
+  if (ast === null) {
+    if (node.content === 'true') {
+      return { value: true }
+    } else if (node.content === 'false') {
+      return { value: false }
+    } else if (node.content === 'null') {
+      return { value: null }
+    } else if (node.content === 'undefined') {
+      return { value: undefined }
+    }
+  }
+  if (!ast) return
+  return evaluateConstantAst(ast as Expression)
+}
+
+function evaluateConstantAst(node: Expression): ConstantValue | undefined {
+  switch (node.type) {
+    case 'StringLiteral':
+      return { value: node.value }
+    case 'NumericLiteral':
+      return { value: node.value }
+    case 'BooleanLiteral':
+      return { value: node.value }
+    case 'NullLiteral':
+      return { value: null }
+    case 'Identifier':
+      return node.name === 'undefined' ? { value: undefined } : undefined
+    case 'UnaryExpression':
+      if (node.operator === 'void') {
+        return { value: undefined }
+      } else if (node.operator === '-') {
+        const value = evaluateConstantAst(node.argument)
+        return value && typeof value.value === 'number'
+          ? { value: -value.value }
+          : undefined
+      }
+      return
+    case 'TemplateLiteral':
+      return evaluateTemplateLiteral(node)
+    case 'ObjectExpression':
+      return evaluateObjectExpression(node)
+  }
+}
+
+function evaluateTemplateLiteral(node: Expression): ConstantValue | undefined {
+  if (node.type !== 'TemplateLiteral') return
+
+  let value = ''
+  for (const [index, quasi] of node.quasis.entries()) {
+    value += quasi.value.cooked || ''
+    const expression = node.expressions[index]
+    if (expression) {
+      const evaluated = evaluateConstantAst(expression as Expression)
+      if (!evaluated) return
+      value += evaluated.value
+    }
+  }
+  return { value }
+}
+
+function evaluateObjectExpression(
+  node: ObjectExpression,
+): ConstantValue | undefined {
+  const value: Record<string, unknown> = {}
+  for (const prop of node.properties) {
+    if (prop.type !== 'ObjectProperty' || prop.computed) {
+      return
+    }
+    const key = getObjectPropertyName(prop)
+    if (key == null) return
+    const evaluated = evaluateConstantAst(prop.value as Expression)
+    if (!evaluated) return
+    value[key] = evaluated.value
+  }
+  return { value }
+}
+
 function resolveStaticKey(
   node: ElementNode,
   context: TransformContext<ElementNode>,