feat(sfc): css v-bind

This commit is contained in:
Evan You 2022-06-20 15:46:57 +08:00
parent 2d67641656
commit 8ab0074bab
19 changed files with 798 additions and 52 deletions

View File

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

View File

@ -42,6 +42,12 @@ import { isReservedTag } from 'web/util'
import { dirRE } from 'compiler/parser' import { dirRE } from 'compiler/parser'
import { parseText } from 'compiler/parser/text-parser' import { parseText } from 'compiler/parser/text-parser'
import { DEFAULT_FILENAME } from './parseComponent' import { DEFAULT_FILENAME } from './parseComponent'
import {
CSS_VARS_HELPER,
genCssVarsCode,
genNormalScriptCssVarsCode
} from './cssVars'
import { rewriteDefault } from './rewriteDefault'
// Special compiler macros // Special compiler macros
const DEFINE_PROPS = 'defineProps' const DEFINE_PROPS = 'defineProps'
@ -57,6 +63,11 @@ const isBuiltInDir = makeMap(
) )
export interface SFCScriptCompileOptions { 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 * Production mode. Used to determine whether to generate hashed CSS variables
*/ */
@ -86,14 +97,15 @@ export interface ImportBinding {
*/ */
export function compileScript( export function compileScript(
sfc: SFCDescriptor, sfc: SFCDescriptor,
options: SFCScriptCompileOptions = {} options: SFCScriptCompileOptions = { id: '' }
): SFCScriptBlock { ): SFCScriptBlock {
let { filename, script, scriptSetup, source } = sfc let { filename, script, scriptSetup, source } = sfc
const isProd = !!options.isProd const isProd = !!options.isProd
const genSourceMap = options.sourceMap !== false const genSourceMap = options.sourceMap !== false
let refBindings: string[] | undefined 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 scriptLang = script && script.lang
const scriptSetupLang = scriptSetup && scriptSetup.lang const scriptSetupLang = scriptSetup && scriptSetup.lang
const isTS = const isTS =
@ -132,6 +144,16 @@ export function compileScript(
sourceType: 'module' sourceType: 'module'
}).program }).program
const bindings = analyzeScriptBindings(scriptAst.body) 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 { return {
...script, ...script,
content, content,
@ -1082,7 +1104,13 @@ export function compileScript(
} }
// 8. inject `useCssVars` calls // 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 // 9. finalize setup() argument signature
let args = `__props` let args = `__props`

View File

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

View File

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

View File

@ -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`
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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__"
`;

View File

@ -1,33 +1,5 @@
import { BindingTypes } from '../src/types' import { BindingTypes } from '../src/types'
import { parse, ParseOptions } from '../src/parse' import { compile, assertCode } from './util'
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()
}
describe('SFC compile <script setup>', () => { describe('SFC compile <script setup>', () => {
test('should expose top level declarations', () => { test('should expose top level declarations', () => {

View File

@ -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`])
})
})
})

View File

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

View File

@ -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()
}

View File

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

View File

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

View File

@ -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])
}
}
})
}

View File

@ -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)
}
}))
})
})

View File

@ -13,7 +13,8 @@ export default defineConfig({
shared: resolve('src/shared'), shared: resolve('src/shared'),
web: resolve('src/platforms/web'), web: resolve('src/platforms/web'),
v3: resolve('src/v3'), 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: { define: {