diff --git a/examples/composition/todomvc.html b/examples/composition/todomvc.html index ac806bc8369..c80f2fe3959 100644 --- a/examples/composition/todomvc.html +++ b/examples/composition/todomvc.html @@ -1,4 +1,4 @@ - + prefixIdentifiers( - code, - ``, + `function (${isFunctional ? `_c,_vm` : ``}){${code}\n}`, isFunctional, isTS, transpileOptions, diff --git a/packages/compiler-sfc/src/cssVars.ts b/packages/compiler-sfc/src/cssVars.ts new file mode 100644 index 00000000000..48f8cb70244 --- /dev/null +++ b/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 = 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 + )})` +} + +// \n` + + `` + ) + expect(content).toMatch(`_useCssVars((_vm, _setup) => ({ + "${mockId}-color": (_vm.color), + "${mockId}-font_size": (_vm.font.size) +})`) + assertCode(content) + }) + + test('w/ normal \n` + + `` + ) + expect(content).toMatch(`_useCssVars((_vm, _setup) => ({ + "${mockId}-size": (_vm.size) +})`) + expect(content).toMatch(`import { useCssVars as _useCssVars } from 'vue'`) + assertCode(content) + }) + + test('w/ \n` + + `` + ) + // 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( + `\n` + + ``, + { 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('\n` + + `` + ).content + ) + }) + + test('\n` + + `` + ).content + ) + }) + + test('\n` + `` + ).content + ) + }) + + test('w/ \n` + + `` + ).content + ) + }) + + //#4185 + test('should ignore comments', () => { + const { content } = compile( + `\n` + + `` + ) + + expect(content).not.toMatch(`"${mockId}-color": (_setup.color)`) + expect(content).toMatch(`"${mockId}-width": (_setup.width)`) + assertCode(content) + }) + + test('w/ \n` + + `` + ) + // 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( + `\n` + + `` + ) + 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: ` + ` + }) + expect(cssVars).toMatchObject([`count.toString(`, `xxx`]) + }) + }) +}) diff --git a/packages/compiler-sfc/test/prefixIdentifiers.spec.ts b/packages/compiler-sfc/test/prefixIdentifiers.spec.ts index f9bc93978c7..8bc5d7722dc 100644 --- a/packages/compiler-sfc/test/prefixIdentifiers.spec.ts +++ b/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(`
{{ foo }}
@@ -12,7 +14,7 @@ it('should work', () => {
`) - 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(`
{{ count }}
`) const result = format( - prefixIdentifiers(render, `render`, false, false, undefined, { + prefixIdentifiers(toFn(render), false, false, undefined, { count: BindingTypes.SETUP_REF }), { diff --git a/packages/compiler-sfc/test/util.ts b/packages/compiler-sfc/test/util.ts new file mode 100644 index 00000000000..773158999de --- /dev/null +++ b/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, + parseOptions?: Partial +) { + 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() +} diff --git a/src/v3/apiSetup.ts b/src/v3/apiSetup.ts index b3540fd75b5..123f921135a 100644 --- a/src/v3/apiSetup.ts +++ b/src/v3/apiSetup.ts @@ -86,13 +86,17 @@ export function proxyWithRefUnwrap( source: Record, 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) + } }) } diff --git a/src/v3/index.ts b/src/v3/index.ts index 044fd27c43b..e2bec70e8fe 100644 --- a/src/v3/index.ts +++ b/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 /types/v3-define-component.d.ts diff --git a/src/v3/sfc-helpers/useCssVars.ts b/src/v3/sfc-helpers/useCssVars.ts new file mode 100644 index 00000000000..cba7050a0e7 --- /dev/null +++ b/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, + setupProxy: Record + ) => Record +) { + 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]) + } + } + }) +} diff --git a/test/unit/features/v3/useCssVars.spec.ts b/test/unit/features/v3/useCssVars.spec.ts new file mode 100644 index 00000000000..76bb6cca6e2 --- /dev/null +++ b/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) + } + })) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 14999481936..52e5ea66489 100644 --- a/vitest.config.ts +++ b/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: {