什么是組件?
組件(Component)是 Vue.js 最強大的功能之一。組件可以擴展 HTML 元素,封裝可重用的代碼。在較高層面上,組件是自定義元素, Vue.js 的編譯器為它添加特殊功能。在有些情況下,組件也可以是原生 HTML 元素的形式,以 is 特性擴展。
使用組件
注冊
之前說過,我們可以通過以下方式創建一個 Vue 實例:
new Vue({ el: '#some-element', // 選項 })
要注冊一個全局組件,你可以使用 Vue.component(tagName, options)
。 例如:
Vue.component('my-component', { // 選項 })
對於自定義標簽名,Vue.js 不強制要求遵循 W3C規則 (小寫,並且包含一個短杠),盡管遵循這個規則比較好。
組件在注冊之后,便可以在父實例的模塊中以自定義元素<my-component></my-component>
的形式使用。要確保在初始化根實例 之前 注冊了組件:
<div id="example"> <my-component></my-component> </div>
// 注冊 Vue.component('my-component', { template: '<div>A custom component!</div>' }) // 創建根實例 new Vue({ el: '#example' })
渲染為:
<div id="example"> <div>A custom component!</div> </div>
局部注冊
不必在全局注冊每個組件。通過使用組件實例選項注冊,可以使組件僅在另一個實例/組件的作用域中可用:
var Child = { template: '<div>A custom component!</div>' } new Vue({ // ... components: { // <my-component> 將只在父模板可用 'my-component': Child } })
這種封裝也適用於其它可注冊的 Vue 功能,如指令。
DOM 模版解析說明
當使用 DOM 作為模版時(例如,將 el
選項掛載到一個已存在的元素上), 你會受到 HTML 的一些限制,因為 Vue 只有在瀏覽器解析和標准化 HTML 后才能獲取模版內容。尤其像這些元素 <ul>
, <ol>
, <table>
, <select>
限制了能被它包裹的元素,<option>
只能出現在其它元素內部。
在自定義組件中使用這些受限制的元素時會導致一些問題,例如:
<table> <my-row>...</my-row> </table>
自定義組件 <my-row>
被認為是無效的內容,因此在渲染的時候會導致錯誤。變通的方案是使用特殊的 is
屬性:
<table> <tr is="my-row"></tr> </table>
應當注意,如果您使用來自以下來源之一的字符串模板,這些限制將不適用:
<script type="text/x-template">
- JavaScript內聯模版字符串
.vue
組件
因此,有必要的話請使用字符串模版。
data
必須是函數
使用組件時,大多數選項可以被傳入到 Vue 構造器中,有一個例外: data
必須是函數。 實際上,如果你這么做:
Vue.component('my-component', { template: '<span>{{ message }}</span>', data: { message: 'hello' } })
那么 Vue 會在控制台發出警告,告訴你在組件中 data
必須是一個函數。最好理解這種規則的存在意義。
<div id="example-2"> <simple-counter></simple-counter> <simple-counter></simple-counter> <simple-counter></simple-counter> </div>
var data = { counter: 0 } Vue.component('simple-counter', { template: '<button v-on:click="counter += 1">{{ counter }}</button>', // data 是一個函數,因此 Vue 不會警告, // 但是我們為每一個組件返回了同一個對象引用 data: function () { return data } }) new Vue({ el: '#example-2' })
由於這三個組件共享了同一個 data
, 因此增加一個 counter 會影響所有組件!我們可以通過為每個組件返回新的 data 對象來解決這個問題:
data: function () { return { counter: 0 } }
現在每個 counter 都有它自己內部的狀態了:
構成組件
組件意味着協同工作,通常父子組件會是這樣的關系:組件 A 在它的模版中使用了組件 B 。它們之間必然需要相互通信:父組件要給子組件傳遞數據,子組件需要將它內部發生的事情告知給父組件。然而,在一個良好定義的接口中盡可能將父子組件解耦是很重要的。這保證了每個組件可以在相對隔離的環境中書寫和理解,也大幅提高了組件的可維護性和可重用性。
在 Vue.js 中,父子組件的關系可以總結為 props down, events up 。父組件通過 props 向下傳遞數據給子組件,子組件通過 events 給父組件發送消息。看看它們是怎么工作的。
Props
使用Props傳遞數據
組件實例的作用域是孤立的。這意味着不能並且不應該在子組件的模板內直接引用父組件的數據。可以使用 props 把數據傳給子組件。
prop 是父組件用來傳遞數據的一個自定義屬性。子組件需要顯式地用 props
選項 聲明 “prop”:
Vue.component('child', { // 聲明 props props: ['message'], // 就像 data 一樣,prop 可以用在模板內 // 同樣也可以在 vm 實例中像 “this.message” 這樣使用 template: '<span>{{ message }}</span>' })
然后向它傳入一個普通字符串:
<child message="hello!"></child>
結果:
camelCase vs. kebab-case
HTML 特性不區分大小寫。當使用非字符串模版時,prop的名字形式會從 camelCase 轉為 kebab-case(短橫線隔開):
Vue.component('child', { // camelCase in JavaScript props: ['myMessage'], template: '<span>{{ myMessage }}</span>' })
<!-- kebab-case in HTML --> <child my-message="hello!"></child>
再次說明,如果你使用字符串模版,不用在意這些限制。
動態 Props
類似於用 v-bind
綁定 HTML 特性到一個表達式,也可以用 v-bind
動態綁定 props 的值到父組件的數據中。每當父組件的數據變化時,該變化也會傳導給子組件:
<div> <input v-model="parentMsg"> <br> <child v-bind:my-message="parentMsg"></child> </div>
使用 v-bind
的縮寫語法通常更簡單:
<child :my-message="parentMsg"></child>
結果:
Message from parent
字面量語法 vs 動態語法
初學者常犯的一個錯誤是使用字面量語法傳遞數值:
<!-- 傳遞了一個字符串"1" --> <comp some-prop="1"></comp>
因為它是一個字面 prop ,它的值以字符串 "1"
而不是以實際的數字傳下去。如果想傳遞一個實際的 JavaScript 數字,需要使用 v-bind
,從而讓它的值被當作 JavaScript 表達式計算:
<!-- 傳遞實際的數字 --> <comp v-bind:some-prop="1"></comp>
單向數據流
prop 是單向綁定的:當父組件的屬性變化時,將傳導給子組件,但是不會反過來。這是為了防止子組件無意修改了父組件的狀態——這會讓應用的數據流難以理解。
另外,每次父組件更新時,子組件的所有 prop 都會更新為最新值。這意味着你不應該在子組件內部改變 prop 。如果你這么做了,Vue 會在控制台給出警告。
通常有兩種改變 prop 的情況:
-
prop 作為初始值傳入,子組件之后只是將它的初始值作為本地數據的初始值使用;
-
prop 作為需要被轉變的原始值傳入。
更確切的說這兩種情況是:
-
定義一個局部 data 屬性,並將 prop 的初始值作為局部數據的初始值。
props: ['initialCounter'], data: function () { return { counter: this.initialCounter } }
2.定義一個 computed 屬性,此屬性從 prop 的值計算得出。
props: ['size'], computed: { normalizedSize: function () { return this.size.trim().toLowerCase() } }
注意在 JavaScript 中對象和數組是引用類型,指向同一個內存空間,如果 prop 是一個對象或數組,在子組件內部改變它會影響父組件的狀態。
Prop 驗證
組件可以為 props 指定驗證要求。如果未指定驗證要求,Vue 會發出警告。當組件給其他人使用時這很有用。
prop 是一個對象而不是字符串數組時,它包含驗證要求:
Vue.component('example', { props: { // 基礎類型檢測 (`null` 意思是任何類型都可以) propA: Number, // 多種類型 propB: [String, Number], // 必傳且是字符串 propC: { type: String, required: true }, // 數字,有默認值 propD: { type: Number, default: 100 }, // 數組/對象的默認值應當由一個工廠函數返回 propE: { type: Object, default: function () { return { message: 'hello' } } }, // 自定義驗證函數 propF: { validator: function (value) { return value > 10 } } } })
type
可以是下面原生構造器:
- String
- Number
- Boolean
- Function
- Object
- Array
type
也可以是一個自定義構造器,使用 instanceof
檢測。
當 prop 驗證失敗了, Vue 將拒絕在子組件上設置此值,如果使用的是開發版本會拋出一條警告。
自定義事件
我們知道,父組件是使用 props 傳遞數據給子組件,但如果子組件要把數據傳遞回去,應該怎樣做?那就是自定義事件!
使用 v-on
綁定自定義事件
每個 Vue 實例都實現了事件接口(Events interface),即:
- 使用
$on(eventName)
監聽事件 - 使用
$emit(eventName)
觸發事件
Vue的事件系統分離自瀏覽器的EventTarget API。盡管它們的運行類似,但是$on
和 $emit
不是addEventListener
和 dispatchEvent
的別名。
另外,父組件可以在使用子組件的地方直接用 v-on
來監聽子組件觸發的事件。
下面是一個例子:
<div id="counter-event-example"> <p>{{ total }}</p> <button-counter v-on:increment="incrementTotal"></button-counter> <button-counter v-on:increment="incrementTotal"></button-counter> </div>
Vue.component('button-counter', { template: '<button v-on:click="increment">{{ counter }}</button>', data: function () { return { counter: 0 } }, methods: { increment: function () { this.counter += 1 this.$emit('increment') } }, }) new Vue({ el: '#counter-event-example', data: { total: 0 }, methods: { incrementTotal: function () { this.total += 1 } } })
在本例中,子組件已經和它外部完全解耦了。它所做的只是觸發一個父組件關心的內部事件。
給組件綁定原生事件
有時候,你可能想在某個組件的根元素上監聽一個原生事件。可以使用 .native
修飾v-on
。例如:
<my-component v-on:click.native="doTheThing"></my-component>
使用自定義事件的表單輸入組件
自定義事件也可以用來創建自定義的表單輸入組件,使用 v-model
來進行數據雙向綁定。牢記:
<input v-model="something">
僅僅是一個語法糖:
<input v-bind:value="something" v-on:input="something = $event.target.value">
所以在組件中使用時,它相當於下面的簡寫:
<input v-bind:value="something" v-on:input="something = arguments[0]">
所以要讓組件的 v-model
生效,它必須:
- 接受一個
value
屬性 - 在有新的 value 時觸發
input
事件
一個非常簡單的貨幣輸入:
<currency-input v-model="price"></currency-input>
Vue.component('currency-input', { template: '\ <span>\ $\ <input\ ref="input"\ v-bind:value="value"\ v-on:input="updateValue($event.target.value)"\ >\ </span>\ ', props: ['value'], methods: { // Instead of updating the value directly, this // method is used to format and place constraints // on the input's value updateValue: function (value) { var formattedValue = value // Remove whitespace on either side .trim() // Shorten to 2 decimal places .slice(0, value.indexOf('.') + 3) // If the value was not already normalized, // manually override it to conform if (formattedValue !== value) { this.$refs.input.value = formattedValue } // Emit the number value through the input event this.$emit('input', Number(formattedValue)) } } })
The implementation above is pretty naive though. For example, users are allowed to enter multiple periods and even letters sometimes - yuck! So for those that want to see a non-trivial example, here’s a more robust currency filter:
這個接口不僅僅可以用來連接組件內部的表單輸入,也很容易集成你自己創造的輸入類型。想象一下:
<voice-recognizer v-model="question"></voice-recognizer> <webcam-gesture-reader v-model="gesture"></webcam-gesture-reader> <webcam-retinal-scanner v-model="retinalImage"></webcam-retinal-scanner>
非父子組件通信
有時候非父子關系的組件也需要通信。在簡單的場景下,使用一個空的 Vue 實例作為中央事件總線:
var bus = new Vue()
// 觸發組件 A 中的事件 bus.$emit('id-selected', 1)
// 在組件 B 創建的鈎子中監聽事件 bus.$on('id-selected', function (id) { // ... })
在更多復雜的情況下,你應該考慮使用專門的 狀態管理模式.
使用 Slots 分發內容
在使用組件時,常常要像這樣組合它們:
<app> <app-header></app-header> <app-footer></app-footer> </app>
注意兩點:
-
<app>
組件不知道它的掛載點會有什么內容。掛載點的內容是由<app>
的父組件決定的。 -
<app>
組件很可能有它自己的模版。
為了讓組件可以組合,我們需要一種方式來混合父組件的內容與子組件自己的模板。這個過程被稱為 內容分發 (或 “transclusion” 如果你熟悉 Angular)。Vue.js 實現了一個內容分發 API ,參照了當前 Web組件規范草案,使用特殊的 <slot>
元素作為原始內容的插槽。
編譯作用域
在深入內容分發 API 之前,我們先明確內容的編譯作用域。假定模板為:
<child-component> {{ message }} </child-component>
message
應該綁定到父組件的數據,還是綁定到子組件的數據?答案是父組件。組件作用域簡單地說是:
父組件模板的內容在父組件作用域內編譯;子組件模板的內容在子組件作用域內編譯。
一個常見錯誤是試圖在父組件模板內將一個指令綁定到子組件的屬性/方法:
<!-- 無效 --> <child-component v-show="someChildProperty"></child-component>
假定 someChildProperty
是子組件的屬性,上例不會如預期那樣工作。父組件模板不應該知道子組件的狀態。
如果要綁定子組件內的指令到一個組件的根節點,應當在它的模板內這么做:
Vue.component('child-component', { // 有效,因為是在正確的作用域內 template: '<div v-show="someChildProperty">Child</div>', data: function () { return { someChildProperty: true } } })
類似地,分發內容是在父組件作用域內編譯。
單個 Slot
除非子組件模板包含至少一個 <slot>
插口,否則父組件的內容將會被丟棄。當子組件模板只有一個沒有屬性的 slot 時,父組件整個內容片段將插入到 slot 所在的 DOM 位置,並替換掉 slot 標簽本身。
最初在 <slot>
標簽中的任何內容都被視為備用內容。備用內容在子組件的作用域內編譯,並且只有在宿主元素為空,且沒有要插入的內容時才顯示備用內容。
假定 my-component
組件有下面模板:
<div> <h2>I'm the child title</h2> <slot> 如果沒有分發內容則顯示我。 </slot> </div>
父組件模版:
<div> <h1>I'm the parent title</h1> <my-component> <p>This is some original content</p> <p>This is some more original content</p> </my-component> </div>
渲染結果:
<div> <h1>I'm the parent title</h1> <div> <h2>I'm the child title</h2> <p>This is some original content</p> <p>This is some more original content</p> </div> </div>
具名Slots
<slot>
元素可以用一個特殊的屬性 name
來配置如何分發內容。多個 slot 可以有不同的名字。具名 slot 將匹配內容片段中有對應 slot
特性的元素。
仍然可以有一個匿名 slot ,它是默認 slot ,作為找不到匹配的內容片段的備用插槽。如果沒有默認的 slot ,這些找不到匹配的內容片段將被拋棄。
例如,假定我們有一個 app-layout
組件,它的模板為:
<div class="container"> <header> <slot name="header"></slot> </header> <main> <slot></slot> </main> <footer> <slot name="footer"></slot> </footer> </div>
父組件模版:
<app-layout> <h1 slot="header">Here might be a page title</h1> <p>A paragraph for the main content.</p> <p>And another one.</p> <p slot="footer">Here's some contact info</p> </app-layout>
渲染結果為:
<div class="container"> <header> <h1>Here might be a page title</h1> </header> <main> <p>A paragraph for the main content.</p> <p>And another one.</p> </main> <footer> <p>Here's some contact info</p> </footer> </div>
在組合組件時,內容分發 API 是非常有用的機制。
動態組件
多個組件可以使用同一個掛載點,然后動態地在它們之間切換。使用保留的 <component>
元素,動態地綁定到它的 is
特性:
var vm = new Vue({ el: '#example', data: { currentView: 'home' }, components: { home: { /* ... */ }, posts: { /* ... */ }, archive: { /* ... */ } } })
<component v-bind:is="currentView"> <!-- 組件在 vm.currentview 變化時改變! --> </component>
也可以直接綁定到組件對象上:
var Home = { template: '<p>Welcome home!</p>' } var vm = new Vue({ el: '#example', data: { currentView: Home } })
keep-alive
如果把切換出去的組件保留在內存中,可以保留它的狀態或避免重新渲染。為此可以添加一個 keep-alive
指令參數:
<keep-alive> <component :is="currentView"> <!-- 非活動組件將被緩存! --> </component> </keep-alive>
在API 參考查看更多 <keep-alive>
的細節。
雜項
編寫可復用組件
在編寫組件時,記住是否要復用組件有好處。一次性組件跟其它組件緊密耦合沒關系,但是可復用組件應當定義一個清晰的公開接口。
Vue 組件的 API 來自三部分 - props, events 和 slots :
-
Props 允許外部環境傳遞數據給組件
-
Events 允許組件觸發外部環境的副作用
-
Slots 允許外部環境將額外的內容組合在組件中。
使用 v-bind
和 v-on
的簡寫語法,模板的縮進清楚且簡潔:
<my-component :foo="baz" :bar="qux" @event-a="doThis" @event-b="doThat" > <img slot="icon" src="..."> <p slot="main-text">Hello!</p> </my-component>
子組件索引
盡管有 props 和 events ,但是有時仍然需要在 JavaScript 中直接訪問子組件。為此可以使用 ref
為子組件指定一個索引 ID 。例如:
<div id="parent"> <user-profile ref="profile"></user-profile> </div>
var parent = new Vue({ el: '#parent' }) // 訪問子組件 var child = parent.$refs.profile
當 ref
和 v-for
一起使用時, ref 是一個數組或對象,包含相應的子組件。
$refs
只在組件渲染完成后才填充,並且它是非響應式的。它僅僅作為一個直接訪問子組件的應急方案——應當避免在模版或計算屬性中使用 $refs
。
異步組件
在大型應用中,我們可能需要將應用拆分為多個小模塊,按需從服務器下載。為了讓事情更簡單, Vue.js 允許將組件定義為一個工廠函數,動態地解析組件的定義。Vue.js 只在組件需要渲染時觸發工廠函數,並且把結果緩存起來,用於后面的再次渲染。例如:
Vue.component('async-example', function (resolve, reject) { setTimeout(function () { resolve({ template: '<div>I am async!</div>' }) }, 1000) })
工廠函數接收一個 resolve
回調,在收到從服務器下載的組件定義時調用。也可以調用reject(reason)
指示加載失敗。這里 setTimeout
只是為了演示。怎么獲取組件完全由你決定。推薦配合使用 :Webpack 的代碼分割功能:
Vue.component('async-webpack-example', function (resolve) { // 這個特殊的 require 語法告訴 webpack // 自動將編譯后的代碼分割成不同的塊, // 這些塊將通過 Ajax 請求自動下載。 require(['./my-async-component'], resolve) })
你可以使用 Webpack 2 + ES2015 的語法返回一個 Promise
resolve 函數:
Vue.component( 'async-webpack-example', () => System.import('./my-async-component') )
如果你是 Browserify 用戶,可能就無法使用異步組件了,它的作者已經表明Browserify 是不支持異步加載的。如果這個功能對你很重要,請使用 Webpack。
組件命名約定
當注冊組件(或者 props)時,可以使用 kebab-case ,camelCase ,或 TitleCase 。Vue 不關心這個。
// 在組件定義中 components: { // 使用 camelCase 形式注冊 'kebab-cased-component': { /* ... */ }, 'camelCasedComponent': { /* ... */ }, 'TitleCasedComponent': { /* ... */ } }
在 HTML 模版中,請使用 kebab-case 形式:
<!-- 在HTML模版中始終使用 kebab-case --> <kebab-cased-component></kebab-cased-component> <camel-cased-component></camel-cased-component> <title-cased-component></title-cased-component>
當使用字符串模式時,可以不受 HTML 的 case-insensitive 限制。這意味實際上在模版中,你可以使用 camelCase 、 PascalCase 或者 kebab-case 來引用你的組件和 prop:
<!-- 在字符串模版中可以用任何你喜歡的方式! --> <my-component></my-component> <myComponent></myComponent> <MyComponent></MyComponent>
如果組件未經 slot
元素傳遞內容,你甚至可以在組件名后使用 /
使其自閉合:
<my-component/>
當然,這只在字符串模版中有效。因為自閉的自定義元素是無效的 HTML ,瀏覽器原生的解析器也無法識別它。
遞歸組件
組件在它的模板內可以遞歸地調用自己,不過,只有當它有 name 選項時才可以:
name: 'unique-name-of-my-component'
當你利用Vue.component
全局注冊了一個組件, 全局的ID作為組件的 name
選項,被自動設置.
Vue.component('unique-name-of-my-component', { // ... })
如果你不謹慎, 遞歸組件可能導致死循環:
name: 'stack-overflow',
template: '<div><stack-overflow></stack-overflow></div>'
上面組件會導致一個錯誤 “max stack size exceeded” ,所以要確保遞歸調用有終止條件 (比如遞歸調用時使用 v-if
並讓他最終返回 false
)。
內聯模版
如果子組件有 inline-template 特性,組件將把它的內容當作它的模板,而不是把它當作分發內容。這讓模板更靈活。
<my-component inline-template> <div> <p>These are compiled as the component's own template.</p> <p>Not parent's transclusion content.</p> </div> </my-component>
但是 inline-template 讓模板的作用域難以理解。最佳實踐是使用 template 選項在組件內定義模板或者在 .vue
文件中使用 template
元素。
X-Templates
另一種定義模版的方式是在 JavaScript 標簽里使用 text/x-template
類型,並且指定一個id。例如:
<script type="text/x-template" id="hello-world-template"> <p>Hello hello hello</p> </script>
Vue.component('hello-world', { template: '#hello-world-template' })
這在有很多模版或者小的應用中有用,否則應該避免使用,因為它將模版和組件的其他定義隔離了。
使用 v-once
的低級靜態組件(Cheap Static Component)
盡管在 Vue 中渲染 HTML 很快,不過當組件中包含大量靜態內容時,可以考慮使用v-once
將渲染結果緩存起來,就像這樣:
Vue.component('terms-of-service', { template: '\ <div v-once>\ <h1>Terms of Service</h1>\ ... a lot of static content ...\ </div>\ ' })
|