Vue render函數詳解

渲染函數 & JSX

基礎

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

讓我們深入一個簡單的例子,這個例子里 render 函數很實用。假設我們要生成一些帶錨點的標題:

<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
    }
  }
})

這里用模板并不是最好的選擇:不但代碼冗長,而且在每一個級別的標題中重復書寫了 <slot></slot>,在要插入錨點元素時還要再次重復。

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

Vue.component('anchored-heading', {
  render: function (createElement) {
    return createElement(
      'h' + this.level,   // 標簽名稱
      this.$slots.default // 子節點數組
    )
  },
  props: {
    level: {
      type: Number,
      required: true
    }
  }
})

看起來簡單多了!這樣代碼精簡很多,但是需要非常熟悉 Vue 的實例 property。在這個例子中,你需要知道,向組件中傳遞不帶 v-slot 指令的子節點時,比如 anchored-heading 中的 Hello world!,這些子節點被存儲在組件實例中的 $slots.default 中。如果你還不了解,在深入渲染函數之前推薦閱讀實例 property API

節點、樹以及虛擬 DOM

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

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

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

上述 HTML 對應的 DOM 節點樹如下圖所示:

DOM 樹可視化

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

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

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

或者一個渲染函數里:

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

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

虛擬 DOM

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 標簽名、組件選項對象,或者
  // resolve 了上述任何一種的一個 async 函數。必填項。
  'div',

  // {Object}
  // 一個與模板中 attribute 對應的數據對象。可選。
  {
    // (詳情見下一節)
  },

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

深入數據對象

有一點要注意:正如 v-bind:classv-bind:style 在模板語法中會被特別對待一樣,它們在 VNode 數據對象中也有對應的頂層字段。該對象也允許你綁定普通的 HTML attribute,也允許綁定如 innerHTML 這樣的 DOM property (這會覆蓋 v-html 指令)。

{
  // 與 `v-bind:class` 的 API 相同,
  // 接受一個字符串、對象或字符串和對象組成的數組
  'class': {
    foo: true,
    bar: false
  },
  // 與 `v-bind:style` 的 API 相同,
  // 接受一個字符串、對象,或對象組成的數組
  style: {
    color: 'red',
    fontSize: '14px'
  },
  // 普通的 HTML attribute
  attrs: {
    id: 'foo'
  },
  // 組件 prop
  props: {
    myProp: 'bar'
  },
  // DOM property
  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
      }
    }
  ],
  // 作用域插槽的格式為
  // { name: props => VNode | Array<VNode> }
  scopedSlots: {
    default: props => createElement('span', props.text)
  },
  // 如果組件是其它組件的子組件,需為插槽指定名稱
  slot: 'name-of-slot',
  // 其它特殊頂層 property
  key: 'myKey',
  ref: 'myRef',
  // 如果你在渲染函數中給多個元素都應用了相同的 ref 名,
  // 那么 `$refs.myRef` 會變成一個數組。
  refInFor: true
}

完整示例

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

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) {
    // 創建 kebab-case 風格的 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
    }
  }
})

約束

VNode 必須唯一

組件樹中的所有 VNode 必須是唯一的。這意味著,下面的渲染函數是不合法的:

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

如果你真的需要重復很多次的元素/組件,你可以使用工廠函數來實現。例如,下面這渲染函數用完全合法的方式渲染了 20 個相同的段落:

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

使用 JavaScript 代替模板功能

v-ifv-for

只要在原生的 JavaScript 中可以輕松完成的操作,Vue 的渲染函數就不會提供專有的替代方法。比如,在模板中使用的 v-ifv-for

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

這些都可以在渲染函數中用 JavaScript 的 if/elsemap 來重寫:

props: ['items'],
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

渲染函數中沒有與 v-model 的直接對應——你必須自己實現相應的邏輯:

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

這就是深入底層的代價,但與 v-model 相比,這可以讓你更好地控制交互細節。

事件 & 按鍵修飾符

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

修飾符 前綴
.passive &
.capture !
.once ~
.capture.once
.once.capture ~!

例如:

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

對于所有其它的修飾符,私有前綴都不是必須的,因為你可以在事件處理函數中使用事件方法:

修飾符 處理函數中的等價操作
.stop event.stopPropagation()
.prevent event.preventDefault()
.self if (event.target !== event.currentTarget) return
按鍵:
.enter, .13 if (event.keyCode !== 13) return (對于別的按鍵修飾符來說,可將 13 改為另一個按鍵碼)
修飾鍵:
.ctrl, .alt, .shift, .meta if (!event.ctrlKey) return (將 ctrlKey 分別修改為 altKeyshiftKey 或者 metaKey)

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

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 訪問靜態插槽的內容,每個插槽都是一個 VNode 數組:

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

也可以通過 this.$scopedSlots 訪問作用域插槽,每個作用域插槽都是一個返回若干 VNode 的函數:

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

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

