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

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