diff --git a/packages/compiler-core/src/codegen.ts b/packages/compiler-core/src/codegen.ts index 8014416f227..9987c1321f7 100644 --- a/packages/compiler-core/src/codegen.ts +++ b/packages/compiler-core/src/codegen.ts @@ -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(', ') diff --git a/packages/compiler-core/src/index.ts b/packages/compiler-core/src/index.ts index 63cc6c15fdc..6a224eb4ac4 100644 --- a/packages/compiler-core/src/index.ts +++ b/packages/compiler-core/src/index.ts @@ -5,7 +5,8 @@ export { CompilerOptions, ParserOptions, TransformOptions, - CodegenOptions + CodegenOptions, + HoistTransform } from './options' export { baseParse, TextModes } from './parse' export { diff --git a/packages/compiler-core/src/options.ts b/packages/compiler-core/src/options.ts index b729ac3d07d..dc9d3631e16 100644 --- a/packages/compiler-core/src/options.ts +++ b/packages/compiler-core/src/options.ts @@ -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 + // 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 diff --git a/packages/compiler-core/src/runtimeHelpers.ts b/packages/compiler-core/src/runtimeHelpers.ts index b684780214c..11267c92989 100644 --- a/packages/compiler-core/src/runtimeHelpers.ts +++ b/packages/compiler-core/src/runtimeHelpers.ts @@ -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`, diff --git a/packages/compiler-core/src/transform.ts b/packages/compiler-core/src/transform.ts index 758dc8357b9..d69e9da452d 100644 --- a/packages/compiler-core/src/transform.ts +++ b/packages/compiler-core/src/transform.ts @@ -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, diff --git a/packages/compiler-core/src/transforms/hoistStatic.ts b/packages/compiler-core/src/transforms/hoistStatic.ts index 2dd514bee1f..2bd0b608e4d 100644 --- a/packages/compiler-core/src/transforms/hoistStatic.ts +++ b/packages/compiler-core/src/transforms/hoistStatic.ts @@ -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 diff --git a/packages/compiler-dom/src/index.ts b/packages/compiler-dom/src/index.ts index 5ae5c1b5ee7..ca305153818 100644 --- a/packages/compiler-dom/src/index.ts +++ b/packages/compiler-dom/src/index.ts @@ -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 { diff --git a/packages/compiler-dom/src/stringifyStatic.ts b/packages/compiler-dom/src/stringifyStatic.ts new file mode 100644 index 00000000000..cb3fa3f6fcc --- /dev/null +++ b/packages/compiler-dom/src/stringifyStatic.ts @@ -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(`
bar
`) +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 += `` + } + 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 `` + 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 '' + } +} diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 01ba0344ff5..92919307863 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -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. diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index e2f87c8f277..888451f5c60 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -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 { 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 = ( @@ -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 = { @@ -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,50 +410,58 @@ 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 - - // props - if (props != null) { - for (const key in props) { - if (isReservedProp(key)) continue - hostPatchProp(el, key, props[key], null, isSVG) + 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) { + if (isReservedProp(key)) continue + hostPatchProp(el, key, props[key], null, isSVG) + } + if (props.onVnodeBeforeMount != null) { + invokeDirectiveHook(props.onVnodeBeforeMount, parentComponent, vnode) + } } - if (props.onVnodeBeforeMount != null) { - invokeDirectiveHook(props.onVnodeBeforeMount, parentComponent, vnode) + + // scopeId + if (__BUNDLER__) { + if (scopeId !== null) { + hostSetScopeId(el, scopeId) + } + const treeOwnerId = parentComponent && parentComponent.type.__scopeId + // vnode's own scopeId and the current patched component's scopeId is + // different - this is a slot content node. + if (treeOwnerId != null && treeOwnerId !== scopeId) { + hostSetScopeId(el, treeOwnerId + '-s') + } } - } - // scopeId - if (__BUNDLER__) { - if (scopeId !== null) { - hostSetScopeId(el, scopeId) + // children + if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { + hostSetElementText(el, vnode.children as string) + } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { + mountChildren( + vnode.children as HostVNodeChildren, + el, + null, + parentComponent, + parentSuspense, + isSVG && type !== 'foreignObject', + optimized || vnode.dynamicChildren !== null + ) } - const treeOwnerId = parentComponent && parentComponent.type.__scopeId - // vnode's own scopeId and the current patched component's scopeId is - // different - this is a slot content node. - if (treeOwnerId != null && treeOwnerId !== scopeId) { - hostSetScopeId(el, treeOwnerId + '-s') + if (transition != null && !transition.persisted) { + transition.beforeEnter(el) } } - // children - if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { - hostSetElementText(el, vnode.children as string) - } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { - mountChildren( - vnode.children as HostVNodeChildren, - el, - null, - parentComponent, - parentSuspense, - isSVG && type !== 'foreignObject', - optimized || vnode.dynamicChildren !== null - ) - } - 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 diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index dd31a0ac86b..475323b910a 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -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 diff --git a/packages/runtime-dom/src/nodeOps.ts b/packages/runtime-dom/src/nodeOps.ts index 291eecd05a5..9b511612566 100644 --- a/packages/runtime-dom/src/nodeOps.ts +++ b/packages/runtime-dom/src/nodeOps.ts @@ -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, '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 } } diff --git a/packages/runtime-dom/src/patchProp.ts b/packages/runtime-dom/src/patchProp.ts index a99eb90b02a..fa2ff3f46ce 100644 --- a/packages/runtime-dom/src/patchProp.ts +++ b/packages/runtime-dom/src/patchProp.ts @@ -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, - unmountChildren?: any -) { +export const patchProp: RendererOptions['patchProp'] = ( + el, + key, + nextValue, + prevValue, + isSVG = false, + prevChildren, + parentComponent, + parentSuspense, + unmountChildren +) => { switch (key) { // special case 'class':