feat(compiler-vapor): node transform

This commit is contained in:
三咲智子 Kevin Deng 2023-12-01 07:34:18 +08:00
parent 0b765bcea3
commit cfd6d40d72
No known key found for this signature in database
GPG Key ID: 69992F2250DFD93E
14 changed files with 289 additions and 109 deletions

View File

@ -16,6 +16,9 @@ PR are welcome! However, please create an issue before you start to work on it,
- [x] simple bindings - [x] simple bindings
- [x] simple events - [x] simple events
- [ ] TODO-MVC App - [ ] TODO-MVC App
- [ ] transform
- [x] NodeTransform
- [ ] DirectiveTransform
- [ ] directives - [ ] directives
- [x] `v-once` - [x] `v-once`
- [x] `v-html` - [x] `v-html`

View File

@ -85,6 +85,13 @@ export interface Position {
column: number column: number
} }
export type AllNode =
| ParentNode
| ExpressionNode
| TemplateChildNode
| AttributeNode
| DirectiveNode
export type ParentNode = RootNode | ElementNode | IfBranchNode | ForNode export type ParentNode = RootNode | ElementNode | IfBranchNode | ForNode
export type ExpressionNode = SimpleExpressionNode | CompoundExpressionNode export type ExpressionNode = SimpleExpressionNode | CompoundExpressionNode

View File

@ -68,5 +68,6 @@ export { generateCodeFrame } from '@vue/shared'
export { export {
checkCompatEnabled, checkCompatEnabled,
warnDeprecation, warnDeprecation,
CompilerDeprecationTypes CompilerDeprecationTypes,
type CompilerCompatOptions
} from './compat/compatConfig' } from './compat/compatConfig'

View File

