feat(compiler-sfc): new script setup implementation

- now exposes all top level bindings to template
- support `ref:` syntax sugar
This commit is contained in:
Evan You 2020-10-29 15:03:39 -04:00
parent 0935c0501b
commit 556560fae3
5 changed files with 810 additions and 609 deletions

View File

@ -161,9 +161,9 @@ export function processExpression(
if (!isDuplicate(node)) {
const needPrefix = shouldPrefix(node, parent)
if (!knownIds[node.name] && needPrefix) {
if (isPropertyShorthand(node, parent)) {
// property shorthand like { foo }, we need to add the key since we
// rewrite the value
if (isStaticProperty(parent) && parent.shorthand) {
// property shorthand like { foo }, we need to add the key since
// we rewrite the value
node.prefix = `${node.name}: `
}
node.name = prefix(node.name)
@ -278,46 +278,65 @@ const isStaticProperty = (node: Node): node is ObjectProperty =>
(node.type === 'ObjectProperty' || node.type === 'ObjectMethod') &&
!node.computed
const isPropertyShorthand = (node: Node, parent: Node) => {
return (
isStaticProperty(parent) &&
parent.value === node &&
parent.key.type === 'Identifier' &&
parent.key.name === (node as Identifier).name &&
parent.key.start === node.start
)
}
const isStaticPropertyKey = (node: Node, parent: Node) =>
isStaticProperty(parent) && parent.key === node
function shouldPrefix(identifier: Identifier, parent: Node) {
function shouldPrefix(id: Identifier, parent: Node) {
// declaration id
if (
!(
isFunction(parent) &&
// not id of a FunctionDeclaration
((parent as any).id === identifier ||
// not a params of a function
parent.params.includes(identifier))
) &&
// not a key of Property
!isStaticPropertyKey(identifier, parent) &&
// not a property of a MemberExpression
!(
(parent.type === 'MemberExpression' ||
parent.type === 'OptionalMemberExpression') &&
parent.property === identifier &&
!parent.computed
) &&
// not in an Array destructure pattern
!(parent.type === 'ArrayPattern') &&
// skip whitelisted globals
!isGloballyWhitelisted(identifier.name) &&
// special case for webpack compilation
identifier.name !== `require` &&
// is a special keyword but parsed as identifier
identifier.name !== `arguments`
(parent.type === 'VariableDeclarator' ||
parent.type === 'ClassDeclaration') &&
parent.id === id
) {
return true
return false
}
if (isFunction(parent)) {
// function decalration/expression id
if ((parent as any).id === id) {
return false
}
// params list
if (parent.params.includes(id)) {
return false
}
}
// property key
// this also covers object destructure pattern
if (isStaticPropertyKey(id, parent)) {
return false
}
// array destructure pattern
if (parent.type === 'ArrayPattern') {
return false
}
// member expression property
if (
(parent.type === 'MemberExpression' ||
parent.type === 'OptionalMemberExpression') &&
parent.property === id &&
!parent.computed
) {
return false
}
// is a special keyword but parsed as identifier
if (id.name === 'arguments') {
return false
}
// skip whitelisted globals
if (isGloballyWhitelisted(id.name)) {
return false
}
// special case for webpack compilation
if (id.name === 'require') {
return false
}
return true
}

View File

@ -1,8 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SFC compile <script setup> <script setup lang="ts"> extract emits 1`] = `
"import { defineComponent as __define__ } from 'vue'
import { Slots as __Slots__ } from 'vue'
"import { Slots, defineComponent } from 'vue'
declare function __emit__(e: 'foo' | 'bar'): void
declare function __emit__(e: 'baz', id: number): void
@ -16,15 +15,14 @@ export function setup(_: {}, { emit: myEmit }: {
return { }
}
export default __define__({
export default __defineComponent__({
emits: [\\"foo\\", \\"bar\\", \\"baz\\"] as unknown as undefined,
setup
})"
`;
exports[`SFC compile <script setup> <script setup lang="ts"> extract props 1`] = `
"import { defineComponent as __define__ } from 'vue'
import { Slots as __Slots__ } from 'vue'
"import { Slots, defineComponent } from 'vue'
interface Test {}
type Alias = number[]
@ -59,7 +57,7 @@ export function setup(myProps: {
return { }
}
export default __define__({
export default __defineComponent__({
props: {
string: { type: String, required: true },
number: { type: Number, required: true },
@ -88,19 +86,17 @@ export default __define__({
`;
exports[`SFC compile <script setup> <script setup lang="ts"> hoist type declarations 1`] = `
"import { defineComponent as __define__ } from 'vue'
import { Slots as __Slots__ } from 'vue'
"import { Slots, defineComponent } from 'vue'
export interface Foo {}
type Bar = {}
export function setup() {
const a = 1
return { a }
return { }
}
export default __define__({
export default __defineComponent__({
setup
})"
`;
@ -149,7 +145,7 @@ export default __default__"
`;
exports[`SFC compile <script setup> CSS vars injection w/ <script setup> 1`] = `
"import { useCssVars as __useCssVars__ } from 'vue'
"import { useCssVars } from 'vue'
export function setup() {
const color = 'red'
@ -166,26 +162,6 @@ exports[`SFC compile <script setup> errors should allow export default referenci
export function setup() {
return { bar }
}
const __default__ = {
props: {
foo: {
default: () => bar
}
}
}
__default__.setup = setup
export default __default__"
`;
exports[`SFC compile <script setup> errors should allow export default referencing re-exported binding 1`] = `
"import { bar } from './bar'
export function setup() {
return { bar }
}
@ -205,7 +181,7 @@ exports[`SFC compile <script setup> errors should allow export default referenci
const bar = 1
return { }
return { bar }
}
const __default__ = {
@ -228,175 +204,220 @@ return { }
export default { setup }"
`;
exports[`SFC compile <script setup> exports export * from './x' 1`] = `
"import { toRefs as __toRefs__ } from 'vue'
import * as __export_all_0__ from './x'
exports[`SFC compile <script setup> imports dedupe between user & helper 1`] = `
"import { ref } from 'vue'
export function setup() {
const y = 1
return Object.assign(
{ y },
__toRefs__(__export_all_0__)
)
const foo = ref(1)
return { ref, foo }
}
export default { setup }"
`;
exports[`SFC compile <script setup> exports export { x } 1`] = `
"export function setup() {
const x = 1
const y = 2
return { x, y }
}
export default { setup }"
`;
exports[`SFC compile <script setup> exports export { x } from './x' 1`] = `
"import { x, y } from './x'
export function setup() {
return { x, y }
}
export default { setup }"
`;
exports[`SFC compile <script setup> exports export { x as default } 1`] = `
"import x from './x'
export function setup() {
const y = 1
return { y }
}
const __default__ = x
__default__.setup = setup
export default __default__"
`;
exports[`SFC compile <script setup> exports export { x as default } from './x' 1`] = `
"import { x as __default__ } from './x'
import { y } from './x'
export function setup() {
return { y }
}
__default__.setup = setup
export default __default__"
`;
exports[`SFC compile <script setup> exports export class X() {} 1`] = `
"export function setup() {
class X {}
return { X }
}
export default { setup }"
`;
exports[`SFC compile <script setup> exports export const { x } = ... (destructuring) 1`] = `
"export function setup() {
const [a = 1, { b } = { b: 123 }, ...c] = useFoo()
const { d = 2, _: [e], ...f } = useBar()
exports[`SFC compile <script setup> imports import dedupe between <script> and <script setup> 1`] = `
"import { x } from './x'
return { a, b, c, d, e, f }
}
export function setup() {
export default { setup }"
`;
exports[`SFC compile <script setup> exports export const x = ... 1`] = `
"export function setup() {
const x = 1
x()
return { x }
}
export default { setup }"
`;
exports[`SFC compile <script setup> exports export default from './x' 1`] = `
"import __default__ from './x'
exports[`SFC compile <script setup> imports should extract comment for import or type declarations 1`] = `
"import a from 'a' // comment
import b from 'b'
export function setup() {
return { a, b }
}
export default { setup }"
`;
exports[`SFC compile <script setup> imports should hoist and expose imports 1`] = `
"import { ref } from 'vue'
export function setup() {
return { ref }
}
export default { setup }"
`;
exports[`SFC compile <script setup> ref: syntax sugar accessing ref binding 1`] = `
"import { ref } from 'vue'
export function setup() {
const a = ref(1)
console.log(a.value)
function get() {
return a.value + 1
}
return { a, get }
}
export default { setup }"
`;
exports[`SFC compile <script setup> ref: syntax sugar array destructure 1`] = `
"import { ref } from 'vue'
export function setup() {
const n = ref(1), [__a, __b = 1, ...__c] = useFoo()
const a = ref(__a);
const b = ref(__b);
const c = ref(__c);
console.log(n.value, a.value, b.value, c.value)
return { n, a, b, c }
}
export default { setup }"
`;
exports[`SFC compile <script setup> ref: syntax sugar convert ref declarations 1`] = `
"import { ref } from 'vue'
export function setup() {
const a = ref(1)
const b = ref({
count: 0
})
let c = () => {}
let d
return { a, b, c, d }
}
export default { setup }"
`;
exports[`SFC compile <script setup> ref: syntax sugar multi ref declarations 1`] = `
"import { ref } from 'vue'
export function setup() {
const a = ref(1), b = ref(2), c = ref({
count: 0
})
return { a, b, c }
}
export default { setup }"
`;
exports[`SFC compile <script setup> ref: syntax sugar mutating ref binding 1`] = `
"import { ref } from 'vue'
export function setup() {
const a = ref(1)
const b = ref({ count: 0 })
function inc() {
a.value++
a.value = a.value + 1
b.value.count++
b.value.count = b.value.count + 1
}
return { a, b, inc }
}
export default { setup }"
`;
exports[`SFC compile <script setup> ref: syntax sugar nested destructure 1`] = `
"import { ref } from 'vue'
export function setup() {
const [{ a: { b: __b }}] = useFoo()
const b = ref(__b);
const { c: [__d, __e] } = useBar()
const d = ref(__d);
const e = ref(__e);
console.log(b.value, d.value, e.value)
return { b, d, e }
}
export default { setup }"
`;
exports[`SFC compile <script setup> ref: syntax sugar object destructure 1`] = `
"import { ref } from 'vue'
export function setup() {
const n = ref(1), { a: __a, b: __c, d: __d = 1, e: __f = 2, ...__g } = useFoo()
const a = ref(__a);
const c = ref(__c);
const d = ref(__d);
const f = ref(__f);
const g = ref(__g);
console.log(n.value, a.value, c.value, d.value, f.value, g.value)
return { n, a, c, d, f, g }
}
export default { setup }"
`;
exports[`SFC compile <script setup> ref: syntax sugar should not convert non ref labels 1`] = `
"export function setup() {
foo: a = 1, b = 2, c = {
count: 0
}
return { }
}
__default__.setup = setup
export default __default__"
export default { setup }"
`;
exports[`SFC compile <script setup> exports export default in <script setup> 1`] = `
"export function setup() {
exports[`SFC compile <script setup> ref: syntax sugar using ref binding in property shorthand 1`] = `
"import { ref } from 'vue'
const y = 1
return { y }
}
export function setup() {
const __default__ = {
props: ['foo']
}
__default__.setup = setup
export default __default__"
`;
exports[`SFC compile <script setup> exports export function x() {} 1`] = `
"export function setup() {
function x(){}
return { x }
const a = ref(1)
const b = { a: a.value }
function test() {
const { a } = b
}
return { a, b, test }
}
export default { setup }"
`;
exports[`SFC compile <script setup> import dedupe between <script> and <script setup> 1`] = `
exports[`SFC compile <script setup> should expose top level declarations 1`] = `
"import { x } from './x'
export function setup() {
x()
let a = 1
const b = 2
function c() {}
class d {}
return { }
}
export default { setup }"
`;
exports[`SFC compile <script setup> should extract comment for import or type declarations 1`] = `
"import a from 'a' // comment
import b from 'b'
export function setup() {
return { }
}
export default { setup }"
`;
exports[`SFC compile <script setup> should hoist imports 1`] = `
"import { ref } from 'vue'
export function setup() {
return { }
return { x, a, b, c, d }
}
export default { setup }"

View File

@ -22,199 +22,76 @@ function assertCode(code: string) {
}
describe('SFC compile <script setup>', () => {
test('should hoist imports', () => {
assertCode(
compile(`<script setup>import { ref } from 'vue'</script>`).content
)
})
test('should extract comment for import or type declarations', () => {
assertCode(
compile(`<script setup>
import a from 'a' // comment
import b from 'b'
</script>`).content
)
})
test('explicit setup signature', () => {
assertCode(
compile(`<script setup="props, { emit }">emit('foo')</script>`).content
)
})
test('import dedupe between <script> and <script setup>', () => {
test('should expose top level declarations', () => {
const { content } = compile(`
<script>
import { x } from './x'
</script>
<script setup>
import { x } from './x'
x()
let a = 1
const b = 2
function c() {}
class d {}
</script>
`)
assertCode(content)
expect(content.indexOf(`import { x }`)).toEqual(
content.lastIndexOf(`import { x }`)
)
expect(content).toMatch('return { x, a, b, c, d }')
})
describe('exports', () => {
test('export const x = ...', () => {
const { content, bindings } = compile(
`<script setup>export const x = 1</script>`
describe('imports', () => {
test('should hoist and expose imports', () => {
assertCode(
compile(`<script setup>import { ref } from 'vue'</script>`).content
)
assertCode(content)
expect(bindings).toStrictEqual({
x: 'setup'
})
})
test('export const { x } = ... (destructuring)', () => {
const { content, bindings } = compile(`<script setup>
export const [a = 1, { b } = { b: 123 }, ...c] = useFoo()
export const { d = 2, _: [e], ...f } = useBar()
</script>`)
assertCode(content)
expect(bindings).toStrictEqual({
a: 'setup',
b: 'setup',
c: 'setup',
d: 'setup',
e: 'setup',
f: 'setup'
})
test('should extract comment for import or type declarations', () => {
assertCode(
compile(`<script setup>
import a from 'a' // comment
import b from 'b'
</script>`).content
)
})
test('export function x() {}', () => {
const { content, bindings } = compile(
`<script setup>export function x(){}</script>`
)
test('dedupe between user & helper', () => {
const { content } = compile(`<script setup>
import { ref } from 'vue'
ref: foo = 1
</script>`)
assertCode(content)
expect(bindings).toStrictEqual({
x: 'setup'
})
expect(content).toMatch(`import { ref } from 'vue'`)
})
test('export class X() {}', () => {
const { content, bindings } = compile(
`<script setup>export class X {}</script>`
)
test('import dedupe between <script> and <script setup>', () => {
const { content } = compile(`
<script>
import { x } from './x'
</script>
<script setup>
import { x } from './x'
x()
</script>
`)
assertCode(content)
expect(bindings).toStrictEqual({
X: 'setup'
})
})
test('export { x }', () => {
const { content, bindings } = compile(
`<script setup>
const x = 1
const y = 2
export { x, y }
</script>`
expect(content.indexOf(`import { x }`)).toEqual(
content.lastIndexOf(`import { x }`)
)
assertCode(content)
expect(bindings).toStrictEqual({
x: 'setup',
y: 'setup'
})
})
test(`export { x } from './x'`, () => {
const { content, bindings } = compile(
`<script setup>
export { x, y } from './x'
</script>`
)
assertCode(content)
expect(bindings).toStrictEqual({
x: 'setup',
y: 'setup'
})
})
test(`export default from './x'`, () => {
const { content, bindings } = compile(
`<script setup>
export default from './x'
</script>`,
{
babelParserPlugins: ['exportDefaultFrom']
}
)
assertCode(content)
expect(bindings).toStrictEqual({})
})
test(`export { x as default }`, () => {
const { content, bindings } = compile(
`<script setup>
import x from './x'
const y = 1
export { x as default, y }
</script>`
)
assertCode(content)
expect(bindings).toStrictEqual({
y: 'setup'
})
})
test(`export { x as default } from './x'`, () => {
const { content, bindings } = compile(
`<script setup>
export { x as default, y } from './x'
</script>`
)
assertCode(content)
expect(bindings).toStrictEqual({
y: 'setup'
})
})
test(`export * from './x'`, () => {
const { content, bindings } = compile(
`<script setup>
export * from './x'
export const y = 1
</script>`
)
assertCode(content)
expect(bindings).toStrictEqual({
y: 'setup'
// in this case we cannot extract bindings from ./x so it falls back
// to runtime proxy dispatching
})
})
test('export default in <script setup>', () => {
const { content, bindings } = compile(
`<script setup>
export default {
props: ['foo']
}
export const y = 1
</script>`
)
assertCode(content)
expect(bindings).toStrictEqual({
foo: 'props',
y: 'setup'
})
})
})
describe('<script setup lang="ts">', () => {
test('hoist type declarations', () => {
const { content, bindings } = compile(`
const { content } = compile(`
<script setup lang="ts">
export interface Foo {}
type Bar = {}
export const a = 1
</script>`)
assertCode(content)
expect(bindings).toStrictEqual({ a: 'setup' })
})
test('extract props', () => {
@ -333,7 +210,7 @@ import b from 'b'
test('w/ <script setup>', () => {
assertCode(
compile(
`<script setup>export const color = 'red'</script>\n` +
`<script setup>const color = 'red'</script>\n` +
`<style vars="{ color }">div{ color: var(--color); }</style>`
).content
)
@ -356,8 +233,8 @@ import b from 'b'
assertAwaitDetection(`const a = 1 + (await foo)`)
})
test('export', () => {
assertAwaitDetection(`export const a = 1 + (await foo)`)
test('ref', () => {
assertAwaitDetection(`ref: a = 1 + (await foo)`)
})
test('nested statements', () => {
@ -366,7 +243,7 @@ import b from 'b'
test('should ignore await inside functions', () => {
// function declaration
assertAwaitDetection(`export async function foo() { await bar }`, false)
assertAwaitDetection(`async function foo() { await bar }`, false)
// function expression
assertAwaitDetection(`const foo = async () => { await bar }`, false)
// object method
@ -379,6 +256,197 @@ import b from 'b'
})
})
describe('ref: syntax sugar', () => {
test('convert ref declarations', () => {
const { content, bindings } = compile(`<script setup>
ref: a = 1
ref: b = {
count: 0
}
let c = () => {}
let d
</script>`)
expect(content).toMatch(`import { ref } from 'vue'`)
expect(content).not.toMatch(`ref: a`)
expect(content).toMatch(`const a = ref(1)`)
expect(content).toMatch(`
const b = ref({
count: 0
})
`)
// normal declarations left untouched
expect(content).toMatch(`let c = () => {}`)
expect(content).toMatch(`let d`)
assertCode(content)
expect(bindings).toStrictEqual({
a: 'setup',
b: 'setup',
c: 'setup',
d: 'setup'
})
})
test('multi ref declarations', () => {
const { content, bindings } = compile(`<script setup>
ref: a = 1, b = 2, c = {
count: 0
}
</script>`)
expect(content).toMatch(`
const a = ref(1), b = ref(2), c = ref({
count: 0
})
`)
expect(content).toMatch(`return { a, b, c }`)
assertCode(content)
expect(bindings).toStrictEqual({
a: 'setup',
b: 'setup',
c: 'setup'
})
})
test('should not convert non ref labels', () => {
const { content } = compile(`<script setup>
foo: a = 1, b = 2, c = {
count: 0
}
</script>`)
expect(content).toMatch(`foo: a = 1, b = 2`)
assertCode(content)
})
test('accessing ref binding', () => {
const { content } = compile(`<script setup>
ref: a = 1
console.log(a)
function get() {
return a + 1
}
</script>`)
expect(content).toMatch(`console.log(a.value)`)
expect(content).toMatch(`return a.value + 1`)
assertCode(content)
})
test('cases that should not append .value', () => {
const { content } = compile(`<script setup>
ref: a = 1
console.log(b.a)
function get(a) {
return a + 1
}
</script>`)
expect(content).not.toMatch(`a.value`)
})
test('mutating ref binding', () => {
const { content } = compile(`<script setup>
ref: a = 1
ref: b = { count: 0 }
function inc() {
a++
a = a + 1
b.count++
b.count = b.count + 1
}
</script>`)
expect(content).toMatch(`a.value++`)
expect(content).toMatch(`a.value = a.value + 1`)
expect(content).toMatch(`b.value.count++`)
expect(content).toMatch(`b.value.count = b.value.count + 1`)
assertCode(content)
})
test('using ref binding in property shorthand', () => {
const { content } = compile(`<script setup>
ref: a = 1
const b = { a }
function test() {
const { a } = b
}
</script>`)
expect(content).toMatch(`const b = { a: a.value }`)
// should not convert destructure
expect(content).toMatch(`const { a } = b`)
assertCode(content)
})
test('object destructure', () => {
const { content, bindings } = compile(`<script setup>
ref: n = 1, ({ a, b: c, d = 1, e: f = 2, ...g } = useFoo())
console.log(n, a, c, d, f, g)
</script>`)
expect(content).toMatch(
`const n = ref(1), { a: __a, b: __c, d: __d = 1, e: __f = 2, ...__g } = useFoo()`
)
expect(content).toMatch(`\nconst a = ref(__a);`)
expect(content).not.toMatch(`\nconst b = ref(__b);`)
expect(content).toMatch(`\nconst c = ref(__c);`)
expect(content).toMatch(`\nconst d = ref(__d);`)
expect(content).not.toMatch(`\nconst e = ref(__e);`)
expect(content).toMatch(`\nconst f = ref(__f);`)
expect(content).toMatch(`\nconst g = ref(__g);`)
expect(content).toMatch(
`console.log(n.value, a.value, c.value, d.value, f.value, g.value)`
)
expect(content).toMatch(`return { n, a, c, d, f, g }`)
expect(bindings).toStrictEqual({
n: 'setup',
a: 'setup',
c: 'setup',
d: 'setup',
f: 'setup',
g: 'setup'
})
assertCode(content)
})
test('array destructure', () => {
const { content, bindings } = compile(`<script setup>
ref: n = 1, [a, b = 1, ...c] = useFoo()
console.log(n, a, b, c)
</script>`)
expect(content).toMatch(
`const n = ref(1), [__a, __b = 1, ...__c] = useFoo()`
)
expect(content).toMatch(`\nconst a = ref(__a);`)
expect(content).toMatch(`\nconst b = ref(__b);`)
expect(content).toMatch(`\nconst c = ref(__c);`)
expect(content).toMatch(`console.log(n.value, a.value, b.value, c.value)`)
expect(content).toMatch(`return { n, a, b, c }`)
expect(bindings).toStrictEqual({
n: 'setup',
a: 'setup',
b: 'setup',
c: 'setup'
})
assertCode(content)
})
test('nested destructure', () => {
const { content, bindings } = compile(`<script setup>
ref: [{ a: { b }}] = useFoo()
ref: ({ c: [d, e] } = useBar())
console.log(b, d, e)
</script>`)
expect(content).toMatch(`const [{ a: { b: __b }}] = useFoo()`)
expect(content).toMatch(`const { c: [__d, __e] } = useBar()`)
expect(content).not.toMatch(`\nconst a = ref(__a);`)
expect(content).not.toMatch(`\nconst c = ref(__c);`)
expect(content).toMatch(`\nconst b = ref(__b);`)
expect(content).toMatch(`\nconst d = ref(__d);`)
expect(content).toMatch(`\nconst e = ref(__e);`)
expect(content).toMatch(`return { b, d, e }`)
expect(bindings).toStrictEqual({
b: 'setup',
d: 'setup',
e: 'setup'
})
assertCode(content)
})
})
describe('errors', () => {
test('<script> and <script setup> must have same lang', () => {
expect(() =>
@ -386,13 +454,27 @@ import b from 'b'
).toThrow(`<script> and <script setup> must have the same language type`)
})
test('export local as default', () => {
test('non-type named exports', () => {
expect(() =>
compile(`<script setup>
export const a = 1
</script>`)
).toThrow(`cannot contain non-type named exports`)
expect(() =>
compile(`<script setup>
const bar = 1
export { bar as default }
</script>`)
).toThrow(`Cannot export locally defined variable as default`)
).toThrow(`cannot contain non-type named exports`)
})
test('ref: non-assignment expressions', () => {
expect(() =>
compile(`<script setup>
ref: a = 1, foo()
</script>`)
).toThrow(`ref: statements can only contain assignment expressions`)
})
test('export default referencing local var', () => {
@ -410,10 +492,10 @@ import b from 'b'
).toThrow(`cannot reference locally declared variables`)
})
test('export default referencing exports', () => {
test('export default referencing ref declarations', () => {
expect(() =>
compile(`<script setup>
export const bar = 1
ref: bar = 1
export default {
props: bar
}
@ -440,22 +522,6 @@ import b from 'b'
assertCode(
compile(`<script setup>
import { bar } from './bar'
export { bar }
export default {
props: {
foo: {
default: () => bar
}
}
}
</script>`).content
)
})
test('should allow export default referencing re-exported binding', () => {
assertCode(
compile(`<script setup>
export { bar } from './bar'
export default {
props: {
foo: {
@ -479,29 +545,6 @@ import b from 'b'
`)
).toThrow(`Default export is already declared`)
expect(() =>
compile(`
<script>
export default {}
</script>
<script setup>
const x = {}
export { x as default }
</script>
`)
).toThrow(`Default export is already declared`)
expect(() =>
compile(`
<script>
export default {}
</script>
<script setup>
export { x as default } from './y'
</script>
`)
).toThrow(`Default export is already declared`)
expect(() =>
compile(`
<script>

View File

@ -20,7 +20,9 @@ import {
TSDeclareFunction,
ObjectProperty,
ArrayExpression,
Statement
Statement,
Expression,
LabeledStatement
} from '@babel/types'
import { walk } from 'estree-walker'
import { RawSourceMap } from 'source-map'
@ -31,6 +33,7 @@ export interface SFCScriptCompileOptions {
* https://babeljs.io/docs/en/babel-parser#plugins
*/
babelParserPlugins?: ParserPlugin[]
refSugar?: boolean
}
let hasWarned = false
@ -102,38 +105,159 @@ export function compileScript(
}
const defaultTempVar = `__default__`
const bindings: BindingMetadata = {}
const imports: Record<string, string> = {}
const setupScopeVars: Record<string, boolean> = {}
const setupExports: Record<string, boolean> = {}
let exportAllIndex = 0
const bindingMetadata: BindingMetadata = {}
const helperImports: Set<string> = new Set()
const userImports: Record<string, string> = Object.create(null)
const setupBindings: Record<string, boolean> = Object.create(null)
const refBindings: Record<string, boolean> = Object.create(null)
const refIdentifiers: Set<Identifier> = new Set()
const enableRefSugar = options.refSugar !== false
let defaultExport: Node | undefined
let needDefaultExportRefCheck = false
let hasAwait = false
const checkDuplicateDefaultExport = (node: Node) => {
if (defaultExport) {
// <script> already has export default
throw new Error(
`Default export is already declared in normal <script>.\n\n` +
generateCodeFrame(
source,
node.start! + startOffset,
node.start! + startOffset + `export default`.length
)
)
}
}
const s = new MagicString(source)
const startOffset = scriptSetup.loc.start.offset
const endOffset = scriptSetup.loc.end.offset
const scriptStartOffset = script && script.loc.start.offset
const scriptEndOffset = script && script.loc.end.offset
let scriptAst
function error(
msg: string,
node: Node,
end: number = node.end! + startOffset
) {
throw new Error(
msg + `\n\n` + generateCodeFrame(source, node.start! + startOffset, end)
)
}
function processRefExpression(exp: Expression, statement: LabeledStatement) {
if (exp.type === 'AssignmentExpression') {
helperImports.add('ref')
const { left, right } = exp
if (left.type === 'Identifier') {
if (left.name[0] === '$') {
error(`ref variable identifiers cannot start with $.`, left)
}
refBindings[left.name] = setupBindings[left.name] = true
refIdentifiers.add(left)
s.prependRight(right.start! + startOffset, `ref(`)
s.appendLeft(right.end! + startOffset, ')')
} else if (left.type === 'ObjectPattern') {
// remove wrapping parens
for (let i = left.start!; i > 0; i--) {
const char = source[i + startOffset]
if (char === '(') {
s.remove(i + startOffset, i + startOffset + 1)
break
}
}
for (let i = left.end!; i > 0; i++) {
const char = source[i + startOffset]
if (char === ')') {
s.remove(i + startOffset, i + startOffset + 1)
break
}
}
processRefObjectPattern(left, statement)
} else if (left.type === 'ArrayPattern') {
processRefArrayPattern(left, statement)
}
} else if (exp.type === 'SequenceExpression') {
// possible multiple declarations
// ref: x = 1, y = 2
exp.expressions.forEach(e => processRefExpression(e, statement))
} else {
error(`ref: statements can only contain assignment expressions.`, exp)
}
}
function processRefObjectPattern(
pattern: ObjectPattern,
statement: LabeledStatement
) {
for (const p of pattern.properties) {
let nameId: Identifier | undefined
if (p.type === 'ObjectProperty') {
if (p.key.start! === p.value.start!) {
// shorthand { foo } --> { foo: __foo }
nameId = p.key as Identifier
s.appendLeft(nameId.end! + startOffset, `: __${nameId.name}`)
if (p.value.type === 'AssignmentPattern') {
// { foo = 1 }
refIdentifiers.add(p.value.left as Identifier)
}
} else {
if (p.value.type === 'Identifier') {
// { foo: bar } --> { foo: __bar }
nameId = p.value
s.prependRight(nameId.start! + startOffset, `__`)
} else if (p.value.type === 'ObjectPattern') {
processRefObjectPattern(p.value, statement)
} else if (p.value.type === 'ArrayPattern') {
processRefArrayPattern(p.value, statement)
} else if (p.value.type === 'AssignmentPattern') {
// { foo: bar = 1 } --> { foo: __bar = 1 }
nameId = p.value.left as Identifier
s.prependRight(nameId.start! + startOffset, `__`)
}
}
} else {
// rest element { ...foo } --> { ...__foo }
nameId = p.argument as Identifier
s.prependRight(nameId.start! + startOffset, `__`)
}
if (nameId) {
// register binding
refBindings[nameId.name] = setupBindings[nameId.name] = true
refIdentifiers.add(nameId)
// append binding declarations after the parent statement
s.appendLeft(
statement.end! + startOffset,
`\nconst ${nameId.name} = ref(__${nameId.name});`
)
}
}
}
function processRefArrayPattern(
pattern: ArrayPattern,
statement: LabeledStatement
) {
for (const e of pattern.elements) {
if (!e) continue
let nameId: Identifier | undefined
if (e.type === 'Identifier') {
// [a] --> [__a]
nameId = e
} else if (e.type === 'AssignmentPattern') {
// [a = 1] --> [__a = 1]
nameId = e.left as Identifier
} else if (e.type === 'RestElement') {
// [...a] --> [...__a]
nameId = e.argument as Identifier
} else if (e.type === 'ObjectPattern') {
processRefObjectPattern(e, statement)
} else if (e.type === 'ArrayPattern') {
processRefArrayPattern(e, statement)
}
if (nameId) {
s.prependRight(nameId.start! + startOffset, `__`)
// register binding
refBindings[nameId.name] = setupBindings[nameId.name] = true
refIdentifiers.add(nameId)
// append binding declarations after the parent statement
s.appendLeft(
statement.end! + startOffset,
`\nconst ${nameId.name} = ref(__${nameId.name});`
)
}
}
}
// 1. process normal <script> first if it exists
let scriptAst
if (script) {
// import dedupe between <script> and <script setup>
scriptAst = parse(script.content, {
@ -147,7 +271,7 @@ export function compileScript(
for (const {
local: { name }
} of node.specifiers) {
imports[name] = node.source.value
userImports[name] = node.source.value
}
} else if (node.type === 'ExportDefaultDeclaration') {
// export default
@ -280,6 +404,21 @@ export function compileScript(
end++
}
// process `ref: x` bindings (convert to refs)
if (
enableRefSugar &&
node.type === 'LabeledStatement' &&
node.label.name === 'ref' &&
node.body.type === 'ExpressionStatement'
) {
s.overwrite(
node.label.start! + startOffset,
node.body.start! + startOffset,
'const '
)
processRefExpression(node.body.expression, node)
}
if (node.type === 'ImportDeclaration') {
// import declarations are moved to top
s.move(start, end, 0)
@ -287,7 +426,7 @@ export function compileScript(
let prev
let removed = 0
for (const specifier of node.specifiers) {
if (imports[specifier.local.name]) {
if (userImports[specifier.local.name]) {
// already imported in <script setup>, dedupe
removed++
s.remove(
@ -295,7 +434,7 @@ export function compileScript(
specifier.end! + startOffset
)
} else {
imports[specifier.local.name] = node.source.value
userImports[specifier.local.name] = node.source.value
}
prev = specifier
}
@ -305,106 +444,23 @@ export function compileScript(
}
if (node.type === 'ExportNamedDeclaration' && node.exportKind !== 'type') {
// named exports
if (node.declaration) {
// variable/function/class declarations.
// remove leading `export ` keyword
s.remove(start, start + 7)
walkDeclaration(node.declaration, setupExports)
}
if (node.specifiers.length) {
// named export with specifiers
if (node.source) {
// export { x } from './x'
// change it to import and move to top
s.overwrite(start, start + 6, 'import')
s.move(start, end, 0)
} else {
// export { x }
s.remove(start, end)
}
for (const specifier of node.specifiers) {
if (specifier.type === 'ExportDefaultSpecifier') {
// export default from './x'
// rewrite to `import __default__ from './x'`
checkDuplicateDefaultExport(node)
defaultExport = node
s.overwrite(
specifier.exported.start! + startOffset,
specifier.exported.start! + startOffset + 7,
defaultTempVar
)
} else if (
specifier.type === 'ExportSpecifier' &&
specifier.exported.type === 'Identifier'
) {
if (specifier.exported.name === 'default') {
checkDuplicateDefaultExport(node)
defaultExport = node
// 1. remove specifier
if (node.specifiers.length > 1) {
// removing the default specifier from a list of specifiers.
// look ahead until we reach the first non , or whitespace char.
let end = specifier.end! + startOffset
while (end < source.length) {
if (/[^,\s]/.test(source.charAt(end))) {
break
}
end++
}
s.remove(specifier.start! + startOffset, end)
} else {
s.remove(node.start! + startOffset!, node.end! + startOffset!)
}
if (!node.source) {
// export { x as default, ... }
const local = specifier.local.name
if (setupScopeVars[local] || setupExports[local]) {
throw new Error(
`Cannot export locally defined variable as default in <script setup>.\n` +
`Default export must be an object literal with no reference to local scope.\n` +
generateCodeFrame(
source,
specifier.start! + startOffset,
specifier.end! + startOffset
)
)
}
// rewrite to `const __default__ = x` and move to end
s.append(`\nconst ${defaultTempVar} = ${local}\n`)
} else {
// export { x as default } from './x'
// rewrite to `import { x as __default__ } from './x'` and
// add to top
s.prepend(
`import { ${
specifier.local.name
} as ${defaultTempVar} } from '${node.source.value}'\n`
)
}
} else {
setupExports[specifier.exported.name] = true
if (node.source) {
imports[specifier.exported.name] = node.source.value
}
}
}
}
}
// TODO warn
error(`<script setup> cannot contain non-type named exports.`, node)
}
if (node.type === 'ExportAllDeclaration') {
// export * from './x'
s.overwrite(
start,
node.source.start! + startOffset,
`import * as __export_all_${exportAllIndex++}__ from `
)
s.move(start, end, 0)
// TODO warn
}
if (node.type === 'ExportDefaultDeclaration') {
checkDuplicateDefaultExport(node)
if (defaultExport) {
// <script> already has export default
error(
`Default export is already declared in normal <script>.`,
node,
node.start! + startOffset + `export default`.length
)
}
// export default {} inside <script setup>
// this should be kept in module scope - move it to the end
s.move(start, end, source.length)
@ -421,7 +477,7 @@ export function compileScript(
node.type === 'ClassDeclaration') &&
!node.declare
) {
walkDeclaration(node, setupScopeVars)
walkDeclaration(node, setupBindings)
}
// Type declarations
@ -483,9 +539,6 @@ export function compileScript(
// await
if (
node.type === 'VariableDeclaration' ||
(node.type === 'ExportNamedDeclaration' &&
node.declaration &&
node.declaration.type === 'VariableDeclaration') ||
node.type.endsWith('Statement')
) {
;(walk as any)(node, {
@ -501,20 +554,50 @@ export function compileScript(
}
}
// 4. check default export to make sure it doesn't reference setup scope
// variables
if (needDefaultExportRefCheck) {
checkDefaultExport(
defaultExport!,
setupScopeVars,
imports,
setupExports,
source,
startOffset
)
// 4. Do a full walk to rewrite identifiers referencing let exports with ref
// value access
if (enableRefSugar && Object.keys(refBindings).length) {
for (const node of scriptSetupAst) {
if (node.type !== 'ImportDeclaration') {
walkIdentifiers(node, (id, parent) => {
if (refBindings[id.name] && !refIdentifiers.has(id)) {
if (isStaticProperty(parent) && parent.shorthand) {
// let binding used in a property shorthand
// { foo } -> { foo: foo.value }
// skip for destructure patterns
if (!(parent as any).inPattern) {
s.appendLeft(id.end! + startOffset, `: ${id.name}.value`)
}
} else {
s.appendLeft(id.end! + startOffset, '.value')
}
} else if (id.name[0] === '$' && refBindings[id.name.slice(1)]) {
// $xxx raw ref access variables, remove the $ prefix
s.remove(id.start! + startOffset, id.start! + startOffset + 1)
}
})
}
}
}
// 5. remove non-script content
// 5. check default export to make sure it doesn't reference setup scope
// variables
if (needDefaultExportRefCheck) {
walkIdentifiers(defaultExport!, id => {
if (setupBindings[id.name]) {
error(
`\`export default\` in <script setup> cannot reference locally ` +
`declared variables because it will be hoisted outside of the ` +
`setup() function. If your component options requires initialization ` +
`in the module scope, use a separate normal <script> to export ` +
`the options instead.`,
id
)
}
})
}
// 6. remove non-script content
if (script) {
if (startOffset < scriptStartOffset!) {
// <script setup> before <script>
@ -532,11 +615,11 @@ export function compileScript(
s.remove(endOffset, source.length)
}
// 5. finalize setup argument signature.
// 7. finalize setup argument signature.
let args = ``
if (isTS) {
if (slotsType === '__Slots__') {
s.prepend(`import { Slots as __Slots__ } from 'vue'\n`)
helperImports.add('Slots')
}
const ctxType = `{
emit: ${emitType},
@ -560,7 +643,7 @@ export function compileScript(
args = hasExplicitSignature ? (setupValue as string) : ``
}
// 6. wrap setup code with function.
// 8. wrap setup code with function.
// export the content of <script setup> as a named export, `setup`.
// this allows `import { setup } from '*.vue'` for testing purposes.
s.prependLeft(
@ -569,27 +652,18 @@ export function compileScript(
)
// generate return statement
let returned = `{ ${Object.keys(setupExports).join(', ')} }`
// handle `export * from`. We need to call `toRefs` on the imported module
// object before merging.
if (exportAllIndex > 0) {
s.prepend(`import { toRefs as __toRefs__ } from 'vue'\n`)
for (let i = 0; i < exportAllIndex; i++) {
returned += `,\n __toRefs__(__export_all_${i}__)`
}
returned = `Object.assign(\n ${returned}\n)`
}
const exposedBindings = { ...userImports, ...setupBindings }
let returned = `{ ${Object.keys(exposedBindings).join(', ')} }`
// inject `useCssVars` calls
if (hasCssVars) {
s.prepend(`import { useCssVars as __useCssVars__ } from 'vue'\n`)
helperImports.add(`useCssVars`)
for (const style of styles) {
const vars = style.attrs.vars
if (typeof vars === 'string') {
s.prependRight(
endOffset,
`\n${genCssVarsCode(vars, !!style.scoped, setupExports)}`
`\n${genCssVarsCode(vars, !!style.scoped, exposedBindings)}`
)
}
}
@ -597,18 +671,18 @@ export function compileScript(
s.appendRight(endOffset, `\nreturn ${returned}\n}\n\n`)
// 7. finalize default export
// 9. finalize default export
if (isTS) {
// for TS, make sure the exported type is still valid type with
// correct props information
s.prepend(`import { defineComponent as __define__ } from 'vue'\n`)
helperImports.add(`defineComponent`)
// we have to use object spread for types to be merged properly
// user's TS setting should compile it down to proper targets
const def = defaultExport ? `\n ...${defaultTempVar},` : ``
const runtimeProps = genRuntimeProps(typeDeclaredProps)
const runtimeEmits = genRuntimeEmits(typeDeclaredEmits)
s.append(
`export default __define__({${def}${runtimeProps}${runtimeEmits}\n setup\n})`
`export default __defineComponent__({${def}${runtimeProps}${runtimeEmits}\n setup\n})`
)
} else {
if (defaultExport) {
@ -620,22 +694,28 @@ export function compileScript(
}
}
// 8. expose bindings for template compiler optimization
if (scriptAst) {
Object.assign(bindings, analyzeScriptBindings(scriptAst))
// 10. finalize Vue helper imports
const helpers = [...helperImports].filter(i => userImports[i] !== 'vue')
if (helpers.length) {
s.prepend(`import { ${helpers.join(', ')} } from 'vue'\n`)
}
Object.keys(setupExports).forEach(key => {
bindings[key] = 'setup'
// 11. expose bindings for template compiler optimization
if (scriptAst) {
Object.assign(bindingMetadata, analyzeScriptBindings(scriptAst))
}
Object.keys(exposedBindings).forEach(key => {
bindingMetadata[key] = 'setup'
})
Object.keys(typeDeclaredProps).forEach(key => {
bindings[key] = 'props'
bindingMetadata[key] = 'props'
})
Object.assign(bindings, analyzeScriptBindings(scriptSetupAst))
Object.assign(bindingMetadata, analyzeScriptBindings(scriptSetupAst))
s.trim()
return {
...scriptSetup,
bindings,
bindings: bindingMetadata,
content: s.toString(),
map: (s.generateMap({
source: filename,
@ -903,38 +983,21 @@ function genRuntimeEmits(emits: Set<string>) {
}
/**
* export default {} inside `<script setup>` cannot access variables declared
* inside since it's hoisted. Walk and check to make sure.
* Walk an AST and find identifiers that are variable references.
* This is largely the same logic with `transformExpressions` in compiler-core
* but with some subtle differences as this needs to handle a wider range of
* possible syntax.
*/
function checkDefaultExport(
function walkIdentifiers(
root: Node,
scopeVars: Record<string, boolean>,
imports: Record<string, string>,
exports: Record<string, boolean>,
source: string,
offset: number
onIdentifier: (node: Identifier, parent: Node) => void
) {
const knownIds: Record<string, number> = Object.create(null)
;(walk as any)(root, {
enter(node: Node & { scopeIds?: Set<string> }, parent: Node) {
if (node.type === 'Identifier') {
if (
!knownIds[node.name] &&
!isStaticPropertyKey(node, parent) &&
(scopeVars[node.name] || (!imports[node.name] && exports[node.name]))
) {
throw new Error(
`\`export default\` in <script setup> cannot reference locally ` +
`declared variables because it will be hoisted outside of the ` +
`setup() function. If your component options requires initialization ` +
`in the module scope, use a separate normal <script> to export ` +
`the options instead.\n\n` +
generateCodeFrame(
source,
node.start! + offset,
node.end! + offset
)
)
if (!knownIds[node.name] && isRefIdentifier(node, parent)) {
onIdentifier(node, parent)
}
} else if (isFunction(node)) {
// walk function expressions and add its arguments to known identifiers
@ -968,6 +1031,12 @@ function checkDefaultExport(
}
})
)
} else if (
node.type === 'ObjectProperty' &&
parent.type === 'ObjectPattern'
) {
// mark property in destructure pattern
;(node as any).inPattern = true
}
},
leave(node: Node & { scopeIds?: Set<string> }) {
@ -983,15 +1052,64 @@ function checkDefaultExport(
})
}
function isStaticPropertyKey(node: Node, parent: Node): boolean {
return (
parent &&
(parent.type === 'ObjectProperty' || parent.type === 'ObjectMethod') &&
!parent.computed &&
parent.key === node
)
function isRefIdentifier(id: Identifier, parent: Node) {
// declaration id
if (
(parent.type === 'VariableDeclarator' ||
parent.type === 'ClassDeclaration') &&
parent.id === id
) {
return false
}
if (isFunction(parent)) {
// function decalration/expression id
if ((parent as any).id === id) {
return false
}
// params list
if (parent.params.includes(id)) {
return false
}
}
// property key
// this also covers object destructure pattern
if (isStaticPropertyKey(id, parent)) {
return false
}
// array destructure pattern
if (parent.type === 'ArrayPattern') {
return false
}
// member expression property
if (
(parent.type === 'MemberExpression' ||
parent.type === 'OptionalMemberExpression') &&
parent.property === id &&
!parent.computed
) {
return false
}
// is a special keyword but parsed as identifier
if (id.name === 'arguments') {
return false
}
return true
}
const isStaticProperty = (node: Node): node is ObjectProperty =>
node &&
(node.type === 'ObjectProperty' || node.type === 'ObjectMethod') &&
!node.computed
const isStaticPropertyKey = (node: Node, parent: Node) =>
isStaticProperty(parent) && parent.key === node
function isFunction(node: Node): node is FunctionNode {
return /Function(?:Expression|Declaration)$|Method$/.test(node.type)
}

View File

@ -13,7 +13,7 @@ import { ParserPlugin } from '@babel/parser'
export function genCssVarsCode(
varsExp: string,
scoped: boolean,
knownBindings?: Record<string, boolean>
knownBindings?: Record<string, string | boolean>
) {
const exp = createSimpleExpression(varsExp, false)
const context = createTransformContext(createRoot([]), {