Skip to content

Commit

Permalink
feat(compiler-sfc): analyze import usage in template via AST (#9729)
Browse files Browse the repository at this point in the history
close #8897
close nuxt/nuxt#22416
  • Loading branch information
yyx990803 authored and sxzz committed Dec 4, 2023
1 parent 25f90b2 commit 0ba131a
Show file tree
Hide file tree
Showing 12 changed files with 335 additions and 105 deletions.
58 changes: 58 additions & 0 deletions packages/compiler-core/__tests__/parse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '../src/ast'

import { baseParse } from '../src/parser'
import { Program } from '@babel/types'

/* eslint jest/no-disabled-tests: "off" */

Expand Down Expand Up @@ -2170,6 +2171,63 @@ describe('compiler: parse', () => {
})
})

describe('expression parsing', () => {
test('interpolation', () => {
const ast = baseParse(`{{ a + b }}`, { prefixIdentifiers: true })
// @ts-ignore
expect((ast.children[0] as InterpolationNode).content.ast?.type).toBe(
'BinaryExpression'
)
})

test('v-bind', () => {
const ast = baseParse(`<div :[key+1]="foo()" />`, {
prefixIdentifiers: true
})
const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode
// @ts-ignore
expect(dir.arg?.ast?.type).toBe('BinaryExpression')
// @ts-ignore
expect(dir.exp?.ast?.type).toBe('CallExpression')
})

test('v-on multi statements', () => {
const ast = baseParse(`<div @click="a++;b++" />`, {
prefixIdentifiers: true
})
const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode
// @ts-ignore
expect(dir.exp?.ast?.type).toBe('Program')
expect((dir.exp?.ast as Program).body).toMatchObject([
{ type: 'ExpressionStatement' },
{ type: 'ExpressionStatement' }
])
})

test('v-slot', () => {
const ast = baseParse(`<Comp #foo="{ a, b }" />`, {
prefixIdentifiers: true
})
const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode
// @ts-ignore
expect(dir.exp?.ast?.type).toBe('ArrowFunctionExpression')
})

test('v-for', () => {
const ast = baseParse(`<div v-for="({ a, b }, key, index) of a.b" />`, {
prefixIdentifiers: true
})
const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode
const { source, value, key, index } = dir.forParseResult!
// @ts-ignore
expect(source.ast?.type).toBe('MemberExpression')
// @ts-ignore
expect(value?.ast?.type).toBe('ArrowFunctionExpression')
expect(key?.ast).toBeNull() // simple ident
expect(index?.ast).toBeNull() // simple ident
})
})

