vue從入門到進階:渲染函數 & JSX(八)


Vue 推薦在絕大多數情況下使用 template 來創建你的 HTML。然而在一些場景中,你真的需要 JavaScript 的完全編程的能力,這就是 render 函數,它比 template 更接近編譯器。

<h1>
  <a name="hello-world" href="#hello-world">
    Hello world!
  </a>
</h1>

在 HTML 層,我們決定這樣定義組件接口:

<anchored-heading :level="1">Hello world!</anchored-heading>

當我們開始寫一個通過 level prop 動態生成 heading 標簽的組件,你可能很快想到這樣實現:

<script type="text/x-template" id="anchored-heading-template">
  <h1 v-if="level === 1">
    <slot></slot>
  </h1>
  <h2 v-else-if="level === 2">
    <slot></slot>
  </h2>
  <h3 v-else-if="level === 3">
    <slot></slot>
  </h3>
  <h4 v-else-if="level === 4">
    <slot></slot>
  </h4>
  <h5 v-else-if="level === 5">
    <slot></slot>
  </h5>
  <h6 v-else-if="level === 6">
    <slot></slot>
  </h6>
</script>
Vue.component('anchored-heading', {
  template: '#anchored-heading-template',
  props: {
    level: {
      type: Number,
      required: true
    }
  }
})

在這種場景中使用 template 並不是最好的選擇:首先代碼冗長,為了在不同級別的標題中插入錨點元素,我們需要重復地使用 <slot></slot>

雖然模板在大多數組件中都非常好用,但是在這里它就不是很簡潔的了。那么,我們來嘗試使用 render 函數重寫上面的例子:

Vue.component('anchored-heading', {
  render: function (createElement) {
    return createElement(
      'h' + this.level,   // tag name 標簽名稱
      this.$slots.default // 子組件中的陣列
    )
  },
  props: {
    level: {
      type: Number,
      required: true
    }
  }
})

簡單清晰很多!簡單來說,這樣代碼精簡很多,但是需要非常熟悉 Vue 的實例屬性。在這個例子中,你需要知道當你不使用 slot 屬性向組件中傳遞內容時,比如 anchored-heading 中的 Hello world!,這些子元素被存儲在組件實例中的 $slots.default中。

節點、樹以及虛擬 DOM

在深入渲染函數之前,了解一些瀏覽器的工作原理是很重要的。以下面這段 HTML 為例:

<div>
  <h1>My title</h1>
  Some text content
  <!-- TODO: Add tagline -->
</div>

當瀏覽器讀到這些代碼時,它會建立一個“DOM 節點”樹來保持追蹤,如同你會畫一張家譜樹來追蹤家庭成員的發展一樣。

HTML 的 DOM 節點樹如下圖所示:

每個元素都是一個節點。每片文字也是一個節點。甚至注釋也都是節點。一個節點就是頁面的一個部分。就像家譜樹一樣,每個節點都可以有孩子節點 (也就是說每個部分可以包含其它的一些部分)。

高效的更新所有這些節點會是比較困難的,不過所幸你不必再手動完成這個工作了。你只需要告訴 Vue 你希望頁面上的 HTML 是什么,這可以是在一個模板里:

<h1>{{ blogTitle }}</h1>

或者一個渲染函數里:

render: function (createElement) {
  return createElement('h1', this.blogTitle)
}

在這兩種情況下,Vue 都會自動保持頁面的更新,即便 blogTitle 發生了改變。

Vue 通過建立一個虛擬 DOM 對真實 DOM 發生的變化保持追蹤。請近距離看一下這行代碼:

return createElement('h1', this.blogTitle)

createElement 到底會返回什么呢?其實不是一個實際的 DOM 元素。它更准確的名字可能是 createNodeDescription,因為它所包含的信息會告訴 Vue 頁面上需要渲染什么樣的節點,及其子節點。我們把這樣的節點描述為“虛擬節點 (Virtual Node)”,也常簡寫它為“VNode”。“虛擬 DOM”是我們對由 Vue 組件樹建立起來的整個 VNode 樹的稱呼。

