feat(compiler): optimize text by merging adjacent nodes

This commit is contained in:
Evan You 2019-09-30 14:51:55 -04:00
parent 7ee07447c5
commit e5e40e1e38
11 changed files with 233 additions and 44 deletions

View File

@ -44,7 +44,7 @@ exports[`compiler: codegen compound expression 1`] = `
" "
return function render() { return function render() {
with (this) { with (this) {
return _toString(_ctx.foo) return _ctx.foo + _toString(bar)
} }
}" }"
`; `;

View File

@ -226,17 +226,23 @@ describe('compiler: codegen', () => {
const { code } = generate( const { code } = generate(
createRoot({ createRoot({
children: [ children: [
createInterpolation( createCompoundExpression(
createCompoundExpression( [
[`_ctx.`, createSimpleExpression(`foo`, false, mockLoc)], `_ctx.`,
mockLoc createSimpleExpression(`foo`, false, mockLoc),
), ` + `,
{
type: NodeTypes.INTERPOLATION,
loc: mockLoc,
content: createSimpleExpression(`bar`, false, mockLoc)
}
],
mockLoc mockLoc
) )
] ]
}) })
) )
expect(code).toMatch(`return _${TO_STRING}(_ctx.foo)`) expect(code).toMatch(`return _ctx.foo + _${TO_STRING}(bar)`)
expect(code).toMatchSnapshot() expect(code).toMatchSnapshot()
}) })

View File

@ -25,22 +25,29 @@ describe('compiler: transform', () => {
}) })
const div = ast.children[0] as ElementNode const div = ast.children[0] as ElementNode
expect(calls.length).toBe(3) expect(calls.length).toBe(4)
expect(calls[0]).toMatchObject([ expect(calls[0]).toMatchObject([
ast,
{
parent: null,
currentNode: ast
}
])
expect(calls[1]).toMatchObject([
div, div,
{ {
parent: ast, parent: ast,
currentNode: div currentNode: div
} }
]) ])
expect(calls[1]).toMatchObject([ expect(calls[2]).toMatchObject([
div.children[0], div.children[0],
{ {
parent: div, parent: div,
currentNode: div.children[0] currentNode: div.children[0]
} }
]) ])
expect(calls[2]).toMatchObject([ expect(calls[3]).toMatchObject([
div.children[1], div.children[1],
{ {
parent: div, parent: div,
@ -76,11 +83,11 @@ describe('compiler: transform', () => {
expect(ast.children.length).toBe(2) expect(ast.children.length).toBe(2)
const newElement = ast.children[0] as ElementNode const newElement = ast.children[0] as ElementNode
expect(newElement.tag).toBe('p') expect(newElement.tag).toBe('p')
expect(spy).toHaveBeenCalledTimes(3) expect(spy).toHaveBeenCalledTimes(4)
// should traverse the children of replaced node // should traverse the children of replaced node
expect(spy.mock.calls[1][0]).toBe(newElement.children[0]) expect(spy.mock.calls[2][0]).toBe(newElement.children[0])
// should traverse the node after the replaced node // should traverse the node after the replaced node
expect(spy.mock.calls[2][0]).toBe(ast.children[1]) expect(spy.mock.calls[3][0]).toBe(ast.children[1])
}) })
test('context.removeNode', () => { test('context.removeNode', () => {
@ -103,10 +110,10 @@ describe('compiler: transform', () => {
expect(ast.children[1]).toBe(c2) expect(ast.children[1]).toBe(c2)
// should not traverse children of remove node // should not traverse children of remove node
expect(spy).toHaveBeenCalledTimes(3) expect(spy).toHaveBeenCalledTimes(4)
// should traverse nodes around removed // should traverse nodes around removed
expect(spy.mock.calls[0][0]).toBe(c1) expect(spy.mock.calls[1][0]).toBe(c1)
expect(spy.mock.calls[2][0]).toBe(c2) expect(spy.mock.calls[3][0]).toBe(c2)
}) })
test('context.removeNode (prev sibling)', () => { test('context.removeNode (prev sibling)', () => {
@ -118,7 +125,7 @@ describe('compiler: transform', () => {
if (node.type === NodeTypes.ELEMENT && node.tag === 'div') { if (node.type === NodeTypes.ELEMENT && node.tag === 'div') {
context.removeNode() context.removeNode()
// remove previous sibling // remove previous sibling
context.removeNode(context.parent.children[0]) context.removeNode(context.parent!.children[0])
} }
} }
const spy = jest.fn(plugin) const spy = jest.fn(plugin)
@ -129,11 +136,11 @@ describe('compiler: transform', () => {
expect(ast.children.length).toBe(1) expect(ast.children.length).toBe(1)
expect(ast.children[0]).toBe(c2) expect(ast.children[0]).toBe(c2)
expect(spy).toHaveBeenCalledTimes(3) expect(spy).toHaveBeenCalledTimes(4)
// should still traverse first span before removal // should still traverse first span before removal
expect(spy.mock.calls[0][0]).toBe(c1) expect(spy.mock.calls[1][0]).toBe(c1)
// should still traverse last span // should still traverse last span
expect(spy.mock.calls[2][0]).toBe(c2) expect(spy.mock.calls[3][0]).toBe(c2)
}) })
test('context.removeNode (next sibling)', () => { test('context.removeNode (next sibling)', () => {
@ -145,7 +152,7 @@ describe('compiler: transform', () => {
if (node.type === NodeTypes.ELEMENT && node.tag === 'div') { if (node.type === NodeTypes.ELEMENT && node.tag === 'div') {
context.removeNode() context.removeNode()
// remove next sibling // remove next sibling
context.removeNode(context.parent.children[1]) context.removeNode(context.parent!.children[1])
} }
} }
const spy = jest.fn(plugin) const spy = jest.fn(plugin)
@ -156,20 +163,22 @@ describe('compiler: transform', () => {
expect(ast.children.length).toBe(1) expect(ast.children.length).toBe(1)
expect(ast.children[0]).toBe(c1) expect(ast.children[0]).toBe(c1)
expect(spy).toHaveBeenCalledTimes(2) expect(spy).toHaveBeenCalledTimes(3)
// should still traverse first span before removal // should still traverse first span before removal
expect(spy.mock.calls[0][0]).toBe(c1) expect(spy.mock.calls[1][0]).toBe(c1)
// should not traverse last span // should not traverse last span
expect(spy.mock.calls[1][0]).toBe(d1) expect(spy.mock.calls[2][0]).toBe(d1)
}) })
test('context.hoist', () => { test('context.hoist', () => {
const ast = parse(`<div :id="foo"/><div :id="bar"/>`) const ast = parse(`<div :id="foo"/><div :id="bar"/>`)
const hoisted: ExpressionNode[] = [] const hoisted: ExpressionNode[] = []
const mock: NodeTransform = (node, context) => { const mock: NodeTransform = (node, context) => {
const dir = (node as ElementNode).props[0] as DirectiveNode if (node.type === NodeTypes.ELEMENT) {
hoisted.push(dir.exp!) const dir = node.props[0] as DirectiveNode
dir.exp = context.hoist(dir.exp!) hoisted.push(dir.exp!)
dir.exp = context.hoist(dir.exp!)
}
} }
transform(ast, { transform(ast, {
nodeTransforms: [mock] nodeTransforms: [mock]

View File

@ -0,0 +1,112 @@
import { CompilerOptions, parse, transform, NodeTypes } from '../../src'
import { optimizeText } from '../../src/transforms/optimizeText'
import { transformExpression } from '../../src/transforms/transformExpression'
function transformWithTextOpt(template: string, options: CompilerOptions = {}) {
const ast = parse(template)
transform(ast, {
nodeTransforms: [
...(options.prefixIdentifiers ? [transformExpression] : []),
optimizeText
],
...options
})
return ast
}
describe('compiler: optimize interpolation', () => {
test('no consecutive text', () => {
const root = transformWithTextOpt(`{{ foo }}`)
expect(root.children[0]).toMatchObject({
type: NodeTypes.INTERPOLATION,
content: {
content: `foo`
}
})
})
test('consecutive text', () => {
const root = transformWithTextOpt(`{{ foo }} bar {{ baz }}`)
expect(root.children.length).toBe(1)
expect(root.children[0]).toMatchObject({
type: NodeTypes.COMPOUND_EXPRESSION,
children: [
{ type: NodeTypes.INTERPOLATION, content: { content: `foo` } },
` + `,
{ type: NodeTypes.TEXT, content: ` bar ` },
` + `,
{ type: NodeTypes.INTERPOLATION, content: { content: `baz` } }
]
})
})
test('consecutive text between elements', () => {
const root = transformWithTextOpt(`<div/>{{ foo }} bar {{ baz }}<div/>`)
expect(root.children.length).toBe(3)
expect(root.children[0].type).toBe(NodeTypes.ELEMENT)
expect(root.children[1]).toMatchObject({
type: NodeTypes.COMPOUND_EXPRESSION,
children: [
{ type: NodeTypes.INTERPOLATION, content: { content: `foo` } },
` + `,
{ type: NodeTypes.TEXT, content: ` bar ` },
` + `,
{ type: NodeTypes.INTERPOLATION, content: { content: `baz` } }
]
})
expect(root.children[2].type).toBe(NodeTypes.ELEMENT)
})
test('consecutive text mixed with elements', () => {
const root = transformWithTextOpt(
`<div/>{{ foo }} bar {{ baz }}<div/>{{ foo }} bar {{ baz }}<div/>`
)
expect(root.children.length).toBe(5)
expect(root.children[0].type).toBe(NodeTypes.ELEMENT)
expect(root.children[1]).toMatchObject({
type: NodeTypes.COMPOUND_EXPRESSION,
children: [
{ type: NodeTypes.INTERPOLATION, content: { content: `foo` } },
` + `,
{ type: NodeTypes.TEXT, content: ` bar ` },
` + `,
{ type: NodeTypes.INTERPOLATION, content: { content: `baz` } }
]
})
expect(root.children[2].type).toBe(NodeTypes.ELEMENT)
expect(root.children[3]).toMatchObject({
type: NodeTypes.COMPOUND_EXPRESSION,
children: [
{ type: NodeTypes.INTERPOLATION, content: { content: `foo` } },
` + `,
{ type: NodeTypes.TEXT, content: ` bar ` },
` + `,
{ type: NodeTypes.INTERPOLATION, content: { content: `baz` } }
]
})
expect(root.children[4].type).toBe(NodeTypes.ELEMENT)
})
test('with prefixIdentifiers: true', () => {
const root = transformWithTextOpt(`{{ foo }} bar {{ baz + qux }}`, {
prefixIdentifiers: true
})
expect(root.children.length).toBe(1)
expect(root.children[0]).toMatchObject({
type: NodeTypes.COMPOUND_EXPRESSION,
children: [
{ type: NodeTypes.INTERPOLATION, content: { content: `_ctx.foo` } },
` + `,
{ type: NodeTypes.TEXT, content: ` bar ` },
` + `,
{
type: NodeTypes.INTERPOLATION,
content: {
type: NodeTypes.COMPOUND_EXPRESSION,
children: [{ content: `_ctx.baz` }, ` + `, { content: `_ctx.qux` }]
}
}
]
})
})
})

View File

@ -64,6 +64,7 @@ export type ExpressionNode = SimpleExpressionNode | CompoundExpressionNode
export type ChildNode = export type ChildNode =
| ElementNode | ElementNode
| InterpolationNode | InterpolationNode
| CompoundExpressionNode
| TextNode | TextNode
| CommentNode | CommentNode
| IfNode | IfNode
@ -130,7 +131,7 @@ export interface InterpolationNode extends Node {
// always dynamic // always dynamic
export interface CompoundExpressionNode extends Node { export interface CompoundExpressionNode extends Node {
type: NodeTypes.COMPOUND_EXPRESSION type: NodeTypes.COMPOUND_EXPRESSION
children: (SimpleExpressionNode | string)[] children: (SimpleExpressionNode | InterpolationNode | TextNode | string)[]
// an expression parsed as the params of a function will track // an expression parsed as the params of a function will track
// the identifiers declared inside the function body. // the identifiers declared inside the function body.
identifiers?: string[] identifiers?: string[]

View File

@ -283,6 +283,7 @@ function genChildren(
(allowSingle || (allowSingle ||
type === NodeTypes.TEXT || type === NodeTypes.TEXT ||
type === NodeTypes.INTERPOLATION || type === NodeTypes.INTERPOLATION ||
type === NodeTypes.COMPOUND_EXPRESSION ||
(type === NodeTypes.ELEMENT && (type === NodeTypes.ELEMENT &&
(child as ElementNode).tagType === ElementTypes.SLOT)) (child as ElementNode).tagType === ElementTypes.SLOT))
) { ) {
@ -423,7 +424,7 @@ function genCompoundExpression(
if (isString(child)) { if (isString(child)) {
context.push(child) context.push(child)
} else { } else {
genExpression(child, context) genNode(child, context)
} }
} }
} }

View File

@ -13,6 +13,7 @@ import { transformOn } from './transforms/vOn'
import { transformBind } from './transforms/vBind' import { transformBind } from './transforms/vBind'
import { defaultOnError, createCompilerError, ErrorCodes } from './errors' import { defaultOnError, createCompilerError, ErrorCodes } from './errors'
import { trackSlotScopes } from './transforms/vSlot' import { trackSlotScopes } from './transforms/vSlot'
import { optimizeText } from './transforms/optimizeText'
export type CompilerOptions = ParserOptions & TransformOptions & CodegenOptions export type CompilerOptions = ParserOptions & TransformOptions & CodegenOptions
@ -45,6 +46,7 @@ export function baseCompile(
transformIf, transformIf,
transformFor, transformFor,
...(prefixIdentifiers ? [transformExpression, trackSlotScopes] : []), ...(prefixIdentifiers ? [transformExpression, trackSlotScopes] : []),
optimizeText,
transformStyle, transformStyle,
transformSlotOutlet, transformSlotOutlet,
transformElement, transformElement,

View File

@ -20,7 +20,7 @@ import { TO_STRING, COMMENT, CREATE_VNODE } from './runtimeConstants'
// Transforms that operate directly on a ChildNode. NodeTransforms may mutate, // Transforms that operate directly on a ChildNode. NodeTransforms may mutate,
// replace or remove the node being processed. // replace or remove the node being processed.
export type NodeTransform = ( export type NodeTransform = (
node: ChildNode, node: RootNode | ChildNode,
context: TransformContext context: TransformContext
) => void | (() => void) | (() => void)[] ) => void | (() => void) | (() => void)[]
@ -56,9 +56,9 @@ export interface TransformContext extends Required<TransformOptions> {
statements: Set<string> statements: Set<string>
hoists: JSChildNode[] hoists: JSChildNode[]
identifiers: { [name: string]: number | undefined } identifiers: { [name: string]: number | undefined }
parent: ParentNode parent: ParentNode | null
childIndex: number childIndex: number
currentNode: ChildNode | null currentNode: RootNode | ChildNode | null
helper(name: string): string helper(name: string): string
replaceNode(node: ChildNode): void replaceNode(node: ChildNode): void
removeNode(node?: ChildNode): void removeNode(node?: ChildNode): void
@ -87,22 +87,30 @@ function createTransformContext(
nodeTransforms, nodeTransforms,
directiveTransforms, directiveTransforms,
onError, onError,
parent: root, parent: null,
currentNode: root,
childIndex: 0, childIndex: 0,
currentNode: null,
helper(name) { helper(name) {
context.imports.add(name) context.imports.add(name)
return prefixIdentifiers ? name : `_${name}` return prefixIdentifiers ? name : `_${name}`
}, },
replaceNode(node) { replaceNode(node) {
/* istanbul ignore if */ /* istanbul ignore if */
if (__DEV__ && !context.currentNode) { if (__DEV__) {
throw new Error(`node being replaced is already removed.`) if (!context.currentNode) {
throw new Error(`Node being replaced is already removed.`)
}
if (!context.parent) {
throw new Error(`Cannot replace root node.`)
}
} }
context.parent.children[context.childIndex] = context.currentNode = node context.parent!.children[context.childIndex] = context.currentNode = node
}, },
removeNode(node) { removeNode(node) {
const list = context.parent.children if (__DEV__ && !context.parent) {
throw new Error(`Cannot remove root node.`)
}
const list = context.parent!.children
const removalIndex = node const removalIndex = node
? list.indexOf(node as any) ? list.indexOf(node as any)
: context.currentNode : context.currentNode
@ -123,7 +131,7 @@ function createTransformContext(
context.onNodeRemoved() context.onNodeRemoved()
} }
} }
context.parent.children.splice(removalIndex, 1) context.parent!.children.splice(removalIndex, 1)
}, },
onNodeRemoved: () => {}, onNodeRemoved: () => {},
addIdentifiers(exp) { addIdentifiers(exp) {
@ -172,7 +180,7 @@ function createTransformContext(
export function transform(root: RootNode, options: TransformOptions) { export function transform(root: RootNode, options: TransformOptions) {
const context = createTransformContext(root, options) const context = createTransformContext(root, options)
traverseChildren(root, context) traverseNode(root, context)
root.imports = [...context.imports] root.imports = [...context.imports]
root.statements = [...context.statements] root.statements = [...context.statements]
root.hoists = context.hoists root.hoists = context.hoists
@ -197,7 +205,10 @@ export function traverseChildren(
} }
} }
export function traverseNode(node: ChildNode, context: TransformContext) { export function traverseNode(
node: RootNode | ChildNode,
context: TransformContext
) {
// apply transform plugins // apply transform plugins
const { nodeTransforms } = context const { nodeTransforms } = context
const exitFns = [] const exitFns = []
@ -240,6 +251,7 @@ export function traverseNode(node: ChildNode, context: TransformContext) {
break break
case NodeTypes.FOR: case NodeTypes.FOR:
case NodeTypes.ELEMENT: case NodeTypes.ELEMENT:
case NodeTypes.ROOT:
traverseChildren(node, context) traverseChildren(node, context)
break break
} }

View File

@ -1,2 +0,0 @@
// TODO merge adjacent text nodes and expressions into a single expression
// e.g. <div>abc {{ d }} {{ e }}</div> should have a single expression node as child.

View File

@ -0,0 +1,48 @@
import { NodeTransform } from '../transform'
import {
NodeTypes,
ChildNode,
TextNode,
InterpolationNode,
CompoundExpressionNode
} from '../ast'
const isText = (node: ChildNode): node is TextNode | InterpolationNode =>
node.type === NodeTypes.INTERPOLATION || node.type === NodeTypes.TEXT
// Merge adjacent text nodes and expressions into a single expression
// e.g. <div>abc {{ d }} {{ e }}</div> should have a single expression node as child.
export const optimizeText: NodeTransform = node => {
if (node.type === NodeTypes.ROOT || node.type === NodeTypes.ELEMENT) {
// perform the transform on node exit so that all expressions have already
// been processed.
return () => {
const children = node.children
let currentContainer: CompoundExpressionNode | undefined = undefined
for (let i = 0; i < children.length; i++) {
const child = children[i]
if (isText(child)) {
for (let j = i + 1; j < children.length; j++) {
const next = children[j]
if (isText(next)) {
if (!currentContainer) {
currentContainer = children[i] = {
type: NodeTypes.COMPOUND_EXPRESSION,
loc: child.loc,
children: [child]
}
}
// merge adjacent text node into current
currentContainer.children.push(` + `, next)
children.splice(j, 1)
j--
} else {
currentContainer = undefined
break
}
}
}
}
}
}
}

View File

@ -29,7 +29,7 @@ export const transformIf = createStructuralDirectiveTransform(
}) })
} else { } else {
// locate the adjacent v-if // locate the adjacent v-if
const siblings = context.parent.children const siblings = context.parent!.children
const comments = [] const comments = []
let i = siblings.indexOf(node as any) let i = siblings.indexOf(node as any)
while (i-- >= -1) { while (i-- >= -1) {