render: function (createElement) {
  // `<div><child v-slot="props"><span>{{ props.text }}</span></child></div>`
  return createElement('div', [
    createElement('child', {
      // 在數據對象中傳遞 `scopedSlots`
      // 格式為 { 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: function (h) {
    return (
      <AnchoredHeading level={1}>
        <span>Hello</span> world!
      </AnchoredHeading>
    )
  }
})

h 作為 createElement 的別名是 Vue 生態系統中的一個通用慣例,實際上也是 JSX 所要求的。從 Vue 的 Babel 插件的 3.4.0 版本開始,我們會在以 ES2015 語法聲明的含有 JSX 的任何方法和 getter 中 (不是函數或箭頭函數中) 自動注入 const h = this.$createElement,這樣你就可以去掉 (h) 參數了。對于更早版本的插件,如果 h 在當前作用域中不可用,應用會拋錯。

要了解更多關于 JSX 如何映射到 JavaScript,請閱讀使用文檔

函數式組件

之前創建的錨點標題組件是比較簡單,沒有管理任何狀態,也沒有監聽任何傳遞給它的狀態,也沒有生命周期方法。實際上,它只是一個接受一些 prop 的函數。在這樣的場景下,我們可以將組件標記為 functional,這意味它無狀態 (沒有響應式數據),也沒有實例 (沒有 this 上下文)。一個函數式組件就像這樣:

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

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

當使用函數式組件時,該引用將會是 HTMLElement,因為他們是無狀態的也是無實例的。

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

<template functional>
</template>

組件需要的一切都是通過 context 參數傳遞,它是一個包括如下字段的對象:

  • props:提供所有 prop 的對象
  • children:VNode 子節點的數組
  • slots:一個函數,返回了包含所有插槽的對象
  • scopedSlots:(2.6.0+) 一個暴露傳入的作用域插槽的對象。也以函數形式暴露普通插槽。
  • data:傳遞給組件的整個數據對象,作為 createElement 的第二個參數傳入組件
  • parent:對父組件的引用
  • listeners:(2.3.0+) 一個包含了所有父組件為當前組件注冊的事件監聽器的對象。這是 data.on 的一個別名。
  • injections:(2.3.0+) 如果使用了 inject 選項,則該對象包含了應當被注入的 property。

在添加 functional: true 之后,需要更新我們的錨點標題組件的渲染函數,為其增加 context 參數,并將 this.$slots.default 更新為 context.children,然后將 this.level 更新為 context.props.level

因為函數式組件只是函數,所以渲染開銷也低很多。

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

  • 程序化地在多個組件中選擇一個來代為渲染;
  • 在將 childrenpropsdata 傳遞給子組件之前操作它們。

下面是一個 smart-list 組件的例子,它能根據傳入 prop 的值來代為渲染更具體的組件:

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

Vue.component('smart-list', {
  functional: true,
  props: {
    items: {
      type: Array,
      required: true
    },
    isOrdered: Boolean
  },
  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
    )
  }
})

向子元素或子組件傳遞 attribute 和事件

在普通組件中,沒有被定義為 prop 的 attribute 會自動添加到組件的根元素上,將已有的同名 attribute 進行替換或與其進行智能合并

然而函數式組件要求你顯式定義該行為:

Vue.component('my-functional-button', {
  functional: true,
  render: function (createElement, context) {
    // 完全透傳任何 attribute、事件監聽器、子節點等。
    return createElement('button', context.data, context.children)
  }
})

通過向 createElement 傳入 context.data 作為第二個參數,我們就把 my-functional-button 上面所有的 attribute 和事件監聽器都傳遞下去了。事實上這是非常透明的,以至于那些事件甚至并不要求 .native 修飾符。

如果你使用基于模板的函數式組件,那么你還需要手動添加 attribute 和監聽器。因為我們可以訪問到其獨立的上下文內容,所以我們可以使用 data.attrs 傳遞任何 HTML attribute,也可以使用 listeners (即 data.on 的別名) 傳遞任何事件監聽器。

<template functional>
  <button
    class="btn btn-primary"
    v-bind="data.attrs"
    v-on="listeners"
  >
    <slot/>
  </button>
</template>

slots()children 對比

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

<my-functional-component>
  <p v-slot:foo>
    first
  </p>
  <p>second</p>
</my-functional-component>

對于這個組件,children 會給你兩個段落標簽,而 slots().default 只會傳遞第二個匿名段落標簽,slots().foo 會傳遞第一個具名段落標簽。同時擁有 childrenslots(),因此你可以選擇讓組件感知某個插槽機制,還是簡單地通過傳遞 children,移交給其它組件去處理。

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,488評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,034評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,327評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,554評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,337評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,883評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,975評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,114評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,625評論 1 332
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,555評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,737評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,244評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,973評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,615評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,343評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,699評論 2 370

推薦閱讀更多精彩內容

  • 什么是函數式組件? 函數式組件就是函數是組件,組件是函數,它的特征是沒有內部狀態、沒有生命周期鉤子函數、沒有thi...
    microkof閱讀 6,220評論 2 9
  • render 函數,大部分工老油條,應該是比較了解了,但是可能有些初出茅廬的小年輕們,不是很了解,并且嚴老濕也去網...
    悲傷日記_Yan閱讀 1,481評論 0 0
  • vue.js `vue.js`是一套用于構建用戶界面的漸進式框架 漸進式 Vue核心 -聲明式渲染 -組件 引入...
    kino2046閱讀 372評論 0 0
  • Vue.directive( id, [definition] )[https://cn.vuejs.org/v2...
    莫伊劍客閱讀 363評論 0 0
  • vue.js vue.js 是一套用于構建用戶界面的漸進式框架 漸進式 Vue 核心 聲明式渲染 組件 引入 我們...
    lucky_yao閱讀 266評論 0 0