createElement 參數

接下來你需要熟悉的是如何在 createElement 函數中生成模板。這里是 createElement 接受的參數:

// @returns {VNode}
createElement(
  // {String | Object | Function}
  // 一個 HTML 標簽字符串,組件選項對象,或者一個返回值
  // 類型為 String/Object 的函數,必要參數
  'div',

  // {Object}
  // 一個包含模板相關屬性的數據對象
  // 這樣,您可以在 template 中使用這些屬性。可選參數。
  {
    // (詳情見下一節)
  },

  // {String | Array}
  // 子節點 (VNodes),由 `createElement()` 構建而成,
  // 或使用字符串來生成“文本節點”。可選參數。
  [
    '先寫一些文字',
    createElement('h1', '一則頭條'),
    createElement(MyComponent, {
      props: {
        someProp: 'foobar'
      }
    })
  ]
)

深入 data 對象

有一件事要注意:正如在模板語法中,v-bind:classv-bind:style ,會被特別對待一樣,在 VNode 數據對象中,下列屬性名是級別最高的字段。該對象也允許你綁定普通的 HTML 特性,就像 DOM 屬性一樣,比如 innerHTML (這會取代 v-html 指令)。

{
  // 和`v-bind:class`一樣的 API
  'class': {
    foo: true,
    bar: false
  },
  // 和`v-bind:style`一樣的 API
  style: {
    color: 'red',
    fontSize: '14px'
  },
  // 正常的 HTML 特性
  attrs: {
    id: 'foo'
  },
  // 組件 props
  props: {
    myProp: 'bar'
  },
  // DOM 屬性
  domProps: {
    innerHTML: 'baz'
  },
  // 事件監聽器基於 `on`
  // 所以不再支持如 `v-on:keyup.enter` 修飾器
  // 需要手動匹配 keyCode。
  on: {
    click: this.clickHandler
  },
  // 僅對於組件,用於監聽原生事件,而不是組件內部使用
  // `vm.$emit` 觸發的事件。
  nativeOn: {
    click: this.nativeClickHandler
  },
  // 自定義指令。注意,你無法對 `binding` 中的 `oldValue`
  // 賦值,因為 Vue 已經自動為你進行了同步。
  directives: [
    {
      name: 'my-custom-directive',
      value: '2',
      expression: '1 + 1',
      arg: 'foo',
      modifiers: {
        bar: true
      }
    }
  ],
  // Scoped slots in the form of
  // { name: props => VNode | Array<VNode> }
  scopedSlots: {
    default: props => createElement('span', props.text)
  },
  // 如果組件是其他組件的子組件,需為插槽指定名稱
  slot: 'name-of-slot',
  // 其他特殊頂層屬性
  key: 'myKey',
  ref: 'myRef'
}

完整示例

有了這些知識,我們現在可以完成我們最開始想實現的組件:

var getChildrenTextContent = function (children) {
  return children.map(function (node) {
    return node.children
      ? getChildrenTextContent(node.children)
      : node.text
  }).join('')
}

Vue.component('anchored-heading', {
  render: function (createElement) {
    // create kebabCase id
    var headingId = getChildrenTextContent(this.$slots.default)
      .toLowerCase()
      .replace(/\W+/g, '-')
      .replace(/(^\-|\-$)/g, '')

    return createElement(
      'h' + this.level,
      [
        createElement('a', {
          attrs: {
            name: headingId,
            href: '#' + headingId
          }
        }, this.$slots.default)
      ]
    )
  },
  props: {
    level: {
      type: Number,
      required: true
    }
  }
})

約束

VNodes 必須唯一

組件樹中的所有 VNodes 必須是唯一的。這意味着,下面的 render function 是無效的:

render: function (createElement) {
  var myParagraphVNode = createElement('p', 'hi')
  return createElement('div', [
    // 錯誤-重復的 VNodes
    myParagraphVNode, myParagraphVNode
  ])
}

如果你真的需要重復很多次的元素/組件,你可以使用工廠函數來實現。例如,下面這個例子 render 函數完美有效地渲染了 20 個重復的段落:

