Compiler Refinements
In this chapter, we will make several adjustments to improve the quality of the template compiler.
We will cover two main topics:
- Whitespace Handling - Remove and condense unnecessary whitespace
- Text Node Merging - Efficiently merge adjacent text nodes
These are optimizations to improve the quality of the generated code rather than visible features.
Whitespace Handling
The Problem
In the current implementation, all whitespace in templates is preserved as-is.
Consider the following template:
<div>
<span>Hello</span>
<span>World</span>
</div>In the current implementation, newlines and indentation between <div> and <span> are preserved as text nodes.
This generates unnecessary nodes and can affect performance.
Vue.js's Approach
Vue.js uses the whitespace option to control how whitespace is handled.
type WhitespaceStrategy = 'preserve' | 'condense''condense'(default): Condense consecutive whitespace and remove unnecessary whitespace'preserve': Preserve whitespace as-is
Condense Mode Behavior
In condense mode, whitespace is processed according to the following rules:
- Whitespace-only text nodes at the start/end → Remove
- Whitespace between elements containing newlines → Remove
- Consecutive whitespace → Condense to a single space
- Whitespace between elements without newlines → Preserve (condensed to single space)
Examples:
<div> <span/> </div>
<!-- Result: Only <span/> as child node (surrounding spaces removed) -->
<div/>
<div/>
<div/>
<!-- Result: Only 3 div elements (whitespace with newlines removed) -->
<span>foo</span> <span>bar</span>
<!-- Result: Space between elements is preserved (no newlines) -->Implementation
First, add the whitespace option to ParserOptions.
packages/compiler-core/src/options.ts:
export interface ParserOptions {
// ... existing options ...
whitespace?: 'preserve' | 'condense'
}Add whitespace processing functions to packages/compiler-core/src/parse.ts.
function isAllWhitespace(content: string): boolean {
for (let i = 0; i < content.length; i++) {
const c = content.charCodeAt(i)
if (
c !== 0x20 && // space
c !== 0x09 && // tab
c !== 0x0a && // newline
c !== 0x0c && // form feed
c !== 0x0d // carriage return
) {
return false
}
}
return true
}
function hasNewlineChar(content: string): boolean {
for (let i = 0; i < content.length; i++) {
const c = content.charCodeAt(i)
if (c === 0x0a || c === 0x0d) {
return true
}
}
return false
}
function condense(content: string): string {
let result = ''
let prevIsWhitespace = false
for (let i = 0; i < content.length; i++) {
const c = content.charCodeAt(i)
const isWhitespace =
c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0c || c === 0x0d
if (isWhitespace) {
if (!prevIsWhitespace) {
result += ' '
prevIsWhitespace = true
}
} else {
result += content[i]
prevIsWhitespace = false
}
}
return result
}
function condenseWhitespace(
nodes: TemplateChildNode[],
context: ParserContext,
): TemplateChildNode[] {
const shouldCondense = context.options.whitespace !== 'preserve'
let removedWhitespace = false
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (node.type === NodeTypes.TEXT) {
if (!context.inPre) {
if (isAllWhitespace(node.content)) {
const prev = nodes[i - 1]?.type
const next = nodes[i + 1]?.type
// Remove if:
// - First or last whitespace
// - (condense mode) Whitespace between comments
// - (condense mode) Whitespace between comment and element
// - (condense mode) Whitespace between elements containing newlines
if (
!prev ||
!next ||
(shouldCondense &&
((prev === NodeTypes.COMMENT &&
(next === NodeTypes.COMMENT || next === NodeTypes.ELEMENT)) ||
(prev === NodeTypes.ELEMENT &&
(next === NodeTypes.COMMENT ||
(next === NodeTypes.ELEMENT &&
hasNewlineChar(node.content))))))
) {
removedWhitespace = true
nodes[i] = null as any
} else {
// Otherwise, condense to single space
node.content = ' '
}
} else if (shouldCondense) {
// In condense mode, condense consecutive whitespace
node.content = condense(node.content)
}
}
}
}
return removedWhitespace ? nodes.filter(Boolean) : nodes
}Then call this function when parsing elements.
function parseElement(
context: ParserContext,
ancestors: ElementNode[],
): ElementNode | undefined {
// ... existing code ...
// Children
if (!element.isSelfClosing) {
ancestors.push(element)
const children = parseChildren(context, ancestors)
ancestors.pop()
element.children = condenseWhitespace(children, context)
// element.children = children
// ...
}
return element
}Also apply the same processing to the root node.
export const baseParse = (
content: string,
options: ParserOptions = {},
): RootNode => {
const context = createParserContext(content, options)
const children = parseChildren(context, [])
return createRoot(condenseWhitespace(children, context))
// return createRoot(children)
}Text Node Merging (transformText)
The Problem
In the current implementation, text nodes and mustache syntax () are treated as separate nodes.
<div>abc {{ d }} {{ e }}</div>This template has the following child nodes:
TEXT: "abc "INTERPOLATION: dTEXT: " "INTERPOLATION: e
Processing these individually during code generation is inefficient.
Vue.js's Approach
Vue.js uses a transformer called transformText to merge adjacent text nodes and mustache syntax into a single CompoundExpression.
After merging:
// "abc " + d + " " + e
createCompoundExpression(['abc ', d, ' ', e])This allows efficient concatenation operations during code generation.
Implementation
Create packages/compiler-core/src/transforms/transformText.ts.
import type { NodeTransform } from '../transform'
import {
type CompoundExpressionNode,
ElementTypes,
NodeTypes,
createCallExpression,
createCompoundExpression,
} from '../ast'
import { isText } from '../utils'
import { CREATE_TEXT } from '../runtimeHelpers'
import { PatchFlags } from '@chibivue/shared'
// Merge adjacent text nodes and mustaches into a single expression
// e.g. <div>abc {{ d }} {{ e }}</div> should have a single child node
export const transformText: NodeTransform = (node, context) => {
if (
node.type === NodeTypes.ROOT ||
node.type === NodeTypes.ELEMENT ||
node.type === NodeTypes.FOR ||
node.type === NodeTypes.IF_BRANCH
) {
// Execute after child processing is complete
return () => {
const children = node.children
let currentContainer: CompoundExpressionNode | undefined = undefined
let hasText = false
for (let i = 0; i < children.length; i++) {
const child = children[i]
if (isText(child)) {
hasText = true
for (let j = i + 1; j < children.length; j++) {
const next = children[j]
if (isText(next)) {
if (!currentContainer) {
currentContainer = children[i] = createCompoundExpression(
[child],
child.loc,
)
}
// Merge adjacent text nodes
currentContainer.children.push(` + `, next)
children.splice(j, 1)
j--
} else {
currentContainer = undefined
break
}
}
}
}
if (
!hasText ||
// Leave plain elements with a single text child as-is
// Runtime has optimized fast path for directly setting textContent
(children.length === 1 &&
(node.type === NodeTypes.ROOT ||
(node.type === NodeTypes.ELEMENT &&
node.tagType === ElementTypes.ELEMENT &&
!node.props.find(
p =>
p.type === NodeTypes.DIRECTIVE &&
!context.directiveTransforms[p.name],
))))
) {
return
}
// Convert text nodes to createTextVNode(text) calls
for (let i = 0; i < children.length; i++) {
const child = children[i]
if (isText(child) || child.type === NodeTypes.COMPOUND_EXPRESSION) {
const callArgs: any[] = []
// createTextVNode defaults to single space,
// so we can omit the argument for single space
if (child.type !== NodeTypes.TEXT || child.content !== ' ') {
callArgs.push(child)
}
// Mark dynamic text with flag for patching inside a block
if (!context.ssr && !isStaticNode(child)) {
callArgs.push(PatchFlags.TEXT)
}
children[i] = {
type: NodeTypes.TEXT_CALL,
content: child,
loc: child.loc,
codegenNode: createCallExpression(
context.helper(CREATE_TEXT),
callArgs,
),
}
}
}
}
}
}
function isStaticNode(node: any): boolean {
if (node.type === NodeTypes.TEXT) {
return true
}
if (node.type === NodeTypes.INTERPOLATION) {
return node.content.isStatic
}
if (node.type === NodeTypes.COMPOUND_EXPRESSION) {
return node.children.every((child: any) => {
if (typeof child === 'string') return true
return isStaticNode(child)
})
}
return false
}Add the isText helper to packages/compiler-core/src/utils.ts.
export function isText(
node: TemplateChildNode,
): node is TextNode | InterpolationNode {
return node.type === NodeTypes.TEXT || node.type === NodeTypes.INTERPOLATION
}Add TEXT_CALL node type and createCallExpression to packages/compiler-core/src/ast.ts.
export const enum NodeTypes {
// ... existing types ...
TEXT_CALL,
}
export interface TextCallNode extends Node {
type: NodeTypes.TEXT_CALL
content: TextNode | InterpolationNode | CompoundExpressionNode
codegenNode: CallExpression
}
export function createCallExpression(
callee: string,
args: CallExpression['arguments'] = [],
loc: SourceLocation = locStub,
): CallExpression {
return {
type: NodeTypes.JS_CALL_EXPRESSION,
loc,
callee,
arguments: args,
}
}Add CREATE_TEXT to packages/compiler-core/src/runtimeHelpers.ts.
export const CREATE_TEXT = Symbol('createTextVNode')
export const helperNameMap: Record<symbol, string> = {
// ... existing helpers ...
[CREATE_TEXT]: 'createTextVNode',
}Registering the Transformer
Register the transformer in packages/compiler-core/src/compile.ts.
import { transformText } from './transforms/transformText'
export function getBaseTransformPreset(): TransformPreset {
return [
[
transformElement,
transformSlotOutlet,
transformText,
],
{
on: transformOn,
bind: transformBind,
if: transformIf,
for: transformFor,
model: transformModel,
},
]
}Updating Code Generation
Add TEXT_CALL node handling to packages/compiler-core/src/codegen.ts.
function genNode(node: any, context: CodegenContext) {
switch (node.type) {
// ... existing cases ...
case NodeTypes.TEXT_CALL:
genNode(node.codegenNode, context)
break
}
}Updating the Runtime
Add createTextVNode to packages/runtime-core/src/vnode.ts.
export function createTextVNode(text: string = ' ', flag: number = 0): VNode {
return createVNode(Text, null, text, flag)
}Export this from packages/runtime-core/src/index.ts.
export { createTextVNode } from './vnode'Testing
Let's verify with the following template:
<script>
import { ref } from 'chibivue'
export default {
setup() {
const name = ref('World')
return { name }
},
}
</script>
<template>
<div>
<p>Hello {{ name }}!</p>
</div>
</template>When you check the compilation result, you should see:
- Unnecessary whitespace (newlines and indentation) has been removed
Hello,, and!have been merged
The compiler quality has now been improved!
Source code up to this point:
chibivue (GitHub)
