Skip to content

Commit 8ab0074

Browse files
committed
feat(sfc): css v-bind
1 parent 2d67641 commit 8ab0074

19 files changed

+798
-52
lines changed

examples/composition/todomvc.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<script src="../../dist/vue.min.js"></script>
1+
<script src="../../dist/vue.js"></script>
22
<link
33
rel="stylesheet"
44
href="../../node_modules/todomvc-app-css/index.css"

packages/compiler-sfc/src/compileScript.ts

+31-3
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ import { isReservedTag } from 'web/util'
4242
import { dirRE } from 'compiler/parser'
4343
import { parseText } from 'compiler/parser/text-parser'
4444
import { DEFAULT_FILENAME } from './parseComponent'
45+
import {
46+
CSS_VARS_HELPER,
47+
genCssVarsCode,
48+
genNormalScriptCssVarsCode
49+
} from './cssVars'
50+
import { rewriteDefault } from './rewriteDefault'
4551

4652
// Special compiler macros
4753
const DEFINE_PROPS = 'defineProps'
@@ -57,6 +63,11 @@ const isBuiltInDir = makeMap(
5763
)
5864

5965
export interface SFCScriptCompileOptions {
66+
/**
67+
* Scope ID for prefixing injected CSS variables.
68+
* This must be consistent with the `id` passed to `compileStyle`.
69+
*/
70+
id: string
6071
/**
6172
* Production mode. Used to determine whether to generate hashed CSS variables
6273
*/
@@ -86,14 +97,15 @@ export interface ImportBinding {
8697
*/
8798
export function compileScript(
8899
sfc: SFCDescriptor,
89-
options: SFCScriptCompileOptions = {}
100+
options: SFCScriptCompileOptions = { id: '' }
90101
): SFCScriptBlock {
91102
let { filename, script, scriptSetup, source } = sfc
92103
const isProd = !!options.isProd
93104
const genSourceMap = options.sourceMap !== false
94105
let refBindings: string[] | undefined
95106

96-
// const cssVars = sfc.cssVars
107+
const cssVars = sfc.cssVars
108+
const scopeId = options.id ? options.id.replace(/^data-v-/, '') : ''
97109
const scriptLang = script && script.lang
98110
const scriptSetupLang = scriptSetup && scriptSetup.lang
99111
const isTS =
@@ -132,6 +144,16 @@ export function compileScript(
132144
sourceType: 'module'
133145
}).program
134146
const bindings = analyzeScriptBindings(scriptAst.body)
147+
if (cssVars.length) {
148+
content = rewriteDefault(content, DEFAULT_VAR, plugins)
149+
content += genNormalScriptCssVarsCode(
150+
cssVars,
151+
bindings,
152+
scopeId,
153+
isProd
154+
)
155+
content += `\nexport default ${DEFAULT_VAR}`
156+
}
135157
return {
136158
...script,
137159
content,
@@ -1082,7 +1104,13 @@ export function compileScript(
10821104
}
10831105

10841106
// 8. inject `useCssVars` calls
1085-
// Not backported in Vue 2
1107+
if (cssVars.length) {
1108+
helperImports.add(CSS_VARS_HELPER)
1109+
s.prependRight(
1110+
startOffset,
1111+
`\n${genCssVarsCode(cssVars, bindingMetadata, scopeId, isProd)}\n`
1112+
)
1113+
}
10861114

10871115
// 9. finalize setup() argument signature
10881116
let args = `__props`

packages/compiler-sfc/src/compileStyle.ts

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
StylePreprocessor,
88
StylePreprocessorResults
99
} from './stylePreprocessors'
10+
import { cssVarsPlugin } from './cssVars'
1011

1112
export interface SFCStyleCompileOptions {
1213
source: string
@@ -19,6 +20,7 @@ export interface SFCStyleCompileOptions {
1920
preprocessOptions?: any
2021
postcssOptions?: any
2122
postcssPlugins?: any[]
23+
isProd?: boolean
2224
}
2325

2426
export interface SFCAsyncStyleCompileOptions extends SFCStyleCompileOptions {
@@ -52,6 +54,7 @@ export function doCompileStyle(
5254
id,
5355
scoped = true,
5456
trim = true,
57+
isProd = false,
5558
preprocessLang,
5659
postcssOptions,
5760
postcssPlugins
@@ -62,6 +65,7 @@ export function doCompileStyle(
6265
const source = preProcessedSource ? preProcessedSource.code : options.source
6366

6467
const plugins = (postcssPlugins || []).slice()
68+
plugins.unshift(cssVarsPlugin({ id: id.replace(/^data-v-/, ''), isProd }))
6569
if (trim) {
6670
plugins.push(trimPlugin())
6771
}

packages/compiler-sfc/src/compileTemplate.ts

+2-4
Original file line numberDiff line numberDiff line change
@@ -148,17 +148,15 @@ function actuallyCompile(
148148
// version of Buble that applies ES2015 transforms + stripping `with` usage
149149
let code =
150150
`var __render__ = ${prefixIdentifiers(
151-
render,
152-
`render`,
151+
`function render(${isFunctional ? `_c,_vm` : ``}){${render}\n}`,
153152
isFunctional,
154153
isTS,
155154
transpileOptions,
156155
bindings
157156
)}\n` +
158157
`var __staticRenderFns__ = [${staticRenderFns.map(code =>
159158
prefixIdentifiers(
160-
code,
161-
``,
159+
`function (${isFunctional ? `_c,_vm` : ``}){${code}\n}`,
162160
isFunctional,
163161
isTS,
164162
transpileOptions,

packages/compiler-sfc/src/cssVars.ts

+179
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { BindingMetadata } from './types'
2+
import { SFCDescriptor } from './parseComponent'
3+
import { PluginCreator } from 'postcss'
4+
import hash from 'hash-sum'
5+
import { prefixIdentifiers } from './prefixIdentifiers'
6+
7+
export const CSS_VARS_HELPER = `useCssVars`
8+
9+
export function genCssVarsFromList(
10+
vars: string[],
11+
id: string,
12+
isProd: boolean,
13+
isSSR = false
14+
): string {
15+
return `{\n ${vars
16+
.map(
17+
key => `"${isSSR ? `--` : ``}${genVarName(id, key, isProd)}": (${key})`
18+
)
19+
.join(',\n ')}\n}`
20+
}
21+
22+
function genVarName(id: string, raw: string, isProd: boolean): string {
23+
if (isProd) {
24+
return hash(id + raw)
25+
} else {
26+
return `${id}-${raw.replace(/([^\w-])/g, '_')}`
27+
}
28+
}
29+
30+
function normalizeExpression(exp: string) {
31+
exp = exp.trim()
32+
if (
33+
(exp[0] === `'` && exp[exp.length - 1] === `'`) ||
34+
(exp[0] === `"` && exp[exp.length - 1] === `"`)
35+
) {
36+
return exp.slice(1, -1)
37+
}
38+
return exp
39+
}
40+
41+
const vBindRE = /v-bind\s*\(/g
42+
43+
export function parseCssVars(sfc: SFCDescriptor): string[] {
44+
const vars: string[] = []
45+
sfc.styles.forEach(style => {
46+
let match
47+
// ignore v-bind() in comments /* ... */
48+
const content = style.content.replace(/\/\*([\s\S]*?)\*\//g, '')
49+
while ((match = vBindRE.exec(content))) {
50+
const start = match.index + match[0].length
51+
const end = lexBinding(content, start)
52+
if (end !== null) {
53+
const variable = normalizeExpression(content.slice(start, end))
54+
if (!vars.includes(variable)) {
55+
vars.push(variable)
56+
}
57+
}
58+
}
59+
})
60+
return vars
61+
}
62+
63+
const enum LexerState {
64+
inParens,
65+
inSingleQuoteString,
66+
inDoubleQuoteString
67+
}
68+
69+
function lexBinding(content: string, start: number): number | null {
70+
let state: LexerState = LexerState.inParens
71+
let parenDepth = 0
72+
73+
for (let i = start; i < content.length; i++) {
74+
const char = content.charAt(i)
75+
switch (state) {
76+
case LexerState.inParens:
77+
if (char === `'`) {
78+
state = LexerState.inSingleQuoteString
79+
} else if (char === `"`) {
80+
state = LexerState.inDoubleQuoteString
81+
} else if (char === `(`) {
82+
parenDepth++
83+
} else if (char === `)`) {
84+
if (parenDepth > 0) {
85+
parenDepth--
86+
} else {
87+
return i
88+
}
89+
}
90+
break
91+
case LexerState.inSingleQuoteString:
92+
if (char === `'`) {
93+
state = LexerState.inParens
94+
}
95+
break
96+
case LexerState.inDoubleQuoteString:
97+
if (char === `"`) {
98+
state = LexerState.inParens
99+
}
100+
break
101+
}
102+
}
103+
return null
104+
}
105+
106+
// for compileStyle
107+
export interface CssVarsPluginOptions {
108+
id: string
109+
isProd: boolean
110+
}
111+
112+
export const cssVarsPlugin: PluginCreator<CssVarsPluginOptions> = opts => {
113+
const { id, isProd } = opts!
114+
return {
115+
postcssPlugin: 'vue-sfc-vars',
116+
Declaration(decl) {
117+
// rewrite CSS variables
118+
const value = decl.value
119+
if (vBindRE.test(value)) {
120+
vBindRE.lastIndex = 0
121+
let transformed = ''
122+
let lastIndex = 0
123+
let match
124+
while ((match = vBindRE.exec(value))) {
125+
const start = match.index + match[0].length
126+
const end = lexBinding(value, start)
127+
if (end !== null) {
128+
const variable = normalizeExpression(value.slice(start, end))
129+
transformed +=
130+
value.slice(lastIndex, match.index) +
131+
`var(--${genVarName(id, variable, isProd)})`
132+
lastIndex = end + 1
133+
}
134+
}
135+
decl.value = transformed + value.slice(lastIndex)
136+
}
137+
}
138+
}
139+
}
140+
cssVarsPlugin.postcss = true
141+
142+
export function genCssVarsCode(
143+
vars: string[],
144+
bindings: BindingMetadata,
145+
id: string,
146+
isProd: boolean
147+
) {
148+
const varsExp = genCssVarsFromList(vars, id, isProd)
149+
return `_${CSS_VARS_HELPER}((_vm, _setup) => ${prefixIdentifiers(
150+
`(${varsExp})`,
151+
false,
152+
false,
153+
undefined,
154+
bindings
155+
)})`
156+
}
157+
158+
// <script setup> already gets the calls injected as part of the transform
159+
// this is only for single normal <script>
160+
export function genNormalScriptCssVarsCode(
161+
cssVars: string[],
162+
bindings: BindingMetadata,
163+
id: string,
164+
isProd: boolean
165+
): string {
166+
return (
167+
`\nimport { ${CSS_VARS_HELPER} as _${CSS_VARS_HELPER} } from 'vue'\n` +
168+
`const __injectCSSVars__ = () => {\n${genCssVarsCode(
169+
cssVars,
170+
bindings,
171+
id,
172+
isProd
173+
)}}\n` +
174+
`const __setup__ = __default__.setup\n` +
175+
`__default__.setup = __setup__\n` +
176+
` ? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }\n` +
177+
` : __injectCSSVars__\n`
178+
)
179+
}

packages/compiler-sfc/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export { generateCodeFrame } from 'compiler/codeframe'
77
export { rewriteDefault } from './rewriteDefault'
88

99
// types
10+
export { SFCParseOptions } from './parse'
1011
export { CompilerOptions, WarningMessage } from 'types/compiler'
1112
export { TemplateCompiler } from './types'
1213
export {

packages/compiler-sfc/src/parse.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const cache = new LRU<string, SFCDescriptor>(100)
1616
const splitRE = /\r?\n/g
1717
const emptyRE = /^(?:\/\/)?\s*$/
1818

19-
export interface ParseOptions {
19+
export interface SFCParseOptions {
2020
source: string
2121
filename?: string
2222
compiler?: TemplateCompiler
@@ -25,7 +25,7 @@ export interface ParseOptions {
2525
sourceMap?: boolean
2626
}
2727

28-
export function parse(options: ParseOptions): SFCDescriptor {
28+
export function parse(options: SFCParseOptions): SFCDescriptor {
2929
const {
3030
source,
3131
filename = DEFAULT_FILENAME,

packages/compiler-sfc/src/parseComponent.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { makeMap } from 'shared/util'
44
import { ASTAttr, WarningMessage } from 'types/compiler'
55
import { BindingMetadata, RawSourceMap } from './types'
66
import type { ImportBinding } from './compileScript'
7+
import { parseCssVars } from './cssVars'
78

89
export const DEFAULT_FILENAME = 'anonymous.vue'
910

@@ -50,7 +51,9 @@ export interface SFCDescriptor {
5051
scriptSetup: SFCScriptBlock | null
5152
styles: SFCBlock[]
5253
customBlocks: SFCCustomBlock[]
53-
errors: WarningMessage[]
54+
cssVars: string[]
55+
56+
errors: (string | WarningMessage)[]
5457

5558
/**
5659
* compare with an existing descriptor to determine whether HMR should perform
@@ -84,6 +87,7 @@ export function parseComponent(
8487
scriptSetup: null, // TODO
8588
styles: [],
8689
customBlocks: [],
90+
cssVars: [],
8791
errors: [],
8892
shouldForceReload: null as any // attached in parse() by compiler-sfc
8993
}
@@ -205,5 +209,8 @@ export function parseComponent(
205209
outputSourceRange: options.outputSourceRange
206210
})
207211

212+
// parse CSS vars
213+
sfc.cssVars = parseCssVars(sfc)
214+
208215
return sfc
209216
}

packages/compiler-sfc/src/prefixIdentifiers.ts

+1-5
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,15 @@ const doNotPrefix = makeMap(
1414
)
1515

1616
/**
17-
* The input is expected to be the render function code directly returned from
18-
* `compile()` calls, e.g. `with(this){return ...}`
17+
* The input is expected to be a valid expression.
1918
*/
2019
export function prefixIdentifiers(
2120
source: string,
22-
fnName = '',
2321
isFunctional = false,
2422
isTS = false,
2523
babelOptions: ParserOptions = {},
2624
bindings?: BindingMetadata
2725
) {
28-
source = `function ${fnName}(${isFunctional ? `_c,_vm` : ``}){${source}\n}`
29-
3026
const s = new MagicString(source)
3127

3228
const plugins: ParserPlugin[] = [

0 commit comments

Comments
 (0)