describe('Errors', () => {
// HTML parsing errors as specified at
// https://html.spec.whatwg.org/multipage/parsing.html#parse-errors
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function parseWithExpressionTransform(
template: string,
options: CompilerOptions = {}
) {
const ast = parse(template)
const ast = parse(template, options)
transform(ast, {
prefixIdentifiers: true,
nodeTransforms: [transformIf, transformExpression],
Expand Down
13 changes: 13 additions & 0 deletions packages/compiler-core/src/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from './runtimeHelpers'
import { PropsExpression } from './transforms/transformElement'
import { ImportItem, TransformContext } from './transform'
import { Node as BabelNode } from '@babel/types'

// Vue template is a platform-agnostic superset of HTML (syntax only).
// More namespaces can be declared by platform specific compilers.
Expand Down Expand Up @@ -233,6 +234,12 @@ export interface SimpleExpressionNode extends Node {
content: string
isStatic: boolean
constType: ConstantTypes
/**
* - `null` means the expression is a simple identifier that doesn't need
* parsing
* - `false` means there was a parsing error
*/
ast?: BabelNode | null | false
/**
* Indicates this is an identifier for a hoist vnode call and points to the
* hoisted node.
Expand All @@ -253,6 +260,12 @@ export interface InterpolationNode extends Node {

export interface CompoundExpressionNode extends Node {
type: NodeTypes.COMPOUND_EXPRESSION
/**
* - `null` means the expression is a simple identifier that doesn't need
* parsing
* - `false` means there was a parsing error
*/
ast?: BabelNode | null | false
children: (
| SimpleExpressionNode
| CompoundExpressionNode
Expand Down
6 changes: 3 additions & 3 deletions packages/compiler-core/src/babelUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ export function walkIdentifiers(
}

const rootExp =
root.type === 'Program' &&
root.body[0].type === 'ExpressionStatement' &&
root.body[0].expression
root.type === 'Program'
? root.body[0].type === 'ExpressionStatement' && root.body[0].expression
: root

walk(root, {
enter(node: Node & { scopeIds?: Set<string> }, parent: Node | undefined) {
Expand Down
11 changes: 11 additions & 0 deletions packages/compiler-core/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@ export interface ParserOptions
* This defaults to `true` in development and `false` in production builds.
*/
comments?: boolean
/**
* Parse JavaScript expressions with Babel.
* @default false
*/
prefixIdentifiers?: boolean
/**
* A list of parser plugins to enable for `@babel/parser`, which is used to
* parse expressions in bindings and interpolations.
* https://babeljs.io/docs/en/next/babel-parser#plugins
*/
expressionPlugins?: ParserPlugin[]
}

export type HoistTransform = (
Expand Down
114 changes: 101 additions & 13 deletions packages/compiler-core/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,25 @@ import {
defaultOnError,
defaultOnWarn
} from './errors'
import { forAliasRE, isCoreComponent, isStaticArgOf } from './utils'
import {
forAliasRE,
isCoreComponent,
isSimpleIdentifier,
isStaticArgOf
} from './utils'
import { decodeHTML } from 'entities/lib/decode.js'
import {
parse,
parseExpression,
type ParserOptions as BabelOptions
} from '@babel/parser'

type OptionalOptions =
| 'decodeEntities'
| 'whitespace'
| 'isNativeTag'
| 'isBuiltInComponent'
| 'expressionPlugins'
| keyof CompilerCompatOptions

export type MergedParserOptions = Omit<
Expand All @@ -64,7 +75,8 @@ export const defaultParserOptions: MergedParserOptions = {
isCustomElement: NO,
onError: defaultOnError,
onWarn: defaultOnWarn,
comments: __DEV__
comments: __DEV__,
prefixIdentifiers: false
}

let currentOptions: MergedParserOptions = defaultParserOptions
Expand Down Expand Up @@ -116,7 +128,7 @@ const tokenizer = new Tokenizer(stack, {
}
addNode({
type: NodeTypes.INTERPOLATION,
content: createSimpleExpression(exp, false, getLoc(innerStart, innerEnd)),
content: createExp(exp, false, getLoc(innerStart, innerEnd)),
loc: getLoc(start, end)
})
},
Expand Down Expand Up @@ -245,7 +257,7 @@ const tokenizer = new Tokenizer(stack, {
setLocEnd((currentProp as AttributeNode).nameLoc, end)
} else {
const isStatic = arg[0] !== `[`
;(currentProp as DirectiveNode).arg = createSimpleExpression(
;(currentProp as DirectiveNode).arg = createExp(
isStatic ? arg : arg.slice(1, -1),
isStatic,
getLoc(start, end),
Expand Down Expand Up @@ -346,10 +358,25 @@ const tokenizer = new Tokenizer(stack, {
}
} else {
// directive
currentProp.exp = createSimpleExpression(
let expParseMode = ExpParseMode.Normal
if (!__BROWSER__) {
if (currentProp.name === 'for') {
expParseMode = ExpParseMode.Skip
} else if (currentProp.name === 'slot') {
expParseMode = ExpParseMode.Params
} else if (
currentProp.name === 'on' &&
currentAttrValue.includes(';')
) {
expParseMode = ExpParseMode.Statements
}
}
currentProp.exp = createExp(
currentAttrValue,
false,
getLoc(currentAttrStartIndex, currentAttrEndIndex)
getLoc(currentAttrStartIndex, currentAttrEndIndex),
ConstantTypes.NOT_CONSTANT,
expParseMode
)
if (currentProp.name === 'for') {
currentProp.forParseResult = parseForExpression(currentProp.exp)
Expand Down Expand Up @@ -477,10 +504,20 @@ function parseForExpression(

const [, LHS, RHS] = inMatch

const createAliasExpression = (content: string, offset: number) => {
const createAliasExpression = (
content: string,
offset: number,
asParam = false
) => {
const start = loc.start.offset + offset
const end = start + content.length
return createSimpleExpression(content, false, getLoc(start, end))
return createExp(
content,
false,
getLoc(start, end),
ConstantTypes.NOT_CONSTANT,
asParam ? ExpParseMode.Params : ExpParseMode.Normal
)
}

const result: ForParseResult = {
Expand All @@ -502,7 +539,7 @@ function parseForExpression(
let keyOffset: number | undefined
if (keyContent) {
keyOffset = exp.indexOf(keyContent, trimmedOffset + valueContent.length)
result.key = createAliasExpression(keyContent, keyOffset)
result.key = createAliasExpression(keyContent, keyOffset, true)
}

if (iteratorMatch[2]) {
Expand All @@ -516,14 +553,15 @@ function parseForExpression(
result.key
? keyOffset! + keyContent.length
: trimmedOffset + valueContent.length
)
),
true
)
}
}
}

if (valueContent) {
result.value = createAliasExpression(valueContent, trimmedOffset)
result.value = createAliasExpression(valueContent, trimmedOffset, true)
}

return result
Expand Down Expand Up @@ -929,8 +967,58 @@ function dirToAttr(dir: DirectiveNode): AttributeNode {
return attr
}

function emitError(code: ErrorCodes, index: number) {
currentOptions.onError(createCompilerError(code, getLoc(index, index)))
enum ExpParseMode {
Normal,
Params,
Statements,
Skip
}

function createExp(
content: SimpleExpressionNode['content'],
isStatic: SimpleExpressionNode['isStatic'] = false,
loc: SourceLocation,
constType: ConstantTypes = ConstantTypes.NOT_CONSTANT,
parseMode = ExpParseMode.Normal
) {
const exp = createSimpleExpression(content, isStatic, loc, constType)
if (
!__BROWSER__ &&
!isStatic &&
currentOptions.prefixIdentifiers &&
parseMode !== ExpParseMode.Skip &&
content.trim()
) {
if (isSimpleIdentifier(content)) {
exp.ast = null // fast path
return exp
}
try {
const plugins = currentOptions.expressionPlugins
const options: BabelOptions = {
plugins: plugins ? [...plugins, 'typescript'] : ['typescript']
}
if (parseMode === ExpParseMode.Statements) {
// v-on with multi-inline-statements, pad 1 char
exp.ast = parse(` ${content} `, options).program
} else if (parseMode === ExpParseMode.Params) {
exp.ast = parseExpression(`(${content})=>{}`, options)
} else {
// normal exp, wrap with parens
exp.ast = parseExpression(`(${content})`, options)
}
} catch (e: any) {
exp.ast = false // indicate an error
emitError(ErrorCodes.X_INVALID_EXPRESSION, loc.start.offset, e.message)
}
}
return exp
}

function emitError(code: ErrorCodes, index: number, message?: string) {
currentOptions.onError(
createCompilerError(code, getLoc(index, index), undefined, message)
)
}

function reset() {
Expand Down
Loading

0 comments on commit 0ba131a

Please sign in to comment.