]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
wip(ssr): vdom serialization
authorEvan You <yyx990803@gmail.com>
Tue, 28 Jan 2020 23:48:27 +0000 (18:48 -0500)
committerEvan You <yyx990803@gmail.com>
Tue, 28 Jan 2020 23:48:27 +0000 (18:48 -0500)
packages/runtime-core/src/index.ts
packages/runtime-core/src/renderer.ts
packages/runtime-dom/src/modules/attrs.ts
packages/runtime-dom/src/patchProp.ts
packages/server-renderer/__tests__/renderProps.spec.ts
packages/server-renderer/__tests__/renderToString.spec.ts
packages/server-renderer/src/renderProps.ts
packages/server-renderer/src/renderToString.ts
packages/shared/src/domAttrConfig.ts [new file with mode: 0644]
packages/shared/src/index.ts
packages/shared/src/normalizeProp.ts

index 72460ddcafe13ab1cab39d79cc3cc5780f6629a4..1c20819fb1373d65c41d493e89176032c4c9c1e9 100644 (file)
@@ -102,8 +102,11 @@ export const camelize = _camelize as (s: string) => string
 export { registerRuntimeCompiler } from './component'
 
 // For server-renderer
+// TODO move these into a conditional object to avoid exporting them in client
+// builds
 export { createComponentInstance, setupComponent } from './component'
 export { renderComponentRoot } from './componentRenderUtils'
+export { normalizeVNode } from './vnode'
 
 // Types -----------------------------------------------------------------------
 
@@ -114,7 +117,7 @@ export {
   Plugin,
   CreateAppFunction
 } from './apiCreateApp'
-export { VNode, VNodeTypes, VNodeProps } from './vnode'
+export { VNode, VNodeTypes, VNodeProps, VNodeChildren } from './vnode'
 export {
   Component,
   FunctionalComponent,
index fc208d8cc8c290df14952b334d20092a1eab67fe..d545d75ed4d8af021ce9d30c58424d1f3fd421df 100644 (file)
@@ -288,7 +288,7 @@ export function createRenderer<
             internals
           )
         } else if (__DEV__) {
-          warn('Invalid HostVNode type:', n2.type, `(${typeof n2.type})`)
+          warn('Invalid HostVNode type:', type, `(${typeof type})`)
         }
     }
   }
index 7bdd9ba68b91e029af16cb9e6fa4e470688c1969..5610d327f4ff847acfb05179bb15543408308b8c 100644 (file)
@@ -1,7 +1,18 @@
-export function patchAttr(el: Element, key: string, value: any) {
-  if (value == null) {
+// TODO explain why we are no longer checking boolean/enumerated here
+
+export function patchAttr(
+  el: Element,
+  key: string,
+  value: any,
+  isSVG: boolean
+) {
+  if (isSVG && key.indexOf('xlink:') === 0) {
+    // TODO handle xlink
+  } else if (value == null) {
     el.removeAttribute(key)
   } else {
+    // TODO in dev mode, warn against incorrect values for boolean or
+    // enumerated attributes
     el.setAttribute(key, value)
   }
 }
index 1f8ff8133fdad10db3fe27e9ffc6da63ebb43276..a99eb90b02a564e73d09df658fbb5321c2cd15dc 100644 (file)
@@ -62,7 +62,7 @@ export function patchProp(
         } else if (key === 'false-value') {
           ;(el as any)._falseValue = nextValue
         }
-        patchAttr(el, key, nextValue)
+        patchAttr(el, key, nextValue, isSVG)
       }
       break
   }
