feat(compiler-sfc): new SFC css varaible injection implementation

ref: https://github.com/vuejs/rfcs/pull/231
This commit is contained in:
Evan You 2020-11-16 18:27:15 -05:00
parent 62372e9943
commit 41bb7fa330
16 changed files with 497 additions and 341 deletions

View File

@ -215,7 +215,8 @@ export function generate(
}
// binding optimizations
const optimizeSources = options.bindingMetadata
const optimizeSources =
options.bindingMetadata && !options.inline
? `, $props, $setup, $data, $options`
: ``
// enter render function

View File

@ -113,18 +113,17 @@ export function processExpression(
// it gets correct type
return `__props.${raw}`
}
}
} else {
if (type === BindingTypes.CONST) {
// setup const binding in non-inline mode
return `$setup.${raw}`
} else if (type) {
return `$${type}.${raw}`
} else {
}
}
// fallback to ctx
return `_ctx.${raw}`
}
}
// fast path if expression is a simple identifier.
const rawExp = node.content

View File

@ -1,62 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SFC compile <script setup> CSS vars injection <script> w/ default export 1`] = `
"const __default__ = { setup() {} }
import { useCssVars as _useCssVars } from 'vue'
const __injectCSSVars__ = () => {
_useCssVars(_ctx => ({ color: _ctx.color }))
}
const __setup__ = __default__.setup
__default__.setup = __setup__
? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
: __injectCSSVars__
export default __default__"
`;
exports[`SFC compile <script setup> CSS vars injection <script> w/ default export in strings/comments 1`] = `
"
// export default {}
const __default__ = {}
import { useCssVars as _useCssVars } from 'vue'
const __injectCSSVars__ = () => {
_useCssVars(_ctx => ({ color: _ctx.color }))
}
const __setup__ = __default__.setup
__default__.setup = __setup__
? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
: __injectCSSVars__
export default __default__"
`;
exports[`SFC compile <script setup> CSS vars injection <script> w/ no default export 1`] = `
"const a = 1
const __default__ = {}
import { useCssVars as _useCssVars } from 'vue'
const __injectCSSVars__ = () => {
_useCssVars(_ctx => ({ color: _ctx.color }))
}
const __setup__ = __default__.setup
__default__.setup = __setup__
? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
: __injectCSSVars__
export default __default__"
`;
exports[`SFC compile <script setup> CSS vars injection w/ <script setup> 1`] = `
"import { useCssVars as _useCssVars } from 'vue'
export default {
expose: [],
setup() {
const color = 'red'
_useCssVars(_ctx => ({ color }))
return { color }
}
}"
`;
exports[`SFC compile <script setup> defineOptions() 1`] = `
"export default {
expose: [],
@ -86,7 +29,7 @@ export default {
default: () => bar
}
},
setup() {
setup(__props) {
@ -104,7 +47,7 @@ exports[`SFC compile <script setup> errors should allow defineOptions() referenc
default: bar => bar + 1
}
},
setup() {
setup(__props) {
const bar = 1
@ -121,7 +64,7 @@ import { ref } from 'vue'
export default {
expose: [],
setup() {
setup(__props) {
const foo = _ref(1)
@ -136,7 +79,7 @@ exports[`SFC compile <script setup> imports import dedupe between <script> and <
export default {
expose: [],
setup() {
setup(__props) {
x()
@ -152,7 +95,7 @@ exports[`SFC compile <script setup> imports should extract comment for import or
export default {
expose: [],
setup() {
setup(__props) {
return { a, b }
@ -165,7 +108,7 @@ exports[`SFC compile <script setup> imports should hoist and expose imports 1`]
"import { ref } from 'vue'
export default {
expose: [],
setup() {
setup(__props) {
return { ref }
}
@ -182,13 +125,13 @@ import { ref } from 'vue'
export default {
expose: [],
setup() {
setup(__props) {
const count = ref(0)
const constant = {}
function fn() {}
return (_ctx, _cache, $props, $setup, $data, $options) => {
return (_ctx, _cache) => {
return (_openBlock(), _createBlock(_Fragment, null, [
_createVNode(Foo),
_createVNode(\\"div\\", { onClick: fn }, _toDisplayString(_unref(count)) + \\" \\" + _toDisplayString(constant) + \\" \\" + _toDisplayString(_unref(other)), 1 /* TEXT */)
@ -208,11 +151,11 @@ import { ref } from 'vue'
export default {
expose: [],
setup() {
setup(__props) {
const count = ref(0)
return (_ctx, _cache, $props, $setup, $data, $options) => {
return (_ctx, _cache) => {
return (_openBlock(), _createBlock(_Fragment, null, [
_createVNode(\\"div\\", null, _toDisplayString(_unref(count)), 1 /* TEXT */),
_hoisted_1
@ -228,7 +171,7 @@ exports[`SFC compile <script setup> ref: syntax sugar accessing ref binding 1`]
export default {
expose: [],
setup() {
setup(__props) {
const a = _ref(1)
console.log(a.value)
@ -247,7 +190,7 @@ exports[`SFC compile <script setup> ref: syntax sugar array destructure 1`] = `
export default {
expose: [],
setup() {
setup(__props) {
const n = _ref(1), [__a, __b = 1, ...__c] = useFoo()
const a = _ref(__a);
@ -266,7 +209,7 @@ exports[`SFC compile <script setup> ref: syntax sugar convert ref declarations 1
export default {
expose: [],
setup() {
setup(__props) {
const foo = _ref()
const a = _ref(1)
@ -287,7 +230,7 @@ exports[`SFC compile <script setup> ref: syntax sugar multi ref declarations 1`]
export default {
expose: [],
setup() {
setup(__props) {
const a = _ref(1), b = _ref(2), c = _ref({
count: 0
@ -304,7 +247,7 @@ exports[`SFC compile <script setup> ref: syntax sugar mutating ref binding 1`] =
export default {
expose: [],
setup() {
setup(__props) {
const a = _ref(1)
const b = _ref({ count: 0 })
@ -326,7 +269,7 @@ exports[`SFC compile <script setup> ref: syntax sugar nested destructure 1`] = `
export default {
expose: [],
setup() {
setup(__props) {
const [{ a: { b: __b }}] = useFoo()
const b = _ref(__b);
@ -346,7 +289,7 @@ exports[`SFC compile <script setup> ref: syntax sugar object destructure 1`] = `
export default {
expose: [],
setup() {
setup(__props) {
const n = _ref(1), { a: __a, b: __c, d: __d = 1, e: __f = 2, ...__g } = useFoo()
const a = _ref(__a);
@ -365,7 +308,7 @@ return { n, a, c, d, f, g }
exports[`SFC compile <script setup> ref: syntax sugar should not convert non ref labels 1`] = `
"export default {
expose: [],
setup() {
setup(__props) {
foo: a = 1, b = 2, c = {
count: 0
@ -382,7 +325,7 @@ exports[`SFC compile <script setup> ref: syntax sugar using ref binding in prope
export default {
expose: [],
setup() {
setup(__props) {
const a = _ref(1)
const b = { a: a.value }
@ -401,7 +344,7 @@ exports[`SFC compile <script setup> should expose top level declarations 1`] = `
export default {
expose: [],
setup() {
setup(__props) {
let a = 1
const b = 2
@ -509,7 +452,7 @@ export default _defineComponent({
literalUnionMixed: { type: [String, Number, Boolean], required: true },
intersection: { type: Object, required: true }
} as unknown as undefined,
setup() {
setup(__props) {
@ -526,7 +469,7 @@ export interface Foo {}
export default _defineComponent({
expose: [],
setup() {
setup(__props) {
return { }

View File

@ -0,0 +1,107 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CSS vars injection codegen <script> w/ default export 1`] = `
"const __default__ = { setup() {} }
import { useCssVars as _useCssVars } from 'vue'
const __injectCSSVars__ = () => {
_useCssVars(_ctx => ({
color: (_ctx.color)
}), \\"xxxxxxxx\\")}
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(_ctx => ({
color: (_ctx.color)
}), \\"xxxxxxxx\\")}
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(_ctx => ({
color: (_ctx.color)
}), \\"xxxxxxxx\\")}
const __setup__ = __default__.setup
__default__.setup = __setup__
? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }
: __injectCSSVars__
export default __default__"
`;
exports[`CSS vars injection codegen w/ <script setup> 1`] = `
"import { useCssVars as _useCssVars, unref as _unref } from 'vue'
export default {
expose: [],
setup(__props) {
_useCssVars(_ctx => ({
color: (color)
}), \\"xxxxxxxx\\")
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(_ctx => ({
color: (_ctx.color),
font_size: (_ctx.font.size)
}), \\"xxxxxxxx\\")}
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, unref as _unref } from 'vue'
import { ref } from 'vue'
export default {
expose: [],
props: {
foo: String
},
setup(__props) {
_useCssVars(_ctx => ({
color: (color),
size: (_unref(size)),
foo: (__props.foo)
}), \\"xxxxxxxx\\")
const color = 'red'
const size = ref('10px')
return { color, size, ref }
}
}"
`;

View File

@ -1,25 +1,4 @@
import { parse, SFCScriptCompileOptions, compileScript } from '../src'
import { parse as babelParse } from '@babel/parser'
import { babelParserDefaultPlugins } from '@vue/shared'
function compile(src: string, options?: SFCScriptCompileOptions) {
const { descriptor } = parse(src)
return compileScript(descriptor, options)
}
function assertCode(code: string) {
// parse the generated code to make sure it is valid
try {
babelParse(code, {
sourceType: 'module',
plugins: [...babelParserDefaultPlugins, 'typescript']
})
} catch (e) {
console.log(code)
throw e
}
expect(code).toMatchSnapshot()
}
import { compileSFCScript as compile, assertCode } from './utils'
describe('SFC compile <script setup>', () => {
test('should expose top level declarations', () => {
@ -323,51 +302,10 @@ const { props, emit } = defineOptions({
})
})
describe('CSS vars injection', () => {
test('<script> w/ no default export', () => {
assertCode(
compile(
`<script>const a = 1</script>\n` +
`<style vars="{ color }">div{ color: var(--color); }</style>`
).content
)
})
test('<script> w/ default export', () => {
assertCode(
compile(
`<script>export default { setup() {} }</script>\n` +
`<style vars="{ color }">div{ color: var(--color); }</style>`
).content
)
})
test('<script> w/ default export in strings/comments', () => {
assertCode(
compile(
`<script>
// export default {}
export default {}
</script>\n` +
`<style vars="{ color }">div{ color: var(--color); }</style>`
).content
)
})
test('w/ <script setup>', () => {
assertCode(
compile(
`<script setup>const color = 'red'</script>\n` +
`<style vars="{ color }">div{ color: var(--color); }</style>`
).content
)
})
})
describe('async/await detection', () => {
function assertAwaitDetection(code: string, shouldAsync = true) {
const { content } = compile(`<script setup>${code}</script>`)
expect(content).toMatch(`${shouldAsync ? `async ` : ``}setup()`)
expect(content).toMatch(`${shouldAsync ? `async ` : ``}setup(`)
}
test('expression statement', () => {

View File

@ -9,8 +9,7 @@ import {
} from '../src/compileStyle'
import path from 'path'
describe('SFC scoped CSS', () => {
function compileScoped(
export function compileScoped(
source: string,
options?: Partial<SFCStyleCompileOptions>
): string {
@ -30,6 +29,7 @@ describe('SFC scoped CSS', () => {
return res.code
}
describe('SFC scoped CSS', () => {
test('simple selectors', () => {
expect(compileScoped(`h1 { color: red; }`)).toMatch(
`h1[test] { color: red;`
@ -266,27 +266,6 @@ describe('SFC scoped CSS', () => {
).toHaveBeenWarned()
})
})
describe('<style vars>', () => {
test('should rewrite CSS vars in scoped mode', () => {
const code = compileScoped(
`.foo {
color: var(--color);
font-size: var(--global:font);
}`,
{
id: 'data-v-test',
vars: true
}
)
expect(code).toMatchInlineSnapshot(`
".foo[data-v-test] {
color: var(--test-color);
font-size: var(--font);
}"
`)
})
})
})
describe('SFC CSS modules', () => {

View File

@ -0,0 +1,111 @@
import { compileStyle } from '../src'
import { compileSFCScript, assertCode } from './utils'
describe('CSS vars injection', () => {
describe('codegen', () => {
test('<script> w/ no default export', () => {
assertCode(
compileSFCScript(
`<script>const a = 1</script>\n` +
`<style>div{ color: var(--v-bind:color); }</style>`
).content
)
})
test('<script> w/ default export', () => {
assertCode(
compileSFCScript(
`<script>export default { setup() {} }</script>\n` +
`<style>div{ color: var(--:color); }</style>`
).content
)
})
test('<script> w/ default export in strings/comments', () => {
assertCode(
compileSFCScript(
`<script>
// export default {}
export default {}
</script>\n` + `<style>div{ color: var(--:color); }</style>`
).content
)
})
test('w/ <script setup>', () => {
assertCode(
compileSFCScript(
`<script setup>const color = 'red'</script>\n` +
`<style>div{ color: var(--:color); }</style>`
).content
)
})
})
test('generating correct code for nested paths', () => {
const { content } = compileSFCScript(
`<script>const a = 1</script>\n` +
`<style>div{
color: var(--v-bind:color);
color: var(--v-bind:font.size);
}</style>`
)
expect(content).toMatch(`_useCssVars(_ctx => ({
color: (_ctx.color),
font_size: (_ctx.font.size)
})`)
assertCode(content)
})
test('w/ <script setup> binding analysis', () => {
const { content } = compileSFCScript(
`<script setup>
import { defineOptions, ref } from 'vue'
const color = 'red'
const size = ref('10px')
defineOptions({
props: {
foo: String
}
})
</script>\n` +
`<style>
div {
color: var(--:color);
font-size: var(--:size);
border: var(--:foo);
}
</style>`
)
// should handle:
// 1. local const bindings
// 2. local potential ref bindings
// 3. props bindings (analyzed)
expect(content).toMatch(`_useCssVars(_ctx => ({
color: (color),
size: (_unref(size)),
foo: (__props.foo)
})`)
expect(content).toMatch(
`import { useCssVars as _useCssVars, unref as _unref } from 'vue'`
)
assertCode(content)
})
test('should rewrite CSS vars in scoped mode', () => {
const { code } = compileStyle({
source: `.foo {
color: var(--v-bind:color);
font-size: var(--:font.size);
}`,
filename: 'test.css',
id: 'data-v-test'
})
expect(code).toMatchInlineSnapshot(`
".foo {
color: var(--test-color);
font-size: var(--test-font_size);
}"
`)
})
})

View File

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

View File

@ -1,5 +1,5 @@
import MagicString from 'magic-string'
import { BindingMetadata, BindingTypes } from '@vue/compiler-core'
import { BindingMetadata, BindingTypes, UNREF } from '@vue/compiler-core'
import { SFCDescriptor, SFCScriptBlock } from './parse'
import { parse as _parse, ParserOptions, ParserPlugin } from '@babel/parser'
import { babelParserDefaultPlugins, generateCodeFrame } from '@vue/shared'
@ -26,14 +26,20 @@ import { walk } from 'estree-walker'
import { RawSourceMap } from 'source-map'
import {
CSS_VARS_HELPER,
parseCssVars,
genCssVarsCode,
injectCssVarsCalls
} from './genCssVars'
} from './cssVars'
import { compileTemplate, SFCTemplateCompileOptions } from './compileTemplate'
const DEFINE_OPTIONS = 'defineOptions'
export interface SFCScriptCompileOptions {
/**
* Scope ID for prefixing injected CSS varialbes.
* This must be consistent with the `id` passed to `compileStyle`.
*/
id: string
/**
* https://babeljs.io/docs/en/babel-parser#plugins
*/
@ -52,7 +58,7 @@ export interface SFCScriptCompileOptions {
* from being hot-reloaded separately from component state.
*/
inlineTemplate?: boolean
templateOptions?: SFCTemplateCompileOptions
templateOptions?: Partial<SFCTemplateCompileOptions>
}
const hasWarned: Record<string, boolean> = {}
@ -71,19 +77,33 @@ function warnOnce(msg: string) {
*/
export function compileScript(
sfc: SFCDescriptor,
options: SFCScriptCompileOptions = {}
options: SFCScriptCompileOptions
): SFCScriptBlock {
const { script, scriptSetup, styles, source, filename } = sfc
const { script, scriptSetup, source, filename } = sfc
if (__DEV__ && !__TEST__ && scriptSetup) {
warnOnce(
`<script setup> is still an experimental proposal.\n` +
`Follow its status at https://github.com/vuejs/rfcs/pull/227.`
`Follow its status at https://github.com/vuejs/rfcs/pull/227.\n` +
`It's also recommended to pin your vue dependencies to exact versions ` +
`to avoid breakage.`
)
}
const hasCssVars = styles.some(s => typeof s.attrs.vars === 'string')
// for backwards compat
if (!options) {
options = { id: '' }
}
if (!options.id) {
warnOnce(
`compileScript now requires passing the \`id\` option.\n` +
`Upgrade your vite or vue-loader version for compatibility with ` +
`the latest experimental proposals.`
)
}
const scopeId = options.id ? options.id.replace(/^data-v-/, '') : ''
const cssVars = parseCssVars(sfc)
const scriptLang = script && script.lang
const scriptSetupLang = scriptSetup && scriptSetup.lang
const isTS = scriptLang === 'ts' || scriptSetupLang === 'ts'
@ -104,10 +124,13 @@ export function compileScript(
plugins,
sourceType: 'module'
}).program.body
const bindings = analyzeScriptBindings(scriptAst)
return {
...script,
content: hasCssVars ? injectCssVarsCalls(sfc, plugins) : script.content,
bindings: analyzeScriptBindings(scriptAst),
content: cssVars.length
? injectCssVarsCalls(sfc, cssVars, bindings, scopeId, plugins)
: script.content,
bindings,
scriptAst
}
} catch (e) {
@ -491,7 +514,9 @@ export function compileScript(
warnOnce(
`ref: sugar is still an experimental proposal and is not ` +
`guaranteed to be a part of <script setup>.\n` +
`Follow its status at https://github.com/vuejs/rfcs/pull/228.`
`Follow its status at https://github.com/vuejs/rfcs/pull/228.\n` +
`It's also recommended to pin your vue dependencies to exact versions ` +
`to avoid breakage.`
)
s.overwrite(
node.label.start! + startOffset,
@ -512,10 +537,22 @@ export function compileScript(
if (node.type === 'ImportDeclaration') {
// import declarations are moved to top
s.move(start, end, 0)
// dedupe imports
let prev
let removed = 0
for (const specifier of node.specifiers) {
let prev: Node | undefined, next: Node | undefined
const removeSpecifier = (node: Node) => {
removed++
s.remove(
prev ? prev.end! + startOffset : node.start! + startOffset,
next ? next.start! + startOffset : node.end! + startOffset
)
}
for (let i = 0; i < node.specifiers.length; i++) {
const specifier = node.specifiers[i]
prev = node.specifiers[i - 1]
next = node.specifiers[i + 1]
const local = specifier.local.name
const imported =
specifier.type === 'ImportSpecifier' &&
@ -524,19 +561,11 @@ export function compileScript(
const source = node.source.value
const existing = userImports[local]
if (source === 'vue' && imported === DEFINE_OPTIONS) {
removed++
s.remove(
prev ? prev.end! + startOffset : specifier.start! + startOffset,
specifier.end! + startOffset
)
removeSpecifier(specifier)
} else if (existing) {
if (existing.source === source && existing.imported === imported) {
// already imported in <script setup>, dedupe
removed++
s.remove(
prev ? prev.end! + startOffset : specifier.start! + startOffset,
specifier.end! + startOffset
)
removeSpecifier(specifier)
} else {
error(`different imports aliased to same local name.`, specifier)
}
@ -546,7 +575,6 @@ export function compileScript(
source: node.source.value
}
}
prev = specifier
}
if (removed === node.specifiers.length) {
s.remove(node.start! + startOffset, node.end! + startOffset)
@ -732,7 +760,7 @@ export function compileScript(
}
// 7. finalize setup argument signature.
let args = optionsExp ? `__props, ${optionsExp}` : ``
let args = optionsExp ? `__props, ${optionsExp}` : `__props`
if (optionsExp && optionsType) {
if (slotsType === 'Slots') {
helperImports.add('Slots')
@ -745,26 +773,7 @@ export function compileScript(
}`
}
const allBindings: Record<string, any> = { ...setupBindings }
for (const key in userImports) {
allBindings[key] = true
}
// 8. inject `useCssVars` calls
if (hasCssVars) {
helperImports.add(CSS_VARS_HELPER)
for (const style of styles) {
const vars = style.attrs.vars
if (typeof vars === 'string') {
s.prependRight(
endOffset,
`\n${genCssVarsCode(vars, !!style.scoped, allBindings)}`
)
}
}
}
// 9. analyze binding metadata
// 8. analyze binding metadata
if (scriptAst) {
Object.assign(bindingMetadata, analyzeScriptBindings(scriptAst))
}
@ -785,13 +794,23 @@ export function compileScript(
bindingMetadata[key] = setupBindings[key]
}
// 9. inject `useCssVars` calls
if (cssVars.length) {
helperImports.add(CSS_VARS_HELPER)
helperImports.add('unref')
s.prependRight(
startOffset,
`\n${genCssVarsCode(cssVars, bindingMetadata, scopeId)}\n`
)
}
// 10. generate return statement
let returned
if (options.inlineTemplate) {
if (sfc.template) {
// inline render function mode - we are going to compile the template and
// inline it right here
const { code, preamble, tips, errors } = compileTemplate({
const { code, ast, preamble, tips, errors } = compileTemplate({
...options.templateOptions,
filename,
source: sfc.template.content,
@ -813,12 +832,22 @@ export function compileScript(
if (preamble) {
s.prepend(preamble)
}
// avoid duplicated unref import
// as this may get injected by the render function preamble OR the
// css vars codegen
if (ast && ast.helpers.includes(UNREF)) {
helperImports.delete('unref')
}
returned = code
} else {
returned = `() => {}`
}
} else {
// return bindings from setup
const allBindings: Record<string, any> = { ...setupBindings }
for (const key in userImports) {
allBindings[key] = true
}
returned = `{ ${Object.keys(allBindings).join(', ')} }`
}
s.appendRight(endOffset, `\nreturn ${returned}\n}\n\n`)

View File

@ -7,7 +7,6 @@ import postcss, {
} from 'postcss'
import trimPlugin from './stylePluginTrim'
import scopedPlugin from './stylePluginScoped'
import scopedVarsPlugin from './stylePluginScopedVars'
import {
processors,
StylePreprocessor,
@ -15,6 +14,7 @@ import {
PreprocessLang
} from './stylePreprocessors'
import { RawSourceMap } from 'source-map'
import { cssVarsPlugin } from './cssVars'
export interface SFCStyleCompileOptions {
source: string
@ -22,7 +22,6 @@ export interface SFCStyleCompileOptions {
id: string
map?: RawSourceMap
scoped?: boolean
vars?: boolean
trim?: boolean
preprocessLang?: PreprocessLang
preprocessOptions?: any
@ -82,7 +81,6 @@ export function doCompileStyle(
filename,
id,
scoped = false,
vars = false,
trim = true,
modules = false,
modulesOptions = {},
@ -96,11 +94,7 @@ export function doCompileStyle(
const source = preProcessedSource ? preProcessedSource.code : options.source
const plugins = (postcssPlugins || []).slice()
if (vars && scoped) {
// vars + scoped, only applies to raw source before other transforms
// #1623
plugins.unshift(scopedVarsPlugin(id))
}
plugins.unshift(cssVarsPlugin(id))
if (trim) {
plugins.push(trimPlugin())
}

View File

@ -30,6 +30,7 @@ export interface TemplateCompiler {
export interface SFCTemplateCompileResults {
code: string
ast?: RootNode
preamble?: string
source: string
tips: string[]
@ -169,7 +170,7 @@ function doCompileTemplate({
nodeTransforms = [transformAssetUrl, transformSrcset]
}
let { code, preamble, map } = compiler.compile(source, {
let { code, ast, preamble, map } = compiler.compile(source, {
mode: 'module',
prefixIdentifiers: true,
hoistStatic: true,
@ -193,7 +194,7 @@ function doCompileTemplate({
}
}
return { code, preamble, source, errors, tips: [], map }
return { code, ast, preamble, source, errors, tips: [], map }
}
function mapLines(oldMap: RawSourceMap, newMap: RawSourceMap): RawSourceMap {

View File

@ -4,30 +4,62 @@ import {
createSimpleExpression,
createRoot,
NodeTypes,
SimpleExpressionNode
SimpleExpressionNode,
BindingMetadata
} from '@vue/compiler-dom'
import { SFCDescriptor } from './parse'
import { rewriteDefault } from './rewriteDefault'
import { ParserPlugin } from '@babel/parser'
import postcss, { Root } from 'postcss'
export const CSS_VARS_HELPER = `useCssVars`
export const cssVarRE = /\bvar\(--(?:v-bind)?:([^)]+)\)/g
export function convertCssVarCasing(raw: string): string {
return raw.replace(/([^\w-])/g, '_')
}
export function parseCssVars(sfc: SFCDescriptor): string[] {
const vars: string[] = []
sfc.styles.forEach(style => {
let match
while ((match = cssVarRE.exec(style.content))) {
vars.push(match[1])
}
})
return vars
}
// for compileStyle
export const cssVarsPlugin = postcss.plugin(
'vue-scoped',
(id: any) => (root: Root) => {
const shortId = id.replace(/^data-v-/, '')
root.walkDecls(decl => {
// rewrite CSS variables
if (cssVarRE.test(decl.value)) {
decl.value = decl.value.replace(cssVarRE, (_, $1) => {
return `var(--${shortId}-${convertCssVarCasing($1)})`
})
}
})
}
)
export function genCssVarsCode(
varsExp: string,
scoped: boolean,
knownBindings?: Record<string, any>
vars: string[],
bindings: BindingMetadata,
id: string
) {
const varsExp = `{\n ${vars
.map(v => `${convertCssVarCasing(v)}: (${v})`)
.join(',\n ')}\n}`
const exp = createSimpleExpression(varsExp, false)
const context = createTransformContext(createRoot([]), {
prefixIdentifiers: true
prefixIdentifiers: true,
inline: true,
bindingMetadata: bindings
})
if (knownBindings) {
// when compiling <script setup> we already know what bindings are exposed
// so we can avoid prefixing them from the ctx.
for (const key in knownBindings) {
context.identifiers[key] = 1
}
}
const transformed = processExpression(exp, context)
const transformedString =
transformed.type === NodeTypes.SIMPLE_EXPRESSION
@ -40,15 +72,16 @@ export function genCssVarsCode(
})
.join('')
return `_${CSS_VARS_HELPER}(_ctx => (${transformedString})${
scoped ? `, true` : ``
})`
return `_${CSS_VARS_HELPER}(_ctx => (${transformedString}), "${id}")`
}
// <script setup> already gets the calls injected as part of the transform
// this is only for single normal <script>
export function injectCssVarsCalls(
sfc: SFCDescriptor,
cssVars: string[],
bindings: BindingMetadata,
id: string,
parserPlugins: ParserPlugin[]
): string {
const script = rewriteDefault(
@ -57,18 +90,14 @@ export function injectCssVarsCalls(
parserPlugins
)
let calls = ``
for (const style of sfc.styles) {
const vars = style.attrs.vars
if (typeof vars === 'string') {
calls += genCssVarsCode(vars, !!style.scoped) + '\n'
}
}
return (
script +
`\nimport { ${CSS_VARS_HELPER} as _${CSS_VARS_HELPER} } from 'vue'\n` +
`const __injectCSSVars__ = () => {\n${calls}}\n` +
`const __injectCSSVars__ = () => {\n${genCssVarsCode(
cssVars,
bindings,
id
)}}\n` +
`const __setup__ = __default__.setup\n` +
`__default__.setup = __setup__\n` +
` ? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }\n` +

View File

@ -45,7 +45,6 @@ export interface SFCScriptBlock extends SFCBlock {
export interface SFCStyleBlock extends SFCBlock {
type: 'style'
scoped?: boolean
vars?: string
module?: string | boolean
}
@ -269,8 +268,6 @@ function createBlock(
} else if (type === 'style') {
if (p.name === 'scoped') {
;(block as SFCStyleBlock).scoped = true
} else if (p.name === 'vars' && typeof attrs.vars === 'string') {
;(block as SFCStyleBlock).vars = attrs.vars
} else if (p.name === 'module') {
;(block as SFCStyleBlock).module = attrs[p.name]
}

View File

@ -1,15 +0,0 @@
import postcss, { Root } from 'postcss'
const cssVarRE = /\bvar\(--(global:)?([^)]+)\)/g
export default postcss.plugin('vue-scoped', (id: any) => (root: Root) => {
const shortId = id.replace(/^data-v-/, '')
root.walkDecls(decl => {
// rewrite CSS variables
if (cssVarRE.test(decl.value)) {
decl.value = decl.value.replace(cssVarRE, (_, $1, $2) => {
return $1 ? `var(--${$2})` : `var(--${shortId}-${$2})`
})
}
})
})

View File

@ -10,28 +10,26 @@ import {
} from '@vue/runtime-dom'
describe('useCssVars', () => {
async function assertCssVars(
getApp: (state: any) => ComponentOptions,
scopeId?: string
) {
const id = 'xxxxxx'
async function assertCssVars(getApp: (state: any) => ComponentOptions) {
const state = reactive({ color: 'red' })
const App = getApp(state)
const root = document.createElement('div')
const prefix = scopeId ? `${scopeId.replace(/^data-v-/, '')}-` : ``
render(h(App), root)
await nextTick()
for (const c of [].slice.call(root.children as any)) {
expect(
(c as HTMLElement).style.getPropertyValue(`--${prefix}color`)
).toBe(`red`)
expect((c as HTMLElement).style.getPropertyValue(`--${id}-color`)).toBe(
`red`
)
}
state.color = 'green'
await nextTick()
for (const c of [].slice.call(root.children as any)) {
expect(
(c as HTMLElement).style.getPropertyValue(`--${prefix}color`)
).toBe('green')
expect((c as HTMLElement).style.getPropertyValue(`--${id}-color`)).toBe(
'green'
)
}
}
@ -39,9 +37,12 @@ describe('useCssVars', () => {
await assertCssVars(state => ({
setup() {
// test receiving render context
useCssVars((ctx: any) => ({
useCssVars(
(ctx: any) => ({
color: ctx.color
}))
}),
id
)
return state
},
render() {
@ -53,7 +54,7 @@ describe('useCssVars', () => {
test('on fragment root', async () => {
await assertCssVars(state => ({
setup() {
useCssVars(() => state)
useCssVars(() => state, id)
return () => [h('div'), h('div')]
}
}))
@ -64,7 +65,7 @@ describe('useCssVars', () => {
await assertCssVars(state => ({
setup() {
useCssVars(() => state)
useCssVars(() => state, id)
return () => h(Child)
}
}))
@ -74,15 +75,23 @@ describe('useCssVars', () => {
const state = reactive({ color: 'red' })
const root = document.createElement('div')
let resolveAsync: any
let asyncPromise: any
const AsyncComp = {
async setup() {
return () => h('p', 'default')
setup() {
asyncPromise = new Promise(r => {
resolveAsync = () => {
r(() => h('p', 'default'))
}
})
return asyncPromise
}
}
const App = {
setup() {
useCssVars(() => state)
useCssVars(() => state, id)
return () =>
h(Suspense, null, {
default: h(AsyncComp),
@ -92,39 +101,42 @@ describe('useCssVars', () => {
}
render(h(App), root)
await nextTick()
// css vars use with fallback tree
for (const c of [].slice.call(root.children as any)) {
expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe(`red`)
expect((c as HTMLElement).style.getPropertyValue(`--${id}-color`)).toBe(
`red`
)
}
// AsyncComp resolve
await nextTick()
resolveAsync()
await asyncPromise.then(() => {})
// Suspense effects flush
await nextTick()
// css vars use with default tree
for (const c of [].slice.call(root.children as any)) {
expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe(`red`)
expect((c as HTMLElement).style.getPropertyValue(`--${id}-color`)).toBe(
`red`
)
}
state.color = 'green'
await nextTick()
for (const c of [].slice.call(root.children as any)) {
expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe('green')
expect((c as HTMLElement).style.getPropertyValue(`--${id}-color`)).toBe(
'green'
)
}
})
test('with <style scoped>', async () => {
const id = 'data-v-12345'
await assertCssVars(
state => ({
await assertCssVars(state => ({
__scopeId: id,
setup() {
useCssVars(() => state, true)
useCssVars(() => state, id)
return () => h('div')
}
}),
id
)
}))
})
test('with subTree changed', async () => {
@ -134,21 +146,26 @@ describe('useCssVars', () => {
const App = {
setup() {
useCssVars(() => state)
useCssVars(() => state, id)
return () => (value.value ? [h('div')] : [h('div'), h('div')])
}
}
render(h(App), root)
await nextTick()
// css vars use with fallback tree
for (const c of [].slice.call(root.children as any)) {
expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe(`red`)
expect((c as HTMLElement).style.getPropertyValue(`--${id}-color`)).toBe(
`red`
)
}
value.value = false
await nextTick()
for (const c of [].slice.call(root.children as any)) {
expect((c as HTMLElement).style.getPropertyValue(`--color`)).toBe('red')
expect((c as HTMLElement).style.getPropertyValue(`--${id}-color`)).toBe(
'red'
)
}
})
})

View File

@ -5,15 +5,18 @@ import {
warn,
VNode,
Fragment,
unref,
onUpdated,
watchEffect
} from '@vue/runtime-core'
import { ShapeFlags } from '@vue/shared'
/**
* Runtime helper for SFC's CSS variable injection feature.
* @private
*/
export function useCssVars(
getter: (ctx: ComponentPublicInstance) => Record<string, string>,
scoped = false
scopeId: string
) {
const instance = getCurrentInstance()
/* istanbul ignore next */
@ -23,13 +26,8 @@ export function useCssVars(
return
}
const prefix =
scoped && instance.type.__scopeId
? `${instance.type.__scopeId.replace(/^data-v-/, '')}-`
: ``
const setVars = () =>
setVarsOnVNode(instance.subTree, getter(instance.proxy!), prefix)
setVarsOnVNode(instance.subTree, getter(instance.proxy!), scopeId)
onMounted(() => watchEffect(setVars, { flush: 'post' }))
onUpdated(setVars)
}
@ -37,14 +35,14 @@ export function useCssVars(
function setVarsOnVNode(
vnode: VNode,
vars: Record<string, string>,
prefix: string
scopeId: string
) {
if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
const suspense = vnode.suspense!
vnode = suspense.activeBranch!
if (suspense.pendingBranch && !suspense.isHydrating) {
suspense.effects.push(() => {
setVarsOnVNode(suspense.activeBranch!, vars, prefix)
setVarsOnVNode(suspense.activeBranch!, vars, scopeId)
})
}
}
@ -57,9 +55,9 @@ function setVarsOnVNode(
if (vnode.shapeFlag & ShapeFlags.ELEMENT && vnode.el) {
const style = vnode.el.style
for (const key in vars) {
style.setProperty(`--${prefix}${key}`, unref(vars[key]))
style.setProperty(`--${scopeId}-${key}`, vars[key])
}
} else if (vnode.type === Fragment) {
;(vnode.children as VNode[]).forEach(c => setVarsOnVNode(c, vars, prefix))
;(vnode.children as VNode[]).forEach(c => setVarsOnVNode(c, vars, scopeId))
}
}