]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(ssr): support getSSRProps for vnode directives
authorEvan You <yyx990803@gmail.com>
Mon, 16 Mar 2020 22:36:19 +0000 (18:36 -0400)
committerEvan You <yyx990803@gmail.com>
Mon, 16 Mar 2020 22:36:19 +0000 (18:36 -0400)
packages/runtime-core/src/directives.ts
packages/runtime-dom/src/directives/vModel.ts
packages/runtime-dom/src/directives/vShow.ts
packages/server-renderer/__tests__/ssrDirectives.spec.ts [new file with mode: 0644]
packages/server-renderer/src/renderToString.ts

index d3e638f9010e9ab7ad4fac4e6403dadb4e4b52c4..f128fcffc14cb8e2d732612adf3e01d831ded086 100644 (file)
@@ -14,7 +14,7 @@ return withDirectives(h(comp), [
 import { VNode } from './vnode'
 import { isFunction, EMPTY_OBJ, makeMap, EMPTY_ARR } from '@vue/shared'
 import { warn } from './warning'
-import { ComponentInternalInstance } from './component'
+import { ComponentInternalInstance, Data } from './component'
 import { currentRenderingInstance } from './componentRenderUtils'
 import { callWithAsyncErrorHandling, ErrorCodes } from './errorHandling'
 import { ComponentPublicInstance } from './componentProxy'
@@ -35,6 +35,11 @@ export type DirectiveHook<T = any> = (
   prevVNode: VNode<any, T> | null
 ) => void
 
+export type SSRDirectiveHook = (
+  binding: DirectiveBinding,
+  vnode: VNode
+) => Data | undefined
+
 export interface ObjectDirective<T = any> {
   beforeMount?: DirectiveHook<T>
   mounted?: DirectiveHook<T>
@@ -42,6 +47,7 @@ export interface ObjectDirective<T = any> {
   updated?: DirectiveHook<T>
   beforeUnmount?: DirectiveHook<T>
   unmounted?: DirectiveHook<T>
+  getSSRProps?: SSRDirectiveHook
 }
 
 export type FunctionDirective<T = any> = DirectiveHook<T>
@@ -81,7 +87,7 @@ const directiveToVnodeHooksMap = /*#__PURE__*/ [
       const prevBindings = prevVnode ? prevVnode.dirs! : EMPTY_ARR
       for (let i = 0; i < bindings.length; i++) {
         const binding = bindings[i]
-        const hook = binding.dir[key]
+        const hook = binding.dir[key] as DirectiveHook
         if (hook != null) {
           if (prevVnode != null) {
             binding.oldValue = prevBindings[i].value
index 8b1343944f40c71288e2a712fa757ea4dff7c405..4633648959754cfaa0bbc9b9449cb3a049663ce5 100644 (file)
@@ -218,7 +218,7 @@ function callModelHook(
   binding: DirectiveBinding,
   vnode: VNode,
   prevVNode: VNode | null,
-  hook: keyof ObjectDirective
+  hook: 'beforeMount' | 'mounted' | 'beforeUpdate' | 'updated'
 ) {
   let modelToUse: ObjectDirective
   switch (el.tagName) {
@@ -243,3 +243,24 @@ function callModelHook(
   const fn = modelToUse[hook]
   fn && fn(el, binding, vnode, prevVNode)
 }
+
+// SSR vnode transforms
+if (__NODE_JS__) {
+  vModelText.getSSRProps = ({ value }) => ({ value })
+
+  vModelRadio.getSSRProps = ({ value }, vnode) => {
+    if (vnode.props && looseEqual(vnode.props.value, value)) {
+      return { checked: true }
+    }
+  }
+
+  vModelCheckbox.getSSRProps = ({ value }, vnode) => {
+    if (isArray(value)) {
+      if (vnode.props && looseIndexOf(value, vnode.props.value) > -1) {
+        return { checked: true }
+      }
+    } else if (value) {
+      return { checked: true }
+    }
+  }
+}
index 5f2743a55fb3ad5a061ecd98c1bff8f0bc5874ff..2b1d9caef041e9ca18e2ecf5ef4f389e355bfd2b 100644 (file)
@@ -40,6 +40,14 @@ export const vShow: ObjectDirective<VShowElement> = {
   }
 }
 
+if (__NODE_JS__) {
+  vShow.getSSRProps = ({ value }) => {
+    if (!value) {
+      return { style: { display: 'none' } }
+    }
+  }
+}
+
 function setDisplay(el: VShowElement, value: unknown): void {
   el.style.display = value ? el._vod : 'none'
 }
diff --git a/packages/server-renderer/__tests__/ssrDirectives.spec.ts b/packages/server-renderer/__tests__/ssrDirectives.spec.ts
new file mode 100644 (file)
index 0000000..a3ba59c
--- /dev/null
@@ -0,0 +1,393 @@
+import { renderToString } from '../src/renderToString'
+import {
+  createApp,
+  h,
+  withDirectives,
+  vShow,
+  vModelText,
+  vModelRadio,
+  vModelCheckbox
+} from 'vue'
+
+describe('ssr: directives', () => {
+  describe('template v-show', () => {
+    test('basic', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            template: `<div v-show="true"/>`
+          })
+        )
+      ).toBe(`<div style=""></div>`)
+
+      expect(
+        await renderToString(
+          createApp({
+            template: `<div v-show="false"/>`
+          })
+        )
+      ).toBe(`<div style="display:none;"></div>`)
+    })
+
+    test('with static style', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            template: `<div style="color:red" v-show="false"/>`
+          })
+        )
+      ).toBe(`<div style="color:red;display:none;"></div>`)
+    })
+
+    test('with dynamic style', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ style: { color: 'red' } }),
+            template: `<div :style="style" v-show="false"/>`
+          })
+        )
+      ).toBe(`<div style="color:red;display:none;"></div>`)
+    })
+
+    test('with static + dynamic style', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ style: { color: 'red' } }),
+            template: `<div :style="style" style="font-size:12;" v-show="false"/>`
+          })
+        )
+      ).toBe(`<div style="color:red;font-size:12;display:none;"></div>`)
+    })
+  })
+
+  describe('template v-model', () => {
+    test('text', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ text: 'hello' }),
+            template: `<input v-model="text">`
+          })
+        )
+      ).toBe(`<input value="hello">`)
+    })
+
+    test('radio', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ selected: 'foo' }),
+            template: `<input type="radio" value="foo" v-model="selected">`
+          })
+        )
+      ).toBe(`<input type="radio" value="foo" checked>`)
+
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ selected: 'foo' }),
+            template: `<input type="radio" value="bar" v-model="selected">`
+          })
+        )
+      ).toBe(`<input type="radio" value="bar">`)
+
+      // non-string values
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ selected: 'foo' }),
+            template: `<input type="radio" :value="{}" v-model="selected">`
+          })
+        )
+      ).toBe(`<input type="radio">`)
+    })
+
+    test('checkbox', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ checked: true }),
+            template: `<input type="checkbox" v-model="checked">`
+          })
+        )
+      ).toBe(`<input type="checkbox" checked>`)
+
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ checked: false }),
+            template: `<input type="checkbox" v-model="checked">`
+          })
+        )
+      ).toBe(`<input type="checkbox">`)
+
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ checked: ['foo'] }),
+            template: `<input type="checkbox" value="foo" v-model="checked">`
+          })
+        )
+      ).toBe(`<input type="checkbox" value="foo" checked>`)
+
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ checked: [] }),
+            template: `<input type="checkbox" value="foo" v-model="checked">`
+          })
+        )
+      ).toBe(`<input type="checkbox" value="foo">`)
+    })
+
+    test('textarea', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ foo: 'hello' }),
+            template: `<textarea v-model="foo"/>`
+          })
+        )
+      ).toBe(`<textarea>hello</textarea>`)
+    })
+
+    test('dynamic type', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ type: 'text', model: 'hello' }),
+            template: `<input :type="type" v-model="model">`
+          })
+        )
+      ).toBe(`<input type="text" value="hello">`)
+
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ type: 'checkbox', model: true }),
+            template: `<input :type="type" v-model="model">`
+          })
+        )
+      ).toBe(`<input type="checkbox" checked>`)
+
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ type: 'checkbox', model: false }),
+            template: `<input :type="type" v-model="model">`
+          })
+        )
+      ).toBe(`<input type="checkbox">`)
+
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ type: 'checkbox', model: ['hello'] }),
+            template: `<input :type="type" value="hello" v-model="model">`
+          })
+        )
+      ).toBe(`<input type="checkbox" value="hello" checked>`)
+
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ type: 'checkbox', model: [] }),
+            template: `<input :type="type" value="hello" v-model="model">`
+          })
+        )
+      ).toBe(`<input type="checkbox" value="hello">`)
+
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ type: 'radio', model: 'hello' }),
+            template: `<input :type="type" value="hello" v-model="model">`
+          })
+        )
+      ).toBe(`<input type="radio" value="hello" checked>`)
+
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({ type: 'radio', model: 'hello' }),
+            template: `<input :type="type" value="bar" v-model="model">`
+          })
+        )
+      ).toBe(`<input type="radio" value="bar">`)
+    })
+
+    test('with v-bind', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            data: () => ({
+              obj: { type: 'radio', value: 'hello' },
+              model: 'hello'
+            }),
+            template: `<input v-bind="obj" v-model="model">`
+          })
+        )
+      ).toBe(`<input type="radio" value="hello" checked>`)
+    })
+  })
+
+  describe('vnode v-show', () => {
+    test('basic', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            render() {
+              return withDirectives(h('div'), [[vShow, true]])
+            }
+          })
+        )
+      ).toBe(`<div></div>`)
+
+      expect(
+        await renderToString(
+          createApp({
+            render() {
+              return withDirectives(h('div'), [[vShow, false]])
+            }
+          })
+        )
+      ).toBe(`<div style="display:none;"></div>`)
+    })
+
+    test('with merge', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            render() {
+              return withDirectives(
+                h('div', {
+                  style: {
+                    color: 'red'
+                  }
+                }),
+                [[vShow, false]]
+              )
+            }
+          })
+        )
+      ).toBe(`<div style="color:red;display:none;"></div>`)
+    })
+  })
+
+  describe('vnode v-model', () => {
+    test('text', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            render() {
+              return withDirectives(h('input'), [[vModelText, 'hello']])
+            }
+          })
+        )
+      ).toBe(`<input value="hello">`)
+    })
+
+    test('radio', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            render() {
+              return withDirectives(
+                h('input', { type: 'radio', value: 'hello' }),
+                [[vModelRadio, 'hello']]
+              )
+            }
+          })
+        )
+      ).toBe(`<input type="radio" value="hello" checked>`)
+
+      expect(
+        await renderToString(
+          createApp({
+            render() {
+              return withDirectives(
+                h('input', { type: 'radio', value: 'hello' }),
+                [[vModelRadio, 'foo']]
+              )
+            }
+          })
+        )
+      ).toBe(`<input type="radio" value="hello">`)
+    })
+
+    test('checkbox', async () => {
+      expect(
+        await renderToString(
+          createApp({
+            render() {
+              return withDirectives(h('input', { type: 'checkbox' }), [
+                [vModelCheckbox, true]
+              ])
+            }
+          })
+        )
+      ).toBe(`<input type="checkbox" checked>`)
+
+      expect(
+        await renderToString(
+          createApp({
+            render() {
+              return withDirectives(h('input', { type: 'checkbox' }), [
+                [vModelCheckbox, false]
+              ])
+            }
+          })
+        )
+      ).toBe(`<input type="checkbox">`)
+
+      expect(
+        await renderToString(
+          createApp({
+            render() {
+              return withDirectives(
+                h('input', { type: 'checkbox', value: 'foo' }),
+                [[vModelCheckbox, ['foo']]]
+              )
+            }
+          })
+        )
+      ).toBe(`<input type="checkbox" value="foo" checked>`)
+
+      expect(
+        await renderToString(
+          createApp({
+            render() {
+              return withDirectives(
+                h('input', { type: 'checkbox', value: 'foo' }),
+                [[vModelCheckbox, []]]
+              )
+            }
+          })
+        )
+      ).toBe(`<input type="checkbox" value="foo">`)
+    })
+  })
+
+  test('custom directive w/ getSSRProps', async () => {
+    expect(
+      await renderToString(
+        createApp({
+          render() {
+            return withDirectives(h('div'), [
+              [
+                {
+                  getSSRProps({ value }) {
+                    return { id: value }
+                  }
+                },
+                'foo'
+              ]
+            ])
+          }
+        })
+      )
+    ).toBe(`<div id="foo"></div>`)
+  })
+})
index 88438efb0039bc1e081ad87ec55a9819bb20bb3b..6f12f44c49fdd53562fe624895fe86d85ca47d73 100644 (file)
@@ -12,7 +12,10 @@ import {
   Slots,
   createApp,
   ssrContextKey,
-  warn
+  warn,
+  DirectiveBinding,
+  VNodeProps,
+  mergeProps
 } from 'vue'
 import {
   ShapeFlags,
@@ -289,10 +292,12 @@ function renderElementVNode(
   parentComponent: ComponentInternalInstance
 ) {
   const tag = vnode.type as string
-  const { props, children, shapeFlag, scopeId } = vnode
+  let { props, children, shapeFlag, scopeId, dirs } = vnode
   let openTag = `<${tag}`
 
-  // TODO directives
+  if (dirs !== null) {
+    props = applySSRDirectives(vnode, props, dirs)
+  }
 
   if (props !== null) {
     openTag += ssrRenderAttrs(props, tag)
@@ -338,6 +343,25 @@ function renderElementVNode(
   }
 }
 
+function applySSRDirectives(
+  vnode: VNode,
+  rawProps: VNodeProps | null,
+  dirs: DirectiveBinding[]
+): VNodeProps {
+  const toMerge: VNodeProps[] = []
+  for (let i = 0; i < dirs.length; i++) {
+    const binding = dirs[i]
+    const {
+      dir: { getSSRProps }
+    } = binding
+    if (getSSRProps) {
+      const props = getSSRProps(binding, vnode)
+      if (props) toMerge.push(props)
+    }
+  }
+  return mergeProps(rawProps || {}, ...toMerge)
+}
+
 function renderPortalVNode(
   vnode: VNode,
   parentComponent: ComponentInternalInstance