index 89c778fde5297a6a18a1c4d02874d064a6160f22..b1bbe453e1064a1c9242b6ac16c0f268e5da3173 100644 (file)
@@ -1,29 +1,19 @@
 describe('ssr: render props', () => {
   test('class', () => {})
 
-  test('styles', () => {
+  test('style', () => {
     // only render numbers for properties that allow no unit numbers
   })
 
-  describe('attrs', () => {
-    test('basic', () => {})
+  test('normal attrs', () => {})
 
-    test('boolean attrs', () => {})
+  test('boolean attrs', () => {})
 
-    test('enumerated attrs', () => {})
+  test('enumerated attrs', () => {})
 
-    test('skip falsy values', () => {})
-  })
-
-  describe('domProps', () => {
-    test('innerHTML', () => {})
+  test('ignore falsy values', () => {})
 
-    test('textContent', () => {})
+  test('props to attrs', () => {})
 
-    test('textarea', () => {})
-
-    test('other renderable domProps', () => {
-      // also test camel to kebab case conversion for some props
-    })
-  })
+  test('ignore non-renderable props', () => {})
 })
index 4a7dc68ebbea4e86d96d9b4a262b0b48dd9041a6..3a7d76d4d3b85e4976f80b4fe5b8a91823569fd7 100644 (file)
@@ -1,17 +1,31 @@
 // import { renderToString, renderComponent } from '../src'
 
 describe('ssr: renderToString', () => {
-  test('basic', () => {})
+  describe('elements', () => {
+    test('text children', () => {})
 
-  test('nested components', () => {})
+    test('array children', () => {})
 
-  test('nested components with optimized slots', () => {})
+    test('void elements', () => {})
 
-  test('mixing optimized / vnode components', () => {})
+    test('innerHTML', () => {})
 
-  test('nested components with vnode slots', () => {})
+    test('textContent', () => {})
 
-  test('async components', () => {})
+    test('textarea value', () => {})
+  })
 
-  test('parallel async components', () => {})
+  describe('components', () => {
+    test('nested components', () => {})
+
+    test('nested components with optimized slots', () => {})
+
+    test('mixing optimized / vnode components', () => {})
+
+    test('nested components with vnode slots', () => {})
+
+    test('async components', () => {})
+
+    test('parallel async components', () => {})
+  })
 })
index e3059eaa2807a58486905627e7282b169c820558..97cb53c3c858b015426203c479f30a04609b47cd 100644 (file)
@@ -1,5 +1,64 @@
-export function renderProps() {}
+import { escape } from './escape'
+import {
+  normalizeClass,
+  normalizeStyle,
+  propsToAttrMap,
+  hyphenate,
+  isString,
+  isNoUnitNumericStyleProp,
+  isOn,
+  isSSRSafeAttrName,
+  isBooleanAttr
+} from '@vue/shared/src'
 
-export function renderClass() {}
+export function renderProps(
+  props: Record<string, unknown>,
+  isCustomElement: boolean = false
+): string {
+  let ret = ''
+  for (const key in props) {
+    if (key === 'key' || key === 'ref' || isOn(key)) {
+      continue
+    }
+    const value = props[key]
+    if (key === 'class') {
+      ret += ` class="${renderClass(value)}"`
+    } else if (key === 'style') {
+      ret += ` style="${renderStyle(value)}"`
+    } else if (value != null) {
+      const attrKey = isCustomElement
+        ? key
+        : propsToAttrMap[key] || key.toLowerCase()
+      if (isBooleanAttr(attrKey)) {
+        ret += ` ${attrKey}=""`
+      } else if (isSSRSafeAttrName(attrKey)) {
+        ret += ` ${attrKey}="${escape(value)}"`
+      }
+    }
+  }
+  return ret
+}
 
-export function renderStyle() {}
+export function renderClass(raw: unknown): string {
+  return escape(normalizeClass(raw))
+}
+
+export function renderStyle(raw: unknown): string {
+  if (!raw) {
+    return ''
+  }
+  const styles = normalizeStyle(raw)
+  let ret = ''
+  for (const key in styles) {
+    const value = styles[key]
+    const normalizedKey = key.indexOf(`--`) === 0 ? key : hyphenate(key)
+    if (
+      isString(value) ||
+      (typeof value === 'number' && isNoUnitNumericStyleProp(normalizedKey))
+    ) {
+      // only render valid values
+      ret += `${normalizedKey}:${value};`
+    }
+  }
+  return escape(ret)
+}
index 6d14e9c601cdd12b3306de204f785e371437c164..1b936cfbcd4b18863d691c8562157e69d7c2f0bd 100644 (file)
@@ -3,12 +3,27 @@ import {
   Component,
   ComponentInternalInstance,
   VNode,
+  VNodeChildren,
   createComponentInstance,
   setupComponent,
   createVNode,
-  renderComponentRoot
+  renderComponentRoot,
+  Text,
+  Comment,
+  Fragment,
+  Portal,
+  ShapeFlags,
+  normalizeVNode
 } from 'vue'
-import { isString, isPromise, isArray, isFunction } from '@vue/shared'
+import {
+  isString,
+  isPromise,
+  isArray,
+  isFunction,
+  isVoidTag
+} from '@vue/shared'
+import { renderProps } from './renderProps'
+import { escape } from './escape'
 
 // Each component has a buffer array.
 // A buffer array can contain one of the following:
@@ -19,6 +34,7 @@ import { isString, isPromise, isArray, isFunction } from '@vue/shared'
 type SSRBuffer = SSRBufferItem[]
 type SSRBufferItem = string | ResolvedSSRBuffer | Promise<ResolvedSSRBuffer>
 type ResolvedSSRBuffer = (string | ResolvedSSRBuffer)[]
+type PushFn = (item: SSRBufferItem) => void
 
 function createBuffer() {
   let appendable = false
@@ -59,39 +75,38 @@ function unrollBuffer(buffer: ResolvedSSRBuffer): string {
 }
 
 export async function renderToString(app: App): Promise<string> {
-  const resolvedBuffer = await renderComponent(app._component, app._props)
+  const resolvedBuffer = await renderComponent(
+    createVNode(app._component, app._props)
+  )
   return unrollBuffer(resolvedBuffer)
 }
 
 export function renderComponent(
-  comp: Component,
-  props: Record<string, any> | null = null,
-  children: VNode['children'] = null,
+  vnode: VNode,
   parentComponent: ComponentInternalInstance | null = null
 ): ResolvedSSRBuffer | Promise<ResolvedSSRBuffer> {
-  const vnode = createVNode(comp, props, children)
   const instance = createComponentInstance(vnode, parentComponent)
   const res = setupComponent(instance, null)
   if (isPromise(res)) {
-    return res.then(() => innerRenderComponent(comp, instance))
+    return res.then(() => innerRenderComponent(instance))
   } else {
-    return innerRenderComponent(comp, instance)
+    return innerRenderComponent(instance)
   }
 }
 
 function innerRenderComponent(
-  comp: Component,
   instance: ComponentInternalInstance
 ): ResolvedSSRBuffer | Promise<ResolvedSSRBuffer> {
+  const comp = instance.type as Component
   const { buffer, push, hasAsync } = createBuffer()
   if (isFunction(comp)) {
-    renderVNode(push, renderComponentRoot(instance))
+    renderVNode(push, renderComponentRoot(instance), instance)
   } else {
     if (comp.ssrRender) {
       // optimized
       comp.ssrRender(push, instance.proxy)
     } else if (comp.render) {
-      renderVNode(push, renderComponentRoot(instance))
+      renderVNode(push, renderComponentRoot(instance), instance)
     } else {
       // TODO on the fly template compilation support
       throw new Error(
@@ -107,8 +122,103 @@ function innerRenderComponent(
   return hasAsync() ? Promise.all(buffer) : (buffer as ResolvedSSRBuffer)
 }
 
-export function renderVNode(push: (item: SSRBufferItem) => void, vnode: VNode) {
-  // TODO
+export function renderVNode(
+  push: PushFn,
+  vnode: VNode,
+  parentComponent: ComponentInternalInstance | null = null
+) {
+  const { type, shapeFlag, children } = vnode
+  switch (type) {
+    case Text:
+      push(children as string)
+      break
+    case Comment:
+      push(children ? `<!--${children}-->` : `<!---->`)
+      break
+    case Fragment:
+      push(`<!---->`)
+      renderVNodeChildren(push, children as VNodeChildren, parentComponent)
+      push(`<!---->`)
+      break
+    case Portal:
+      // TODO
+      break
+    default:
+      if (shapeFlag & ShapeFlags.ELEMENT) {
+        renderElement(push, vnode, parentComponent)
+      } else if (shapeFlag & ShapeFlags.COMPONENT) {
+        push(renderComponent(vnode, parentComponent))
+      } else if (shapeFlag & ShapeFlags.SUSPENSE) {
+        // TODO
+      } else {
+        console.warn(
+          '[@vue/server-renderer] Invalid VNode type:',
+          type,
+          `(${typeof type})`
+        )
+      }
+  }
+}
+
+function renderVNodeChildren(
+  push: PushFn,
+  children: VNodeChildren,
+  parentComponent: ComponentInternalInstance | null = null
+) {
+  for (let i = 0; i < children.length; i++) {
+    renderVNode(push, normalizeVNode(children[i]), parentComponent)
+  }
+}
+
+function renderElement(
+  push: PushFn,
+  vnode: VNode,
+  parentComponent: ComponentInternalInstance | null = null
+) {
+  const tag = vnode.type as string
+  const { props, children, shapeFlag, scopeId } = vnode
+  let openTag = `<${tag}`
+
+  // TODO directives
+
+  if (props !== null) {
+    openTag += renderProps(props, tag.indexOf(`-`) > 0)
+  }
+
+  if (scopeId !== null) {
+    openTag += ` ${scopeId}`
+    const treeOwnerId = parentComponent && parentComponent.type.__scopeId
+    // vnode's own scopeId and the current rendering component's scopeId is
+    // different - this is a slot content node.
+    if (treeOwnerId != null && treeOwnerId !== scopeId) {
+      openTag += ` ${scopeId}-s`
+    }
+  }
+
+  push(openTag + `>`)
+  if (!isVoidTag(tag)) {
+    let hasChildrenOverride = false
+    if (props !== null) {
+      if (props.innerHTML) {
+        hasChildrenOverride = true
+        push(props.innerHTML)
+      } else if (props.textContent) {
+        hasChildrenOverride = true
+        push(escape(props.textContent))
+      } else if (tag === 'textarea' && props.value) {
+        hasChildrenOverride = true
+        push(escape(props.value))
+      }
+    }
+    if (!hasChildrenOverride) {
+      if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
+        push(escape(children as string))
+      } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
+        renderVNodeChildren(push, children as VNodeChildren, parentComponent)
+      }
+    }
+    push(`</${tag}>`)
+  }
 }
 
 export function renderSlot() {
diff --git a/packages/shared/src/domAttrConfig.ts b/packages/shared/src/domAttrConfig.ts
new file mode 100644 (file)
index 0000000..db8fc32
--- /dev/null
@@ -0,0 +1,50 @@
+import { makeMap } from './makeMap'
+
+// TODO validate this list!
+// on the client, most of these probably has corresponding prop
+// or, like allowFullscreen on iframe, although case is different, the attr
+// affects the property properly...
+// Basically, we can skip this check on the client
+// but they are still needed during SSR to produce correct initial markup
+export const isBooleanAttr = makeMap(
+  'allowfullscreen,async,autofocus,autoplay,checked,compact,controls,declare,' +
+    'default,defaultchecked,defaultmuted,defaultselected,defer,disabled,' +
+    'enabled,formnovalidate,hidden,indeterminate,inert,ismap,itemscope,loop,multiple,' +
+    'muted,nohref,noresize,noshade,novalidate,nowrap,open,pauseonexit,readonly,' +
+    'required,reversed,scoped,seamless,selected,sortable,translate,' +
+    'truespeed,typemustmatch,visible'
+)
+
+const unsafeAttrCharRE = /[>/="'\u0009\u000a\u000c\u0020]/
+const attrValidationCache: Record<string, boolean> = {}
+
+export function isSSRSafeAttrName(name: string): boolean {
+  if (attrValidationCache.hasOwnProperty(name)) {
+    return attrValidationCache[name]
+  }
+  const isUnsafe = unsafeAttrCharRE.test(name)
+  if (isUnsafe) {
+    console.error(`unsafe attribute name: ${name}`)
+  }
+  return (attrValidationCache[name] = !isUnsafe)
+}
+
+export const propsToAttrMap: Record<string, string | undefined> = {
+  acceptCharset: 'accept-charset',
+  className: 'class',
+  htmlFor: 'for',
+  httpEquiv: 'http-equiv'
+}
+
+// CSS properties that accept plain numbers
+export const isNoUnitNumericStyleProp = makeMap(
+  `animation-iteration-count,border-image-outset,border-image-slice,` +
+    `border-image-width,box-flex,box-flex-group,box-ordinal-group,column-count,` +
+    `columns,flex,flex-grow,flex-positive,flex-shrink,flex-negative,flex-order,` +
+    `grid-row,grid-row-end,grid-row-span,grid-row-start,grid-column,` +
+    `grid-column-end,grid-column-span,grid-column-start,font-weight,line-clamp,` +
+    `line-height,opacity,order,orphans,tab-size,widows,z-index,zoom,` +
+    // SVG
+    `fill-opacity,flood-opacity,stop-opacity,stroke-dasharray,stroke-dashoffset,` +
+    `stroke-miterlimit,stroke-opacity,stroke-width`
+)
index 55b5d3ea7da8e4cbf386813d2b2e02a806d891da..59b5bb648ee258c72b8c484b9c1706456ea9d9aa 100644 (file)
@@ -4,9 +4,10 @@ export { makeMap }
 export * from './patchFlags'
 export * from './globalsWhitelist'
 export * from './codeframe'
-export * from './domTagConfig'
 export * from './mockWarn'
 export * from './normalizeProp'
+export * from './domTagConfig'
+export * from './domAttrConfig'
 
 export const EMPTY_OBJ: { readonly [key: string]: any } = __DEV__
   ? Object.freeze({})
index 8829710c1e5ffd739b5783ab6837f2384201e74b..96678e8bc94a20d908f3d3a2825535599b874522 100644 (file)
@@ -2,7 +2,7 @@ import { isArray, isString, isObject } from './'
 
 export function normalizeStyle(
   value: unknown
-): Record<string, string | number> | void {
+): Record<string, string | number> | undefined {
   if (isArray(value)) {
     const res: Record<string, string | number> = {}
     for (let i = 0; i < value.length; i++) {