render: function (createElement) {
  return createElement('div',
    Array.apply(null, { length: 20 }).map(function () {
      return createElement('p', 'hi')
    })
  )
}

使用 JavaScript 代替模板功能

v-if 和 v-for

由於使用原生的 JavaScript 來實現某些東西很簡單,Vue 的 render 函數沒有提供專用的 API。比如,template 中的 v-if 和 v-for:

<ul v-if="items.length">
  <li v-for="item in items">{{ item.name }}</li>
</ul>
<p v-else>No items found.</p>

這些都會在 render 函數中被 JavaScript 的 if/else map 重寫:

render: function (createElement) {
  if (this.items.length) {
    return createElement('ul', this.items.map(function (item) {
      return createElement('li', item.name)
    }))
  } else {
    return createElement('p', 'No items found.')
  }
}

v-model

render 函數中沒有與 v-model 相應的 api - 你必須自己來實現相應的邏輯:

render: function (createElement) {
  var self = this
  return createElement('input', {
    domProps: {
      value: self.value
    },
    on: {
      input: function (event) {
        self.value = event.target.value
        self.$emit('input', event.target.value)
      }
    }
  })
}

這就是深入底層要付出的,盡管麻煩了一些,但相對於 v-model 來說,你可以更靈活地控制。

事件 & 按鍵修飾符

對於 .passive.capture.once事件修飾符, Vue 提供了相應的前綴可以用於 on:

Modifier(s) Prefix
.passive &
.capture !
.once ~
.capture.once or .once.capture ~!

例如:

on: {
  '!click': this.doThisInCapturingMode,
  '~keyup': this.doThisOnce,
  `~!mouseover`: this.doThisOnceInCapturingMode
}

對於其他的修飾符,前綴不是很重要,因為你可以在事件處理函數中使用事件方法:

Modifier(s) Equivalent in Handler
.stop event.stopPropagation()
.prevent event.preventDefault()
.self if (event.target !== event.currentTarget) return
Keys:.enter, .13 if (event.keyCode !== 13) return (change 13 to another key code for other key modifiers)
Modifiers Keys:.ctrl, .alt, .shift, .meta if (!event.ctrlKey) return (change ctrlKey to altKey, shiftKey, or metaKey, respectively)

這里是一個使用所有修飾符的例子:

on: {
  keyup: function (event) {
    // 如果觸發事件的元素不是事件綁定的元素
    // 則返回
    if (event.target !== event.currentTarget) return
    // 如果按下去的不是 enter 鍵或者
    // 沒有同時按下 shift 鍵
    // 則返回
    if (!event.shiftKey || event.keyCode !== 13) return
    // 阻止 事件冒泡
    event.stopPropagation()
    // 阻止該元素默認的 keyup 事件
    event.preventDefault()
    // ...
  }
}

插槽

你可以從 this.$slots 獲取 VNodes 列表中的靜態內容:

render: function (createElement) {
  // `<div><slot></slot></div>`
  return createElement('div', this.$slots.default)
}

還可以從 this.$scopedSlots 中獲得能用作函數的作用域插槽,這個函數返回 VNodes:

render: function (createElement) {
  // `<div><slot :text="msg"></slot></div>`
  return createElement('div', [
    this.$scopedSlots.default({
      text: this.msg
    })
  ])
}

如果要用渲染函數向子組件中傳遞作用域插槽,可以利用 VNode 數據中的 scopedSlots 域:

render (createElement) {
  return createElement('div', [
    createElement('child', {
      // pass `scopedSlots` in the data object
      // in the form of { name: props => VNode | Array<VNode> }
      scopedSlots: {
        default: function (props) {
          return createElement('span', props.text)
        }
      }
    })
  ])
}

JSX

如果你寫了很多 render 函數,可能會覺得痛苦:

createElement(
  'anchored-heading', {
    props: {
      level: 1
    }
  }, [
    createElement('span', 'Hello'),
    ' world!'
  ]
)

特別是模板如此簡單的情況下:

<anchored-heading :level="1">
  <span>Hello</span> world!
</anchored-heading>