@ -224,6 +224,7 @@ export function assert(condition: boolean, msg?: string) {
} }
} }
/** find directive */
export function findDir( export function findDir(
node: ElementNode, node: ElementNode,
name: string | RegExp, name: string | RegExp,

View File

@ -104,16 +104,14 @@ export function render(_ctx) {
`; `;
exports[`compile > directives > v-once > as root node 1`] = ` exports[`compile > directives > v-once > as root node 1`] = `
"import { template, children, effect, setAttr } from 'vue/vapor'; "import { template, children, setAttr } from 'vue/vapor';
const t0 = template('<div></div>'); const t0 = template('<div></div>');
export function render(_ctx) { export function render(_ctx) {
const n0 = t0(); const n0 = t0();
const { const {
0: [n1], 0: [n1],
} = children(n0); } = children(n0);
effect(() => { setAttr(n1, 'id', undefined, foo);
setAttr(n1, 'id', undefined, foo);
});
return n0; return n0;
} }
" "

View File

@ -1,8 +1,12 @@
import { BindingTypes, CompilerOptions, RootNode } from '@vue/compiler-dom' import { type RootNode, BindingTypes } from '@vue/compiler-dom'
import {
type CompilerOptions,
VaporErrorCodes,
compile as _compile,
} from '../src'
// TODO remove it // TODO remove it
import { format } from 'prettier' import { format } from 'prettier'
import { compile as _compile } from '../src'
import { ErrorCodes } from '../src/errors'
async function compile( async function compile(
template: string | RootNode, template: string | RootNode,
@ -78,7 +82,7 @@ describe('compile', () => {
await compile(`<div v-bind:arg />`, { onError }) await compile(`<div v-bind:arg />`, { onError })
expect(onError.mock.calls[0][0]).toMatchObject({ expect(onError.mock.calls[0][0]).toMatchObject({
code: ErrorCodes.VAPOR_BIND_NO_EXPRESSION, code: VaporErrorCodes.X_VAPOR_BIND_NO_EXPRESSION,
loc: { loc: {
start: { start: {
line: 1, line: 1,
@ -107,7 +111,7 @@ describe('compile', () => {
const onError = vi.fn() const onError = vi.fn()
await compile(`<div v-on:click />`, { onError }) await compile(`<div v-on:click />`, { onError })
expect(onError.mock.calls[0][0]).toMatchObject({ expect(onError.mock.calls[0][0]).toMatchObject({
code: ErrorCodes.VAPOR_ON_NO_EXPRESSION, code: VaporErrorCodes.X_VAPOR_ON_NO_EXPRESSION,
loc: { loc: {
start: { start: {
line: 1, line: 1,
@ -170,9 +174,9 @@ describe('compile', () => {
test('basic', async () => { test('basic', async () => {
const code = await compile( const code = await compile(
`<div v-once> `<div v-once>
{{ msg }} {{ msg }}
<span :class="clz" /> <span :class="clz" />
</div>`, </div>`,
{ {
bindingMetadata: { bindingMetadata: {
msg: BindingTypes.SETUP_REF, msg: BindingTypes.SETUP_REF,
@ -183,7 +187,7 @@ describe('compile', () => {
expect(code).matchSnapshot() expect(code).matchSnapshot()
}) })
test.fails('as root node', async () => { test('as root node', async () => {
const code = await compile(`<div :id="foo" v-once />`) const code = await compile(`<div :id="foo" v-once />`)
expect(code).toMatchSnapshot() expect(code).toMatchSnapshot()
expect(code).not.contains('effect') expect(code).not.contains('effect')

View File

@ -3,11 +3,11 @@ import { parse, compileScript } from '@vue/compiler-sfc'
import source from './fixtures/counter.vue?raw' import source from './fixtures/counter.vue?raw'
test('fixtures', async () => { test('fixtures', async () => {
const { descriptor } = parse(source, { compiler: CompilerVapor }) const { descriptor } = parse(source, { compiler: CompilerVapor as any })
const script = compileScript(descriptor, { const script = compileScript(descriptor, {
id: 'counter.vue', id: 'counter.vue',
inlineTemplate: true, inlineTemplate: true,
templateOptions: { compiler: CompilerVapor }, templateOptions: { compiler: CompilerVapor as any },
}) })
expect(script.content).matchSnapshot() expect(script.content).matchSnapshot()
}) })

View File

@ -1,19 +1,86 @@
import { import {
type CodegenResult, type CodegenResult,
type CompilerOptions, type CompilerOptions as BaseCompilerOptions,
type RootNode, type RootNode,
type DirectiveTransform,
parse, parse,
} from '@vue/compiler-dom' } from '@vue/compiler-dom'
import { isString } from '@vue/shared' import { extend, isString } from '@vue/shared'
import { transform } from './transform' import { NodeTransform, transform } from './transform'
import { generate } from './generate' import { generate } from './generate'
import { defaultOnError, createCompilerError, VaporErrorCodes } from './errors'
import { transformOnce } from './transforms/vOnce'
import { HackOptions } from './hack'
export type CompilerOptions = HackOptions<BaseCompilerOptions>
// TODO: copied from @vue/compiler-core
// code/AST -> IR -> JS codegen // code/AST -> IR -> JS codegen
export function compile( export function compile(
template: string | RootNode, source: string | RootNode,
options: CompilerOptions = {}, options: CompilerOptions = {},
): CodegenResult { ): CodegenResult {
const ast = isString(template) ? parse(template, options) : template const onError = options.onError || defaultOnError
const ir = transform(ast, options) const isModuleMode = options.mode === 'module'
return generate(ir, options) /* istanbul ignore if */
if (__BROWSER__) {
if (options.prefixIdentifiers === true) {
onError(createCompilerError(VaporErrorCodes.X_PREFIX_ID_NOT_SUPPORTED))
} else if (isModuleMode) {
onError(createCompilerError(VaporErrorCodes.X_MODULE_MODE_NOT_SUPPORTED))
}
}
const prefixIdentifiers =
!__BROWSER__ && (options.prefixIdentifiers === true || isModuleMode)
// TODO scope id
// if (options.scopeId && !isModuleMode) {
// onError(createCompilerError(ErrorCodes.X_SCOPE_ID_NOT_SUPPORTED))
// }
const ast = isString(source) ? parse(source, options) : source
const [nodeTransforms, directiveTransforms] =
getBaseTransformPreset(prefixIdentifiers)
if (!__BROWSER__ && options.isTS) {
const { expressionPlugins } = options
if (!expressionPlugins || !expressionPlugins.includes('typescript')) {
options.expressionPlugins = [...(expressionPlugins || []), 'typescript']
}
}
const ir = transform(
ast,
extend({}, options, {
prefixIdentifiers,
nodeTransforms: [
...nodeTransforms,
...(options.nodeTransforms || []), // user transforms
],
directiveTransforms: extend(
{},
directiveTransforms,
options.directiveTransforms || {}, // user transforms
),
}),
)
return generate(
ir,
extend({}, options, {
prefixIdentifiers,
}),
)
}
export type TransformPreset = [
NodeTransform[],
Record<string, DirectiveTransform>,
]
export function getBaseTransformPreset(
prefixIdentifiers?: boolean,
): TransformPreset {
return [[transformOnce], {}]
} }

View File

@ -1,7 +1,6 @@
import { CompilerError } from '@vue/compiler-dom' import type { CompilerError } from '@vue/compiler-dom'
export { createCompilerError } from '@vue/compiler-dom' export { createCompilerError } from '@vue/compiler-dom'
export function defaultOnError(error: CompilerError) { export function defaultOnError(error: CompilerError) {
throw error throw error
} }
@ -10,14 +9,21 @@ export function defaultOnWarn(msg: CompilerError) {
__DEV__ && console.warn(`[Vue warn] ${msg.message}`) __DEV__ && console.warn(`[Vue warn] ${msg.message}`)
} }
export enum ErrorCodes { export enum VaporErrorCodes {
// transform errors // transform errors
VAPOR_BIND_NO_EXPRESSION, X_VAPOR_BIND_NO_EXPRESSION,
VAPOR_ON_NO_EXPRESSION, X_VAPOR_ON_NO_EXPRESSION,
// generic errors
X_PREFIX_ID_NOT_SUPPORTED,
X_MODULE_MODE_NOT_SUPPORTED,
} }
export const errorMessages: Record<ErrorCodes, string> = { export const errorMessages: Record<VaporErrorCodes, string> = {
// transform errors // transform errors
[ErrorCodes.VAPOR_BIND_NO_EXPRESSION]: `v-bind is missing expression.`, [VaporErrorCodes.X_VAPOR_BIND_NO_EXPRESSION]: `v-bind is missing expression.`,
[ErrorCodes.VAPOR_ON_NO_EXPRESSION]: `v-on is missing expression.`, [VaporErrorCodes.X_VAPOR_ON_NO_EXPRESSION]: `v-on is missing expression.`,
[VaporErrorCodes.X_PREFIX_ID_NOT_SUPPORTED]: `"prefixIdentifiers" option is not supported in this build of compiler.`,
[VaporErrorCodes.X_MODULE_MODE_NOT_SUPPORTED]: `ES module mode is not supported in this build of compiler.`,
} }

View File

@ -0,0 +1,9 @@
import type { Prettify } from '@vue/shared'
import type { NodeTransform } from './transform'
type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> &
Pick<U, Extract<keyof U, keyof T>>
export type HackOptions<T> = Prettify<
Overwrite<T, { nodeTransforms?: NodeTransform[] }>
>

View File

@ -1,5 +1,6 @@
export { parse } from '@vue/compiler-dom' export { parse } from '@vue/compiler-dom'
export { transform } from './transform' export { transform } from './transform'
export { generate } from './generate' export { generate } from './generate'
export { compile } from './compile' export { compile, type CompilerOptions } from './compile'
export * from './ir' export * from './ir'
export * from './errors'

View File

@ -1,15 +1,17 @@
import { import {
type RootNode, type RootNode,
type Node,
type TemplateChildNode, type TemplateChildNode,
type ElementNode, type ElementNode,
type AttributeNode, type AttributeNode,
type InterpolationNode, type InterpolationNode,
type TransformOptions, type TransformOptions as BaseTransformOptions,
type DirectiveNode, type DirectiveNode,
type ExpressionNode, type ExpressionNode,
type ParentNode,
type AllNode,
NodeTypes, NodeTypes,
BindingTypes, BindingTypes,
CompilerCompatOptions,
} from '@vue/compiler-dom' } from '@vue/compiler-dom'
import { import {
type OperationNode, type OperationNode,
@ -17,25 +19,35 @@ import {
IRNodeTypes, IRNodeTypes,
DynamicInfo, DynamicInfo,
} from './ir' } from './ir'
import { isVoidTag } from '@vue/shared' import { EMPTY_OBJ, NOOP, isArray, isVoidTag } from '@vue/shared'
import { import {
ErrorCodes, VaporErrorCodes,
createCompilerError, createCompilerError,
defaultOnError, defaultOnError,
defaultOnWarn, defaultOnWarn,
} from './errors' } from './errors'
import { HackOptions } from './hack'
export interface TransformContext<T extends Node = Node> { export type NodeTransform = (
node: RootNode | TemplateChildNode,
context: TransformContext,
) => void | (() => void) | (() => void)[]
export type TransformOptions = HackOptions<BaseTransformOptions>
export interface TransformContext<T extends AllNode = AllNode> {
node: T node: T
parent: TransformContext | null parent: TransformContext<ParentNode> | null
root: TransformContext<RootNode> root: TransformContext<RootNode>
index: number index: number
options: TransformOptions options: Required<
Omit<TransformOptions, 'filename' | keyof CompilerCompatOptions>
>
template: string template: string
dynamic: DynamicInfo dynamic: DynamicInfo
once: boolean inVOnce: boolean
reference(): number reference(): number
increaseId(): number increaseId(): number
@ -45,10 +57,11 @@ export interface TransformContext<T extends Node = Node> {
helper(name: string): string helper(name: string): string
} }
// TODO use class for better perf
function createRootContext( function createRootContext(
ir: RootIRNode, ir: RootIRNode,
node: RootNode, node: RootNode,
options: TransformOptions, options: TransformOptions = {},
): TransformContext<RootNode> { ): TransformContext<RootNode> {
let globalId = 0 let globalId = 0
const { effect, operation: operation, helpers, vaporHelpers } = ir const { effect, operation: operation, helpers, vaporHelpers } = ir
@ -58,9 +71,32 @@ function createRootContext(
parent: null, parent: null,
index: 0, index: 0,
root: null!, // set later root: null!, // set later
options, options: {
filename: '',
prefixIdentifiers: false,
hoistStatic: false,
hmr: false,
cacheHandlers: false,
nodeTransforms: [],
directiveTransforms: {},
transformHoist: null,
isBuiltInComponent: NOOP,
isCustomElement: NOOP,
expressionPlugins: [],
scopeId: null,
slotted: true,
ssr: false,
inSSR: false,
ssrCssVars: ``,
bindingMetadata: EMPTY_OBJ,
inline: false,
isTS: false,
onError: defaultOnError,
onWarn: defaultOnWarn,
...options,
},
dynamic: ir.dynamic, dynamic: ir.dynamic,
once: false, inVOnce: false,
increaseId: () => globalId++, increaseId: () => globalId++,
reference() { reference() {
@ -69,7 +105,7 @@ function createRootContext(
return (this.dynamic.id = this.increaseId()) return (this.dynamic.id = this.increaseId())
}, },
registerEffect(expr, operation) { registerEffect(expr, operation) {
if (this.once) { if (this.inVOnce) {
return this.registerOperation(operation) return this.registerOperation(operation)
} }
if (!effect[expr]) effect[expr] = [] if (!effect[expr]) effect[expr] = []
@ -110,7 +146,7 @@ function createRootContext(
function createContext<T extends TemplateChildNode>( function createContext<T extends TemplateChildNode>(
node: T, node: T,
parent: TransformContext, parent: TransformContext<ParentNode>,
index: number, index: number,
): TransformContext<T> { ): TransformContext<T> {
const ctx: TransformContext<T> = { const ctx: TransformContext<T> = {
@ -159,7 +195,7 @@ export function transform(
const ctx = createRootContext(ir, root, options) const ctx = createRootContext(ir, root, options)
// TODO: transform presets, see packages/compiler-core/src/transforms // TODO: transform presets, see packages/compiler-core/src/transforms
transformChildren(ctx, true) transformNode(ctx)
if (ir.template.length === 0) { if (ir.template.length === 0) {
ir.template.push({ ir.template.push({
type: IRNodeTypes.FRAGMENT_FACTORY, type: IRNodeTypes.FRAGMENT_FACTORY,
@ -170,20 +206,108 @@ export function transform(
return ir return ir
} }
function transformChildren( function transformNode(
ctx: TransformContext<RootNode | ElementNode>, context: TransformContext<RootNode | TemplateChildNode>,
root?: boolean,
) { ) {
let { node, index } = context
// apply transform plugins
const { nodeTransforms } = context.options
const exitFns = []
for (const nodeTransform of nodeTransforms) {
// TODO nodeTransform type
const onExit = nodeTransform(node, context as any)
if (onExit) {
if (isArray(onExit)) {
exitFns.push(...onExit)
} else {
exitFns.push(onExit)
}
}
if (!context.node) {
// node was removed
return
} else {
// node may have been replaced
node = context.node
}
}
if (node.type === NodeTypes.ROOT) {
transformChildren(context as TransformContext<RootNode>)
return
}
const parentChildren = context.parent!.node.children
const isFirst = index === 0
const isLast = index === parentChildren.length - 1
switch (node.type) {
case NodeTypes.ELEMENT: {
transformElement(context as TransformContext<ElementNode>)
break
}
case NodeTypes.TEXT: {
context.template += node.content
break
}
case NodeTypes.COMMENT: {
context.template += `<!--${node.content}-->`
break
}
case NodeTypes.INTERPOLATION: {
transformInterpolation(
context as TransformContext<InterpolationNode>,
isFirst,
isLast,
)
break
}
case NodeTypes.TEXT_CALL:
// never
break
default: {
// TODO handle other types
// CompoundExpressionNode
// IfNode
// IfBranchNode
// ForNode
context.template += `[type: ${node.type}]`
}
}
// exit transforms
context.node = node
let i = exitFns.length
while (i--) {
exitFns[i]()
}
}
function transformChildren(ctx: TransformContext<RootNode | ElementNode>) {
const { const {
node: { children }, node: { children },
} = ctx } = ctx
const childrenTemplate: string[] = [] const childrenTemplate: string[] = []
children.forEach((child, i) => walkNode(child, i)) children.forEach((child, index) => {
const childContext = createContext(child, ctx, index)
transformNode(childContext)
childrenTemplate.push(childContext.template)
if (
childContext.dynamic.ghost ||
childContext.dynamic.referenced ||
childContext.dynamic.placeholder ||
Object.keys(childContext.dynamic.children).length
) {
ctx.dynamic.children[index] = childContext.dynamic
}
})
processDynamicChildren() processDynamicChildren()
ctx.template += childrenTemplate.join('') ctx.template += childrenTemplate.join('')
if (root) ctx.registerTemplate() if (ctx.node.type === NodeTypes.ROOT) ctx.registerTemplate()
function processDynamicChildren() { function processDynamicChildren() {
let prevChildren: DynamicInfo[] = [] let prevChildren: DynamicInfo[] = []
@ -229,57 +353,6 @@ function transformChildren(
} }
} }
} }
function walkNode(node: TemplateChildNode, index: number) {
const child = createContext(node, ctx, index)
const isFirst = index === 0
const isLast = index === children.length - 1
switch (node.type) {
case NodeTypes.ELEMENT: {
transformElement(child as TransformContext<ElementNode>)
break
}
case NodeTypes.TEXT: {
child.template += node.content
break
}
case NodeTypes.COMMENT: {
child.template += `<!--${node.content}-->`
break
}
case NodeTypes.INTERPOLATION: {
transformInterpolation(
child as TransformContext<InterpolationNode>,
isFirst,
isLast,
)
break
}
case NodeTypes.TEXT_CALL:
// never?
break
default: {
// TODO handle other types
// CompoundExpressionNode
// IfNode
// IfBranchNode
// ForNode
child.template += `[type: ${node.type}]`
}
}
childrenTemplate.push(child.template)
if (
child.dynamic.ghost ||
child.dynamic.referenced ||
child.dynamic.placeholder ||
Object.keys(child.dynamic.children).length
) {
ctx.dynamic.children[index] = child.dynamic
}
}
} }
function transformElement(ctx: TransformContext<ElementNode>) { function transformElement(ctx: TransformContext<ElementNode>) {
@ -365,7 +438,7 @@ function transformProp(
(exp.type === NodeTypes.SIMPLE_EXPRESSION && !exp.content.trim()) (exp.type === NodeTypes.SIMPLE_EXPRESSION && !exp.content.trim())
) { ) {
ctx.options.onError!( ctx.options.onError!(
createCompilerError(ErrorCodes.VAPOR_BIND_NO_EXPRESSION, loc), createCompilerError(VaporErrorCodes.X_VAPOR_BIND_NO_EXPRESSION, loc),
) )
return return
} }
@ -394,7 +467,7 @@ function transformProp(
case 'on': { case 'on': {
if (!exp && !modifiers.length) { if (!exp && !modifiers.length) {
ctx.options.onError!( ctx.options.onError!(
createCompilerError(ErrorCodes.VAPOR_ON_NO_EXPRESSION, loc), createCompilerError(VaporErrorCodes.X_VAPOR_ON_NO_EXPRESSION, loc),
) )
return return
} }
@ -441,10 +514,6 @@ function transformProp(
}) })
break break
} }
case 'once': {
ctx.once = true
break
}
case 'cloak': { case 'cloak': {
// do nothing // do nothing
break break

View File

@ -0,0 +1,14 @@
import { NodeTypes, findDir } from '@vue/compiler-dom'
import { NodeTransform } from '../transform'
const seen = new WeakSet()
export const transformOnce: NodeTransform = (node, context) => {
if (node.type === NodeTypes.ELEMENT && findDir(node, 'once', true)) {
if (seen.has(node) || context.inVOnce /* || context.inSSR */) {
return
}
seen.add(node)
context.inVOnce = true
}
}

View File

@ -13,7 +13,7 @@ export default defineConfig({
plugins: [ plugins: [
Vue({ Vue({
template: { template: {
compiler: CompilerVapor compiler: CompilerVapor as any
}, },
compiler: CompilerSFC compiler: CompilerSFC
}), }),