feat(compiler-dom/runtime-dom): stringify eligible static trees

This commit is contained in:
Evan You 2020-02-12 11:56:42 -05:00
parent e861c6da90
commit 27913e661a
13 changed files with 304 additions and 87 deletions

View File

@ -48,7 +48,8 @@ import {
WITH_SCOPE_ID,
WITH_DIRECTIVES,
CREATE_BLOCK,
OPEN_BLOCK
OPEN_BLOCK,
CREATE_STATIC
} from './runtimeHelpers'
import { ImportItem } from './transform'
@ -309,7 +310,12 @@ function genFunctionPreamble(ast: RootNode, context: CodegenContext) {
// has check cost, but hoists are lifted out of the function - we need
// to provide the helper here.
if (ast.hoists.length) {
const staticHelpers = [CREATE_VNODE, CREATE_COMMENT, CREATE_TEXT]
const staticHelpers = [
CREATE_VNODE,
CREATE_COMMENT,
CREATE_TEXT,
CREATE_STATIC
]
.filter(helper => ast.helpers.includes(helper))
.map(aliasHelper)
.join(', ')

View File

@ -5,7 +5,8 @@ export {
CompilerOptions,
ParserOptions,
TransformOptions,
CodegenOptions
CodegenOptions,
HoistTransform
} from './options'
export { baseParse, TextModes } from './parse'
export {

View File

@ -1,7 +1,11 @@
import { ElementNode, Namespace } from './ast'
import { ElementNode, Namespace, JSChildNode, PlainElementNode } from './ast'
import { TextModes } from './parse'
import { CompilerError } from './errors'
import { NodeTransform, DirectiveTransform } from './transform'
import {
NodeTransform,
DirectiveTransform,
TransformContext
} from './transform'
export interface ParserOptions {
isVoidTag?: (tag: string) => boolean // e.g. img, br, hr
@ -26,9 +30,17 @@ export interface ParserOptions {
onError?: (error: CompilerError) => void
}
export type HoistTransform = (
node: PlainElementNode,
context: TransformContext
) => JSChildNode
export interface TransformOptions {
nodeTransforms?: NodeTransform[]
directiveTransforms?: Record<string, DirectiveTransform | undefined>
// an optional hook to transform a node being hoisted.
// used by compiler-dom to turn hoisted nodes into stringified HTML vnodes.
transformHoist?: HoistTransform | null
isBuiltInComponent?: (tag: string) => symbol | void
// Transform expressions like {{ foo }} to `_ctx.foo`.
// If this option is false, the generated code will be wrapped in a

View File

@ -8,6 +8,7 @@ export const CREATE_BLOCK = Symbol(__DEV__ ? `createBlock` : ``)
export const CREATE_VNODE = Symbol(__DEV__ ? `createVNode` : ``)
export const CREATE_COMMENT = Symbol(__DEV__ ? `createCommentVNode` : ``)
export const CREATE_TEXT = Symbol(__DEV__ ? `createTextVNode` : ``)
export const CREATE_STATIC = Symbol(__DEV__ ? `createStaticVNode` : ``)
export const RESOLVE_COMPONENT = Symbol(__DEV__ ? `resolveComponent` : ``)
export const RESOLVE_DYNAMIC_COMPONENT = Symbol(
__DEV__ ? `resolveDynamicComponent` : ``
@ -40,6 +41,7 @@ export const helperNameMap: any = {
[CREATE_VNODE]: `createVNode`,
[CREATE_COMMENT]: `createCommentVNode`,
[CREATE_TEXT]: `createTextVNode`,
[CREATE_STATIC]: `createStaticVNode`,
[RESOLVE_COMPONENT]: `resolveComponent`,
[RESOLVE_DYNAMIC_COMPONENT]: `resolveDynamicComponent`,
[RESOLVE_DIRECTIVE]: `resolveDirective`,

View File

@ -115,6 +115,7 @@ export function createTransformContext(
cacheHandlers = false,
nodeTransforms = [],
directiveTransforms = {},
transformHoist = null,
isBuiltInComponent = NOOP,
scopeId = null,
ssr = false,
@ -128,6 +129,7 @@ export function createTransformContext(
cacheHandlers,
nodeTransforms,
directiveTransforms,
transformHoist,
isBuiltInComponent,
scopeId,
ssr,

View File

@ -52,7 +52,10 @@ function walk(
) {
if (!doNotHoistNode && isStaticNode(child, resultCache)) {
// whole tree is static
child.codegenNode = context.hoist(child.codegenNode!)
const hoisted = context.transformHoist
? context.transformHoist(child, context)
: child.codegenNode!
child.codegenNode = context.hoist(hoisted)
continue
} else {
// node may contain dynamic children, but its props may be eligible for

View File

@ -18,6 +18,7 @@ import { transformModel } from './transforms/vModel'
import { transformOn } from './transforms/vOn'
import { transformShow } from './transforms/vShow'
import { warnTransitionChildren } from './transforms/warnTransitionChildren'
import { stringifyStatic } from './stringifyStatic'
export const parserOptions = __BROWSER__
? parserOptionsMinimal
@ -41,17 +42,16 @@ export function compile(
template: string,
options: CompilerOptions = {}
): CodegenResult {
const result = baseCompile(template, {
return baseCompile(template, {
...parserOptions,
...options,
nodeTransforms: [...DOMNodeTransforms, ...(options.nodeTransforms || [])],
directiveTransforms: {
...DOMDirectiveTransforms,
...(options.directiveTransforms || {})
}
},
transformHoist: __BROWSER__ ? null : stringifyStatic
})
// debugger
return result
}
export function parse(template: string, options: ParserOptions = {}): RootNode {

View File

@ -0,0 +1,116 @@
import {
NodeTypes,
ElementNode,
TransformContext,
TemplateChildNode,
SimpleExpressionNode,
createCallExpression,
HoistTransform,
CREATE_STATIC
} from '@vue/compiler-core'
import { isVoidTag, isString, isSymbol, escapeHtml } from '@vue/shared'
// Turn eligible hoisted static trees into stringied static nodes, e.g.
// const _hoisted_1 = createStaticVNode(`<div class="foo">bar</div>`)
export const stringifyStatic: HoistTransform = (node, context) => {
if (shouldOptimize(node)) {
return createCallExpression(context.helper(CREATE_STATIC), [
JSON.stringify(stringifyElement(node, context))
])
} else {
return node.codegenNode!
}
}
// Opt-in heuristics based on:
// 1. number of elements with attributes > 5.
// 2. OR: number of total nodes > 20
// For some simple trees, the performance can actually be worse.
// it is only worth it when the tree is complex enough
// (e.g. big piece of static content)
function shouldOptimize(node: ElementNode): boolean {
let bindingThreshold = 5
let nodeThreshold = 20
function walk(node: ElementNode) {
for (let i = 0; i < node.children.length; i++) {
if (--nodeThreshold === 0) {
return true
}
const child = node.children[i]
if (child.type === NodeTypes.ELEMENT) {
if (child.props.length > 0 && --bindingThreshold === 0) {
return true
}
if (walk(child)) {
return true
}
}
}
return false
}
return walk(node)
}
function stringifyElement(
node: ElementNode,
context: TransformContext
): string {
let res = `<${node.tag}`
for (let i = 0; i < node.props.length; i++) {
const p = node.props[i]
if (p.type === NodeTypes.ATTRIBUTE) {
res += ` ${p.name}`
if (p.value) {
res += `="${p.value.content}"`
}
} else if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') {
// constant v-bind, e.g. :foo="1"
// TODO
}
}
if (context.scopeId) {
res += ` ${context.scopeId}`
}
res += `>`
for (let i = 0; i < node.children.length; i++) {
res += stringifyNode(node.children[i], context)
}
if (!isVoidTag(node.tag)) {
res += `</${node.tag}>`
}
return res
}
function stringifyNode(
node: string | TemplateChildNode,
context: TransformContext
): string {
if (isString(node)) {
return node
}
if (isSymbol(node)) {
return ``
}
switch (node.type) {
case NodeTypes.ELEMENT:
return stringifyElement(node, context)
case NodeTypes.TEXT:
return escapeHtml(node.content)
case NodeTypes.COMMENT:
return `<!--${escapeHtml(node.content)}-->`
case NodeTypes.INTERPOLATION:
// constants
// TODO check eval
return (node.content as SimpleExpressionNode).content
case NodeTypes.COMPOUND_EXPRESSION:
// TODO proper handling
return node.children.map((c: any) => stringifyNode(c, context)).join('')
case NodeTypes.TEXT_CALL:
return stringifyNode(node.content, context)
default:
// static trees will not contain if/for nodes
return ''
}
}

View File

@ -85,7 +85,12 @@ export { toHandlers } from './helpers/toHandlers'
export { renderSlot } from './helpers/renderSlot'
export { createSlots } from './helpers/createSlots'
export { pushScopeId, popScopeId, withScopeId } from './helpers/scopeId'
export { setBlockTracking, createTextVNode, createCommentVNode } from './vnode'
export {
setBlockTracking,
createTextVNode,
createCommentVNode,
createStaticVNode
} from './vnode'
// Since @vue/shared is inlined into final builds,
// when re-exporting from @vue/shared we need to avoid relying on their original
// types so that the bundled d.ts does not attempt to import from it.

View File

@ -8,7 +8,8 @@ import {
VNode,
VNodeArrayChildren,
createVNode,
isSameVNodeType
isSameVNodeType,
Static
} from './vnode'
import {
ComponentInternalInstance,
@ -28,7 +29,8 @@ import {
EMPTY_ARR,
isReservedProp,
isFunction,
PatchFlags
PatchFlags,
NOOP
} from '@vue/shared'
import {
queueJob,
@ -88,8 +90,15 @@ export interface RendererOptions<HostNode = any, HostElement = any> {
setElementText(node: HostElement, text: string): void
parentNode(node: HostNode): HostElement | null
nextSibling(node: HostNode): HostNode | null
querySelector(selector: string): HostElement | null
setScopeId(el: HostNode, id: string): void
querySelector?(selector: string): HostElement | null
setScopeId?(el: HostElement, id: string): void
cloneNode?(node: HostNode): HostNode
insertStaticContent?(
content: string,
parent: HostElement,
anchor: HostNode | null,
isSVG: boolean
): HostElement
}
export type RootRenderFunction<HostNode, HostElement> = (
@ -197,7 +206,9 @@ export function createRenderer<
parentNode: hostParentNode,
nextSibling: hostNextSibling,
querySelector: hostQuerySelector,
setScopeId: hostSetScopeId
setScopeId: hostSetScopeId = NOOP,
cloneNode: hostCloneNode,
insertStaticContent: hostInsertStaticContent
} = options
const internals: RendererInternals<HostNode, HostElement> = {
@ -233,6 +244,11 @@ export function createRenderer<
case Comment:
processCommentNode(n1, n2, container, anchor)
break
case Static:
if (n1 == null) {
mountStaticNode(n2, container, anchor, isSVG)
} // static nodes are noop on patch
break
case Fragment:
processFragment(
n1,
@ -336,6 +352,26 @@ export function createRenderer<
}
}
function mountStaticNode(
n2: HostVNode,
container: HostElement,
anchor: HostNode | null,
isSVG: boolean
) {
if (n2.el != null && hostCloneNode !== undefined) {
hostInsert(hostCloneNode(n2.el), container, anchor)
} else {
// static nodes are only present when used with compiler-dom/runtime-dom
// which guarantees presence of hostInsertStaticContent.
n2.el = hostInsertStaticContent!(
n2.children as string,
container,
anchor,
isSVG
)
}
}
function processElement(
n1: HostVNode | null,
n2: HostVNode,
@ -374,9 +410,15 @@ export function createRenderer<
isSVG: boolean,
optimized: boolean
) {
const el = (vnode.el = hostCreateElement(vnode.type as string, isSVG))
let el: HostElement
const { type, props, shapeFlag, transition, scopeId } = vnode
if (vnode.el != null && hostCloneNode !== undefined) {
// If a vnode has non-null el, it means it's being reused.
// Only static vnodes can be reused, so its mounted DOM nodes should be
// exactly the same, and we can simply do a clone here.
el = vnode.el = hostCloneNode(vnode.el) as HostElement
} else {
el = vnode.el = hostCreateElement(vnode.type as string, isSVG)
// props
if (props != null) {
for (const key in props) {
@ -418,6 +460,8 @@ export function createRenderer<
if (transition != null && !transition.persisted) {
transition.beforeEnter(el)
}
}
hostInsert(el, container, anchor)
const vnodeMountedHook = props && props.onVnodeMounted
if (
@ -776,8 +820,14 @@ export function createRenderer<
const targetSelector = n2.props && n2.props.target
const { patchFlag, shapeFlag, children } = n2
if (n1 == null) {
if (__DEV__ && isString(targetSelector) && !hostQuerySelector) {
warn(
`Current renderer does not support string target for Portals. ` +
`(missing querySelector renderer option)`
)
}
const target = (n2.target = isString(targetSelector)
? hostQuerySelector(targetSelector)
? hostQuerySelector!(targetSelector)
: targetSelector)
if (target != null) {
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
@ -825,7 +875,7 @@ export function createRenderer<
// target changed
if (targetSelector !== (n1.props && n1.props.target)) {
const nextTarget = (n2.target = isString(targetSelector)
? hostQuerySelector(targetSelector)
? hostQuerySelector!(targetSelector)
: targetSelector)
if (nextTarget != null) {
// move content

View File

@ -39,6 +39,7 @@ export const Portal = (Symbol(__DEV__ ? 'Portal' : undefined) as any) as {
}
export const Text = Symbol(__DEV__ ? 'Text' : undefined)
export const Comment = Symbol(__DEV__ ? 'Comment' : undefined)
export const Static = Symbol(__DEV__ ? 'Static' : undefined)
export type VNodeTypes =
| string
@ -46,6 +47,7 @@ export type VNodeTypes =
| typeof Fragment
| typeof Portal
| typeof Text
| typeof Static
| typeof Comment
| typeof SuspenseImpl
@ -328,6 +330,10 @@ export function createTextVNode(text: string = ' ', flag: number = 0): VNode {
return createVNode(Text, null, text, flag)
}
export function createStaticVNode(content: string): VNode {
return createVNode(Static, null, content)
}
export function createCommentVNode(
text: string = '',
// when used as the v-else branch, the comment node must be created as a

View File

@ -1,8 +1,13 @@
import { RendererOptions } from '@vue/runtime-core/src'
const doc = (typeof document !== 'undefined' ? document : null) as Document
const svgNS = 'http://www.w3.org/2000/svg'
export const nodeOps = {
insert: (child: Node, parent: Node, anchor?: Node) => {
let tempContainer: HTMLElement
let tempSVGContainer: SVGElement
export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
insert: (child, parent, anchor) => {
if (anchor != null) {
parent.insertBefore(child, anchor)
} else {
@ -10,37 +15,50 @@ export const nodeOps = {
}
},
remove: (child: Node) => {
remove: child => {
const parent = child.parentNode
if (parent != null) {
parent.removeChild(child)
}
},
createElement: (tag: string, isSVG?: boolean): Element =>
createElement: (tag, isSVG): Element =>
isSVG ? doc.createElementNS(svgNS, tag) : doc.createElement(tag),
createText: (text: string): Text => doc.createTextNode(text),
createText: text => doc.createTextNode(text),
createComment: (text: string): Comment => doc.createComment(text),
createComment: text => doc.createComment(text),
setText: (node: Text, text: string) => {
setText: (node, text) => {
node.nodeValue = text
},
setElementText: (el: HTMLElement, text: string) => {
setElementText: (el, text) => {
el.textContent = text
},
parentNode: (node: Node): HTMLElement | null =>
node.parentNode as HTMLElement,
parentNode: node => node.parentNode as Element | null,
nextSibling: (node: Node): Node | null => node.nextSibling,
nextSibling: node => node.nextSibling,
querySelector: (selector: string): Element | null =>
doc.querySelector(selector),
querySelector: selector => doc.querySelector(selector),
setScopeId(el: Element, id: string) {
setScopeId(el, id) {
el.setAttribute(id, '')
},
cloneNode(el) {
return el.cloneNode(true)
},
insertStaticContent(content, parent, anchor, isSVG) {
const temp = isSVG
? tempSVGContainer ||
(tempSVGContainer = doc.createElementNS(svgNS, 'svg'))
: tempContainer || (tempContainer = doc.createElement('div'))
temp.innerHTML = content
const node = temp.children[0]
nodeOps.insert(node, parent, anchor)
return node
}
}

View File

@ -4,23 +4,19 @@ import { patchAttr } from './modules/attrs'
import { patchDOMProp } from './modules/props'
import { patchEvent } from './modules/events'
import { isOn } from '@vue/shared'
import {
ComponentInternalInstance,
SuspenseBoundary,
VNode
} from '@vue/runtime-core'
import { RendererOptions } from '@vue/runtime-core'
export function patchProp(
el: Element,
key: string,
nextValue: any,
prevValue: any,
isSVG: boolean,
prevChildren?: VNode[],
parentComponent?: ComponentInternalInstance,
parentSuspense?: SuspenseBoundary<Node, Element>,
unmountChildren?: any
) {
export const patchProp: RendererOptions<Node, Element>['patchProp'] = (
el,
key,
nextValue,
prevValue,
isSVG = false,
prevChildren,
parentComponent,
parentSuspense,
unmountChildren
) => {
switch (key) {
// special
case 'class':