這就是為什么會有一個 Babel 插件,用於在 Vue 中使用 JSX 語法的原因,它可以讓我們回到更接近於模板的語法上。

import AnchoredHeading from './AnchoredHeading.vue'

new Vue({
  el: '#demo',
  render (h) {
    return (
      <AnchoredHeading level={1}>
        <span>Hello</span> world!
      </AnchoredHeading>
    )
  }
})

將 h 作為 createElement 的別名是 Vue 生態系統中的一個通用慣例,實際上也是 JSX 所要求的,如果在作用域中 h 失去作用,在應用中會觸發報錯。

函數式組件

之前創建的錨點標題組件是比較簡單,沒有管理或者監聽任何傳遞給他的狀態,也沒有生命周期方法。它只是一個接收參數的函數。

在這個例子中,我們標記組件為 functional,這意味它是無狀態 (沒有 data),無實例 (沒有 this 上下文)。

一個 函數式組件 就像這樣:

Vue.component('my-component', {
  functional: true,
  // 為了彌補缺少的實例
  // 提供第二個參數作為上下文
  render: function (createElement, context) {
    // ...
  },
  // Props 可選
  props: {
    // ...
  }
})

注意:在 2.3.0 之前的版本中,如果一個函數式組件想要接受 props,則 props 選項是必須的。在 2.3.0 或以上的版本中,你可以省略 props 選項,所有組件上的屬性都會被自動解析為 props。

在 2.5.0 及以上版本中,如果你使用了單文件組件,那么基於模板的函數式組件可以這樣聲明:

<template functional>
</template>

組件需要的一切都是通過上下文傳遞,包括:

  • props:提供 props 的對象
  • children: VNode 子節點的數組
  • slots: slots 對象
  • data:傳遞給組件的 data 對象
  • parent:對父組件的引用
  • listeners: (2.3.0+) 一個包含了組件上所注冊的 v-on 偵聽器的對象。這只是一個指向 data.on 的別名。
  • injections: (2.3.0+) 如果使用了 inject 選項,則該對象包含了應當被注入的屬性。

在添加 functional: true 之后,錨點標題組件的 render 函數之間簡單更新增加 context 參數,this.$slots.default 更新為 context.children,之后this.level 更新為 context.props.level

因為函數式組件只是一個函數,所以渲染開銷也低很多。然而,對持久化實例的缺乏也意味着函數式組件不會出現在 Vue devtools 的組件樹里。

在作為包裝組件時它們也同樣非常有用,比如,當你需要做這些時:

  • 程序化地在多個組件中選擇一個
  • 在將 children, props, data 傳遞給子組件之前操作它們。

下面是一個依賴傳入 props 的值的 smart-list 組件例子,它能代表更多具體的組件:

var EmptyList = { /* ... */ }
var TableList = { /* ... */ }
var OrderedList = { /* ... */ }
var UnorderedList = { /* ... */ }

Vue.component('smart-list', {
  functional: true,
  render: function (createElement, context) {
    function appropriateListComponent () {
      var items = context.props.items

      if (items.length === 0)           return EmptyList
      if (typeof items[0] === 'object') return TableList
      if (context.props.isOrdered)      return OrderedList

      return UnorderedList
    }

    return createElement(
      appropriateListComponent(),
      context.data,
      context.children
    )
  },
  props: {
    items: {
      type: Array,
      required: true
    },
    isOrdered: Boolean
  }
})

slots() 和 children 對比

你可能想知道為什么同時需要 slots() childrenslots().default 不是和 children 類似的嗎?在一些場景中,是這樣,但是如果是函數式組件和下面這樣的 children 呢?

<my-functional-component>
  <p slot="foo">
    first
  </p>
  <p>second</p>
</my-functional-component>

對於這個組件,children 會給你兩個段落標簽,而 slots().default 只會傳遞第二個匿名段落標簽,slots().foo 會傳遞第一個具名段落標簽。同時擁有 childrenslots() ,因此你可以選擇讓組件通過 slot() 系統分發或者簡單的通過 children 接收,讓其他組件去處理。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM