Browse Source

feat(sfc): css v-bind

Evan You 3 years ago
parent
commit
8ab0074bab

+ 1 - 1
examples/composition/todomvc.html

@@ -1,4 +1,4 @@
-<script src="../../dist/vue.min.js"></script>
+<script src="../../dist/vue.js"></script>
 <link
   rel="stylesheet"
   href="../../node_modules/todomvc-app-css/index.css"

+ 31 - 3
packages/compiler-sfc/src/compileScript.ts

@@ -42,6 +42,12 @@ import { isReservedTag } from 'web/util'
 import { dirRE } from 'compiler/parser'
 import { parseText } from 'compiler/parser/text-parser'
 import { DEFAULT_FILENAME } from './parseComponent'
+import {
+  CSS_VARS_HELPER,
+  genCssVarsCode,
+  genNormalScriptCssVarsCode
+} from './cssVars'
+import { rewriteDefault } from './rewriteDefault'
 
 // Special compiler macros
 const DEFINE_PROPS = 'defineProps'
@@ -57,6 +63,11 @@ const isBuiltInDir = makeMap(
 )
 
 export interface SFCScriptCompileOptions {
+  /**
+   * Scope ID for prefixing injected CSS variables.
+   * This must be consistent with the `id` passed to `compileStyle`.
+   */
+  id: string
   /**
    * Production mode. Used to determine whether to generate hashed CSS variables
    */
@@ -86,14 +97,15 @@ export interface ImportBinding {
  */
 export function compileScript(
   sfc: SFCDescriptor,
-  options: SFCScriptCompileOptions = {}
+  options: SFCScriptCompileOptions = { id: '' }
 ): SFCScriptBlock {
   let { filename, script, scriptSetup, source } = sfc
   const isProd = !!options.isProd
   const genSourceMap = options.sourceMap !== false
   let refBindings: string[] | undefined
 
-  // const cssVars = sfc.cssVars
+  const cssVars = sfc.cssVars
+  const scopeId = options.id ? options.id.replace(/^data-v-/, '') : ''
   const scriptLang = script && script.lang
   const scriptSetupLang = scriptSetup && scriptSetup.lang
   const isTS =
@@ -132,6 +144,16 @@ export function compileScript(
         sourceType: 'module'
       }).program
       const bindings = analyzeScriptBindings(scriptAst.body)
+      if (cssVars.length) {
+        content = rewriteDefault(content, DEFAULT_VAR, plugins)
+        content += genNormalScriptCssVarsCode(
+          cssVars,
+          bindings,
+          scopeId,
+          isProd
+        )
+        content += `\nexport default ${DEFAULT_VAR}`
+      }
       return {
         ...script,
         content,
@@ -1082,7 +1104,13 @@ export function compileScript(
   }
 
   // 8. inject `useCssVars` calls
-  // Not backported in Vue 2
+  if (cssVars.length) {
+    helperImports.add(CSS_VARS_HELPER)
+    s.prependRight(
+      startOffset,
+      `\n${genCssVarsCode(cssVars, bindingMetadata, scopeId, isProd)}\n`
+    )
+  }
 
   // 9. finalize setup() argument signature
   let args = `__props`

+ 4 - 0
packages/compiler-sfc/src/compileStyle.ts

@@ -7,6 +7,7 @@ import {
   StylePreprocessor,
   StylePreprocessorResults
 } from './stylePreprocessors'
+import { cssVarsPlugin } from './cssVars'
 
 export interface SFCStyleCompileOptions {
   source: string
@@ -19,6 +20,7 @@ export interface SFCStyleCompileOptions {
   preprocessOptions?: any
   postcssOptions?: any
   postcssPlugins?: any[]
+  isProd?: boolean
 }
 
 export interface SFCAsyncStyleCompileOptions extends SFCStyleCompileOptions {
@@ -52,6 +54,7 @@ export function doCompileStyle(
     id,
     scoped = true,
     trim = true,
+    isProd = false,
     preprocessLang,
     postcssOptions,
     postcssPlugins
@@ -62,6 +65,7 @@ export function doCompileStyle(
   const source = preProcessedSource ? preProcessedSource.code : options.source
 
   const plugins = (postcssPlugins || []).slice()
+  plugins.unshift(cssVarsPlugin({ id: id.replace(/^data-v-/, ''), isProd }))
   if (trim) {
     plugins.push(trimPlugin())
   }

+ 2 - 4
packages/compiler-sfc/src/compileTemplate.ts

@@ -148,8 +148,7 @@ function actuallyCompile(
     // version of Buble that applies ES2015 transforms + stripping `with` usage
     let code =
       `var __render__ = ${prefixIdentifiers(
-        render,
-        `render`,
+        `function render(${isFunctional ? `_c,_vm` : ``}){${render}\n}`,
         isFunctional,
         isTS,
         transpileOptions,
@@ -157,8 +156,7 @@ function actuallyCompile(
       )}\n` +
       `var __staticRenderFns__ = [${staticRenderFns.map(code =>
         prefixIdentifiers(
-          code,
-          ``,
+          `function (${isFunctional ? `_c,_vm` : ``}){${code}\n}`,
           isFunctional,
           isTS,
           transpileOptions,

+ 179 - 0
packages/compiler-sfc/src/cssVars.ts

@@ -0,0 +1,179 @@
+import { BindingMetadata } from './types'
+import { SFCDescriptor } from './parseComponent'
+import { PluginCreator } from 'postcss'
+import hash from 'hash-sum'
+import { prefixIdentifiers } from './prefixIdentifiers'
+
+export const CSS_VARS_HELPER = `useCssVars`
+
+export function genCssVarsFromList(
+  vars: string[],
+  id: string,
+  isProd: boolean,
+  isSSR = false
+): string {
+  return `{\n  ${vars
+    .map(
+      key => `"${isSSR ? `--` : ``}${genVarName(id, key, isProd)}": (${key})`
+    )
+    .join(',\n  ')}\n}`
+}
+
+function genVarName(id: string, raw: string, isProd: boolean): string {
+  if (isProd) {
+    return hash(id + raw)
+  } else {
+    return `${id}-${raw.replace(/([^\w-])/g, '_')}`
+  }
+}
+
+function normalizeExpression(exp: string) {
+  exp = exp.trim()
+  if (
+    (exp[0] === `'` && exp[exp.length - 1] === `'`) ||
+    (exp[0] === `"` && exp[exp.length - 1] === `"`)
+  ) {
+    return exp.slice(1, -1)
+  }
+  return exp
+}
+
+const vBindRE = /v-bind\s*\(/g
+
+export function parseCssVars(sfc: SFCDescriptor): string[] {
+  const vars: string[] = []
+  sfc.styles.forEach(style => {
+    let match
+    // ignore v-bind() in comments /* ... */
+    const content = style.content.replace(/\/\*([\s\S]*?)\*\//g, '')
+    while ((match = vBindRE.exec(content))) {
+      const start = match.index + match[0].length
+      const end = lexBinding(content, start)
+      if (end !== null) {
+        const variable = normalizeExpression(content.slice(start, end))
+        if (!vars.includes(variable)) {
+          vars.push(variable)
+        }
+      }
+    }
+  })
+  return vars
+}
+
+const enum LexerState {
+  inParens,
+  inSingleQuoteString,
+  inDoubleQuoteString
+}
+
+function lexBinding(content: string, start: number): number | null {
+  let state: LexerState = LexerState.inParens
+  let parenDepth = 0
+
+  for (let i = start; i < content.length; i++) {
+    const char = content.charAt(i)
+    switch (state) {
+      case LexerState.inParens:
+        if (char === `'`) {
+          state = LexerState.inSingleQuoteString
+        } else if (char === `"`) {
+          state = LexerState.inDoubleQuoteString
+        } else if (char === `(`) {
+          parenDepth++
+        } else if (char === `)`) {
+          if (parenDepth > 0) {
+            parenDepth--
+          } else {
+            return i
+          }
+        }
+        break
+      case LexerState.inSingleQuoteString:
+        if (char === `'`) {
+          state = LexerState.inParens
+        }
+        break
+      case LexerState.inDoubleQuoteString:
+        if (char === `"`) {
+          state = LexerState.inParens
+        }
+        break
+    }
+  }
+  return null
+}
+
+// for compileStyle
+export interface CssVarsPluginOptions {
+  id: string
+  isProd: boolean
+}
+
+export const cssVarsPlugin: PluginCreator<CssVarsPluginOptions> = opts => {
+  const { id, isProd } = opts!
+  return {
+    postcssPlugin: 'vue-sfc-vars',
+    Declaration(decl) {
+      // rewrite CSS variables
+      const value = decl.value
+      if (vBindRE.test(value)) {
+        vBindRE.lastIndex = 0
+        let transformed = ''
+        let lastIndex = 0
+        let match
+        while ((match = vBindRE.exec(value))) {
+          const start = match.index + match[0].length
+          const end = lexBinding(value, start)
+          if (end !== null) {
+            const variable = normalizeExpression(value.slice(start, end))
+            transformed +=
+              value.slice(lastIndex, match.index) +
+              `var(--${genVarName(id, variable, isProd)})`
+            lastIndex = end + 1
+          }
+        }
+        decl.value = transformed + value.slice(lastIndex)
+      }
+    }
+  }
+}
+cssVarsPlugin.postcss = true
+
+export function genCssVarsCode(
+  vars: string[],
+  bindings: BindingMetadata,
+  id: string,
+  isProd: boolean
+) {
+  const varsExp = genCssVarsFromList(vars, id, isProd)
+  return `_${CSS_VARS_HELPER}((_vm, _setup) => ${prefixIdentifiers(
+    `(${varsExp})`,
+    false,
+    false,
+    undefined,
+    bindings
+  )})`
+}
+
+// <script setup> already gets the calls injected as part of the transform
+// this is only for single normal <script>
+export function genNormalScriptCssVarsCode(
+  cssVars: string[],
+  bindings: BindingMetadata,
+  id: string,
+  isProd: boolean
+): string {
+  return (
+    `\nimport { ${CSS_VARS_HELPER} as _${CSS_VARS_HELPER} } from 'vue'\n` +
+    `const __injectCSSVars__ = () => {\n${genCssVarsCode(
+      cssVars,
+      bindings,
+      id,
+      isProd
+    )}}\n` +
+    `const __setup__ = __default__.setup\n` +
+    `__default__.setup = __setup__\n` +
+    `  ? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }\n` +
+    `  : __injectCSSVars__\n`
+  )
+}

+ 1 - 0
packages/compiler-sfc/src/index.ts

@@ -7,6 +7,7 @@ export { generateCodeFrame } from 'compiler/codeframe'
 export { rewriteDefault } from './rewriteDefault'
 
 // types
+export { SFCParseOptions } from './parse'
 export { CompilerOptions, WarningMessage } from 'types/compiler'
 export { TemplateCompiler } from './types'
 export {

+ 2 - 2
packages/compiler-sfc/src/parse.ts

@@ -16,7 +16,7 @@ const cache = new LRU<string, SFCDescriptor>(100)
 const splitRE = /\r?\n/g
 const emptyRE = /^(?:\/\/)?\s*$/
 
-export interface ParseOptions {
+export interface SFCParseOptions {
   source: string
   filename?: string
   compiler?: TemplateCompiler
@@ -25,7 +25,7 @@ export interface ParseOptions {
   sourceMap?: boolean
 }
 
-export function parse(options: ParseOptions): SFCDescriptor {
+export function parse(options: SFCParseOptions): SFCDescriptor {
   const {
     source,
     filename = DEFAULT_FILENAME,

+ 8 - 1
packages/compiler-sfc/src/parseComponent.ts

@@ -4,6 +4,7 @@ import { makeMap } from 'shared/util'
 import { ASTAttr, WarningMessage } from 'types/compiler'
 import { BindingMetadata, RawSourceMap } from './types'
 import type { ImportBinding } from './compileScript'
+import { parseCssVars } from './cssVars'
 
 export const DEFAULT_FILENAME = 'anonymous.vue'
 
@@ -50,7 +51,9 @@ export interface SFCDescriptor {
   scriptSetup: SFCScriptBlock | null
   styles: SFCBlock[]
   customBlocks: SFCCustomBlock[]
-  errors: WarningMessage[]
+  cssVars: string[]
+
+  errors: (string | WarningMessage)[]
 
   /**
    * compare with an existing descriptor to determine whether HMR should perform
@@ -84,6 +87,7 @@ export function parseComponent(
     scriptSetup: null, // TODO
     styles: [],
     customBlocks: [],
+    cssVars: [],
     errors: [],
     shouldForceReload: null as any // attached in parse() by compiler-sfc
   }
@@ -205,5 +209,8 @@ export function parseComponent(
     outputSourceRange: options.outputSourceRange
   })
 
+  // parse CSS vars
+  sfc.cssVars = parseCssVars(sfc)
+
   return sfc
 }

+ 1 - 5
packages/compiler-sfc/src/prefixIdentifiers.ts

@@ -14,19 +14,15 @@ const doNotPrefix = makeMap(
 )
 
 /**
- * The input is expected to be the render function code directly returned from
- * `compile()` calls, e.g. `with(this){return ...}`
+ * The input is expected to be a valid expression.
  */
 export function prefixIdentifiers(
   source: string,
-  fnName = '',
   isFunctional = false,
   isTS = false,
   babelOptions: ParserOptions = {},
   bindings?: BindingMetadata
 ) {
-  source = `function ${fnName}(${isFunctional ? `_c,_vm` : ``}){${source}\n}`
-
   const s = new MagicString(source)
 
   const plugins: ParserPlugin[] = [

+ 189 - 0
packages/compiler-sfc/test/__snapshots__/cssVars.spec.ts.snap

@@ -0,0 +1,189 @@
+// Vitest Snapshot v1
+
+exports[`CSS vars injection > codegen > <script> w/ default export 1`] = `
+"const __default__ = { setup() {} }
+import { useCssVars as _useCssVars } from 'vue'
+const __injectCSSVars__ = () => {
+_useCssVars((_vm, _setup) => ({
+  \\"xxxxxxxx-color\\": (_vm.color)
+}))}
+const __setup__ = __default__.setup
+__default__.setup = __setup__
+  ? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
+  : __injectCSSVars__
+
+export default __default__"
+`;
+
+exports[`CSS vars injection > codegen > <script> w/ default export in strings/comments 1`] = `
+"
+          // export default {}
+          const __default__ = {}
+        
+import { useCssVars as _useCssVars } from 'vue'
+const __injectCSSVars__ = () => {
+_useCssVars((_vm, _setup) => ({
+  \\"xxxxxxxx-color\\": (_vm.color)
+}))}
+const __setup__ = __default__.setup
+__default__.setup = __setup__
+  ? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
+  : __injectCSSVars__
+
+export default __default__"
+`;
+
+exports[`CSS vars injection > codegen > <script> w/ no default export 1`] = `
+"const a = 1
+const __default__ = {}
+import { useCssVars as _useCssVars } from 'vue'
+const __injectCSSVars__ = () => {
+_useCssVars((_vm, _setup) => ({
+  \\"xxxxxxxx-color\\": (_vm.color)
+}))}
+const __setup__ = __default__.setup
+__default__.setup = __setup__
+  ? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
+  : __injectCSSVars__
+
+export default __default__"
+`;
+
+exports[`CSS vars injection > codegen > should ignore comments 1`] = `
+"import { useCssVars as _useCssVars } from 'vue'
+
+export default {
+  setup(__props) {
+
+_useCssVars((_vm, _setup) => ({
+  \\"xxxxxxxx-width\\": (_setup.width)
+}))
+const color = 'red';const width = 100
+return { color, width }
+}
+
+}"
+`;
+
+exports[`CSS vars injection > codegen > should work with w/ complex expression 1`] = `
+"import { useCssVars as _useCssVars } from 'vue'
+
+export default {
+  setup(__props) {
+
+_useCssVars((_vm, _setup) => ({
+  \\"xxxxxxxx-foo\\": (_setup.foo),
+  \\"xxxxxxxx-foo____px_\\": (_setup.foo + 'px'),
+  \\"xxxxxxxx-_a___b____2____px_\\": ((_setup.a + _setup.b) / 2 + 'px'),
+  \\"xxxxxxxx-__a___b______2___a_\\": (((_setup.a + _setup.b)) / (2 * _setup.a))
+}))
+
+        let a = 100
+        let b = 200
+        let foo = 300
+        
+return { a, b, foo }
+}
+
+}"
+`;
+
+exports[`CSS vars injection > codegen > w/ <script setup> 1`] = `
+"import { useCssVars as _useCssVars } from 'vue'
+
+export default {
+  setup(__props) {
+
+_useCssVars((_vm, _setup) => ({
+  \\"xxxxxxxx-color\\": (_setup.color)
+}))
+const color = 'red'
+return { color }
+}
+
+}"
+`;
+
+exports[`CSS vars injection > codegen > w/ <script setup> using the same var multiple times 1`] = `
+"import { useCssVars as _useCssVars } from 'vue'
+
+export default {
+  setup(__props) {
+
+_useCssVars((_vm, _setup) => ({
+  \\"xxxxxxxx-color\\": (_setup.color)
+}))
+
+        const color = 'red'
+        
+return { color }
+}
+
+}"
+`;
+
+exports[`CSS vars injection > generating correct code for nested paths 1`] = `
+"const a = 1
+const __default__ = {}
+import { useCssVars as _useCssVars } from 'vue'
+const __injectCSSVars__ = () => {
+_useCssVars((_vm, _setup) => ({
+  \\"xxxxxxxx-color\\": (_vm.color),
+  \\"xxxxxxxx-font_size\\": (_vm.font.size)
+}))}
+const __setup__ = __default__.setup
+__default__.setup = __setup__
+  ? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
+  : __injectCSSVars__
+
+export default __default__"
+`;
+
+exports[`CSS vars injection > w/ <script setup> binding analysis 1`] = `
+"import { useCssVars as _useCssVars } from 'vue'
+import { ref } from 'vue'
+        
+export default {
+  props: {
+          foo: String
+        },
+  setup(__props) {
+
+_useCssVars((_vm, _setup) => ({
+  \\"xxxxxxxx-color\\": (_setup.color),
+  \\"xxxxxxxx-size\\": (_setup.size),
+  \\"xxxxxxxx-foo\\": (_vm.foo)
+}))
+
+        const color = 'red'
+        const size = ref('10px')
+        
+        
+return { color, size, ref }
+}
+
+}"
+`;
+
+exports[`CSS vars injection > w/ normal <script> binding analysis 1`] = `
+"
+      const __default__ = {
+        setup() {
+          return {
+            size: ref('100px')
+          }
+        }
+      }
+      
+import { useCssVars as _useCssVars } from 'vue'
+const __injectCSSVars__ = () => {
+_useCssVars((_vm, _setup) => ({
+  \\"xxxxxxxx-size\\": (_vm.size)
+}))}
+const __setup__ = __default__.setup
+__default__.setup = __setup__
+  ? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
+  : __injectCSSVars__
+
+export default __default__"
+`;

+ 1 - 29
packages/compiler-sfc/test/compileScript.spec.ts

@@ -1,33 +1,5 @@
 import { BindingTypes } from '../src/types'
-import { parse, ParseOptions } from '../src/parse'
-import { parse as babelParse } from '@babel/parser'
-import { compileScript, SFCScriptCompileOptions } from '../src/compileScript'
-
-function compile(
-  source: string,
-  options?: Partial<SFCScriptCompileOptions>,
-  parseOptions?: Partial<ParseOptions>
-) {
-  const sfc = parse({
-    ...parseOptions,
-    source
-  })
-  return compileScript(sfc, options)
-}
-
-function assertCode(code: string) {
-  // parse the generated code to make sure it is valid
-  try {
-    babelParse(code, {
-      sourceType: 'module',
-      plugins: ['typescript']
-    })
-  } catch (e: any) {
-    console.log(code)
-    throw e
-  }
-  expect(code).toMatchSnapshot()
-}
+import { compile, assertCode } from './util'
 
 describe('SFC compile <script setup>', () => {
   test('should expose top level declarations', () => {

+ 247 - 0
packages/compiler-sfc/test/cssVars.spec.ts

@@ -0,0 +1,247 @@
+import { compileStyle, parse } from '../src'
+import { mockId, compile, assertCode } from './util'
+
+describe('CSS vars injection', () => {
+  test('generating correct code for nested paths', () => {
+    const { content } = compile(
+      `<script>const a = 1</script>\n` +
+        `<style>div{
+          color: v-bind(color);
+          font-size: v-bind('font.size');
+        }</style>`
+    )
+    expect(content).toMatch(`_useCssVars((_vm, _setup) => ({
+  "${mockId}-color": (_vm.color),
+  "${mockId}-font_size": (_vm.font.size)
+})`)
+    assertCode(content)
+  })
+
+  test('w/ normal <script> binding analysis', () => {
+    const { content } = compile(
+      `<script>
+      export default {
+        setup() {
+          return {
+            size: ref('100px')
+          }
+        }
+      }
+      </script>\n` +
+        `<style>
+          div {
+            font-size: v-bind(size);
+          }
+        </style>`
+    )
+    expect(content).toMatch(`_useCssVars((_vm, _setup) => ({
+  "${mockId}-size": (_vm.size)
+})`)
+    expect(content).toMatch(`import { useCssVars as _useCssVars } from 'vue'`)
+    assertCode(content)
+  })
+
+  test('w/ <script setup> binding analysis', () => {
+    const { content } = compile(
+      `<script setup>
+        import { defineProps, ref } from 'vue'
+        const color = 'red'
+        const size = ref('10px')
+        defineProps({
+          foo: String
+        })
+        </script>\n` +
+        `<style>
+          div {
+            color: v-bind(color);
+            font-size: v-bind(size);
+            border: v-bind(foo);
+          }
+        </style>`
+    )
+    // should handle:
+    // 1. local const bindings
+    // 2. local potential ref bindings
+    // 3. props bindings (analyzed)
+    expect(content).toMatch(`_useCssVars((_vm, _setup) => ({
+  "${mockId}-color": (_setup.color),
+  "${mockId}-size": (_setup.size),
+  "${mockId}-foo": (_vm.foo)
+})`)
+    expect(content).toMatch(`import { useCssVars as _useCssVars } from 'vue'`)
+    assertCode(content)
+  })
+
+  test('should rewrite CSS vars in compileStyle', () => {
+    const { code } = compileStyle({
+      source: `.foo {
+        color: v-bind(color);
+        font-size: v-bind('font.size');
+      }`,
+      filename: 'test.css',
+      id: 'data-v-test'
+    })
+    expect(code).toMatchInlineSnapshot(`
+      ".foo[data-v-test] {
+              color: var(--test-color);
+              font-size: var(--test-font_size);
+      }"
+    `)
+  })
+
+  test('prod mode', () => {
+    const { content } = compile(
+      `<script>const a = 1</script>\n` +
+        `<style>div{
+          color: v-bind(color);
+          font-size: v-bind('font.size');
+        }</style>`,
+      { isProd: true }
+    )
+    expect(content).toMatch(`_useCssVars((_vm, _setup) => ({
+  "4003f1a6": (_vm.color),
+  "41b6490a": (_vm.font.size)
+}))}`)
+
+    const { code } = compileStyle({
+      source: `.foo {
+        color: v-bind(color);
+        font-size: v-bind('font.size');
+      }`,
+      filename: 'test.css',
+      id: mockId,
+      isProd: true
+    })
+    expect(code).toMatchInlineSnapshot(`
+      ".foo[xxxxxxxx] {
+              color: var(--4003f1a6);
+              font-size: var(--41b6490a);
+      }"
+    `)
+  })
+
+  describe('codegen', () => {
+    test('<script> w/ no default export', () => {
+      assertCode(
+        compile(
+          `<script>const a = 1</script>\n` +
+            `<style>div{ color: v-bind(color); }</style>`
+        ).content
+      )
+    })
+
+    test('<script> w/ default export', () => {
+      assertCode(
+        compile(
+          `<script>export default { setup() {} }</script>\n` +
+            `<style>div{ color: v-bind(color); }</style>`
+        ).content
+      )
+    })
+
+    test('<script> w/ default export in strings/comments', () => {
+      assertCode(
+        compile(
+          `<script>
+          // export default {}
+          export default {}
+        </script>\n` + `<style>div{ color: v-bind(color); }</style>`
+        ).content
+      )
+    })
+
+    test('w/ <script setup>', () => {
+      assertCode(
+        compile(
+          `<script setup>const color = 'red'</script>\n` +
+            `<style>div{ color: v-bind(color); }</style>`
+        ).content
+      )
+    })
+
+    //#4185
+    test('should ignore comments', () => {
+      const { content } = compile(
+        `<script setup>const color = 'red';const width = 100</script>\n` +
+          `<style>
+            /* comment **/
+            div{ /* color: v-bind(color); */ width:20; }
+            div{ width: v-bind(width); }
+            /* comment */
+          </style>`
+      )
+
+      expect(content).not.toMatch(`"${mockId}-color": (_setup.color)`)
+      expect(content).toMatch(`"${mockId}-width": (_setup.width)`)
+      assertCode(content)
+    })
+
+    test('w/ <script setup> using the same var multiple times', () => {
+      const { content } = compile(
+        `<script setup>
+        const color = 'red'
+        </script>\n` +
+          `<style>
+          div {
+            color: v-bind(color);
+          }
+          p {
+            color: v-bind(color);
+          }
+        </style>`
+      )
+      // color should only be injected once, even if it is twice in style
+      expect(content).toMatch(`_useCssVars((_vm, _setup) => ({
+  "${mockId}-color": (_setup.color)
+})`)
+      assertCode(content)
+    })
+
+    test('should work with w/ complex expression', () => {
+      const { content } = compile(
+        `<script setup>
+        let a = 100
+        let b = 200
+        let foo = 300
+        </script>\n` +
+          `<style>
+          p{
+            width: calc(v-bind(foo) - 3px);
+            height: calc(v-bind('foo') - 3px);
+            top: calc(v-bind(foo + 'px') - 3px);
+          }
+          div {
+            color: v-bind((a + b) / 2 + 'px' );
+          }
+          div {
+            color: v-bind    ((a + b) / 2 + 'px' );
+          }
+          p {
+            color: v-bind(((a + b)) / (2 * a));
+          }
+        </style>`
+      )
+      expect(content).toMatch(`_useCssVars((_vm, _setup) => ({
+  "${mockId}-foo": (_setup.foo),
+  "${mockId}-foo____px_": (_setup.foo + 'px'),
+  "${mockId}-_a___b____2____px_": ((_setup.a + _setup.b) / 2 + 'px'),
+  "${mockId}-__a___b______2___a_": (((_setup.a + _setup.b)) / (2 * _setup.a))
+})`)
+      assertCode(content)
+    })
+
+    // #6022
+    test('should be able to parse incomplete expressions', () => {
+      const { cssVars } = parse({
+        source: `<script setup>let xxx = 1</script>
+        <style scoped>
+        label {
+          font-weight: v-bind("count.toString(");
+          font-weight: v-bind(xxx);
+        }
+        </style>`
+      })
+      expect(cssVars).toMatchObject([`count.toString(`, `xxx`])
+    })
+  })
+})

+ 4 - 2
packages/compiler-sfc/test/prefixIdentifiers.spec.ts

@@ -3,6 +3,8 @@ import { compile } from 'web/entry-compiler'
 import { format } from 'prettier'
 import { BindingTypes } from '../src/types'
 
+const toFn = (source: string) => `function render(){${source}\n}`
+
 it('should work', () => {
   const { render } = compile(`<div id="app">
   <div>{{ foo }}</div>
@@ -12,7 +14,7 @@ it('should work', () => {
   </foo>
 </div>`)
 
-  const result = format(prefixIdentifiers(render, `render`), {
+  const result = format(prefixIdentifiers(toFn(render)), {
     semi: false,
     parser: 'babel'
   })
@@ -59,7 +61,7 @@ it('setup bindings', () => {
   const { render } = compile(`<div @click="count++">{{ count }}</div>`)
 
   const result = format(
-    prefixIdentifiers(render, `render`, false, false, undefined, {
+    prefixIdentifiers(toFn(render), false, false, undefined, {
       count: BindingTypes.SETUP_REF
     }),
     {

+ 35 - 0
packages/compiler-sfc/test/util.ts

@@ -0,0 +1,35 @@
+import {
+  parse,
+  compileScript,
+  type SFCParseOptions,
+  type SFCScriptCompileOptions
+} from '../src'
+import { parse as babelParse } from '@babel/parser'
+
+export const mockId = 'xxxxxxxx'
+
+export function compile(
+  source: string,
+  options?: Partial<SFCScriptCompileOptions>,
+  parseOptions?: Partial<SFCParseOptions>
+) {
+  const sfc = parse({
+    ...parseOptions,
+    source
+  })
+  return compileScript(sfc, { id: mockId, ...options })
+}
+
+export function assertCode(code: string) {
+  // parse the generated code to make sure it is valid
+  try {
+    babelParse(code, {
+      sourceType: 'module',
+      plugins: ['typescript']
+    })
+  } catch (e: any) {
+    console.log(code)
+    throw e
+  }
+  expect(code).toMatchSnapshot()
+}

+ 8 - 4
src/v3/apiSetup.ts

@@ -86,13 +86,17 @@ export function proxyWithRefUnwrap(
   source: Record<string, any>,
   key: string
 ) {
-  let raw = source[key]
   Object.defineProperty(target, key, {
     enumerable: true,
     configurable: true,
-    get: () => (isRef(raw) ? raw.value : raw),
-    set: newVal =>
-      isRef(raw) ? (raw.value = newVal) : (raw = source[key] = newVal)
+    get: () => {
+      const raw = source[key]
+      return isRef(raw) ? raw.value : raw
+    },
+    set: newVal => {
+      const raw = source[key]
+      isRef(raw) ? (raw.value = newVal) : (source[key] = newVal)
+    }
   })
 }
 

+ 1 - 0
src/v3/index.ts

@@ -77,6 +77,7 @@ export { nextTick } from 'core/util/next-tick'
 export { set, del } from 'core/observer'
 
 export { useCssModule } from './sfc-helpers/useCssModule'
+export { useCssVars } from './sfc-helpers/useCssVars'
 
 /**
  * @internal type is manually declared in <root>/types/v3-define-component.d.ts

+ 34 - 0
src/v3/sfc-helpers/useCssVars.ts

@@ -0,0 +1,34 @@
+import { watchPostEffect } from '../'
+import { inBrowser, warn } from 'core/util'
+import { currentInstance } from '../currentInstance'
+
+/**
+ * Runtime helper for SFC's CSS variable injection feature.
+ * @private
+ */
+export function useCssVars(
+  getter: (
+    vm: Record<string, any>,
+    setupProxy: Record<string, any>
+  ) => Record<string, string>
+) {
+  if (!inBrowser && !__TEST__) return
+
+  const instance = currentInstance
+  if (!instance) {
+    __DEV__ &&
+      warn(`useCssVars is called without current active component instance.`)
+    return
+  }
+
+  watchPostEffect(() => {
+    const el = instance.$el
+    const vars = getter(instance, instance._setupProxy!)
+    if (el && el.nodeType === 1) {
+      const style = (el as HTMLElement).style
+      for (const key in vars) {
+        style.setProperty(`--${key}`, vars[key])
+      }
+    }
+  })
+}

+ 48 - 0
test/unit/features/v3/useCssVars.spec.ts

@@ -0,0 +1,48 @@
+import Vue from 'vue'
+import { useCssVars, h, reactive, nextTick } from 'v3'
+
+describe('useCssVars', () => {
+  async function assertCssVars(getApp: (state: any) => any) {
+    const state = reactive({ color: 'red' })
+    const App = getApp(state)
+    const vm = new Vue(App).$mount()
+    await nextTick()
+    expect((vm.$el as HTMLElement).style.getPropertyValue(`--color`)).toBe(
+      `red`
+    )
+
+    state.color = 'green'
+    await nextTick()
+    expect((vm.$el as HTMLElement).style.getPropertyValue(`--color`)).toBe(
+      `green`
+    )
+  }
+
+  test('basic', async () => {
+    await assertCssVars(state => ({
+      setup() {
+        // test receiving render context
+        useCssVars(vm => ({
+          color: vm.color
+        }))
+        return state
+      },
+      render() {
+        return h('div')
+      }
+    }))
+  })
+
+  test('on HOCs', async () => {
+    const Child = {
+      render: () => h('div')
+    }
+
+    await assertCssVars(state => ({
+      setup() {
+        useCssVars(() => state)
+        return () => h(Child)
+      }
+    }))
+  })
+})

+ 2 - 1
vitest.config.ts

@@ -13,7 +13,8 @@ export default defineConfig({
       shared: resolve('src/shared'),
       web: resolve('src/platforms/web'),
       v3: resolve('src/v3'),
-      vue: resolve('src/platforms/web/entry-runtime-with-compiler')
+      vue: resolve('src/platforms/web/entry-runtime-with-compiler'),
+      types: resolve('src/types')
     }
   },
   define: {