聊聊對Vue.js框架的理解

分享目標:

瞭解 Vue.js 的組件化機制
瞭解 Vue.js 的響應式系統原理
瞭解 Vue.js 中的 Virtual DOM 及 Diff 原理

Vue.js概述

Vue 是一套用於構建用戶界面的漸進式MVVM框架。那怎麼理解漸進式呢?漸進式含義:強制主張最少。

Vue.js包含了聲明式渲染、組件化系統、客戶端路由、大規模狀態管理、構建工具、數據持久化、跨平臺支持等,但在實際開發中,並沒有強制要求開發者之後某一特定功能,而是根據需求逐漸擴展。

Vue.js的核心庫只關心視圖渲染,且由於漸進式的特性,Vue.js便於與第三方庫或既有項目整合。

組件機制

定義:組件就是對一個功能和樣式進行獨立的封裝,讓HTML元素得到擴展,從而使得代碼得到複用,使得開發靈活,更加高效。
與HTML元素一樣,Vue.js的組件擁有外部傳入的屬性(prop)和事件,除此之外,組件還擁有自己的狀態(data)和通過數據和狀態計算出來的計算屬性(computed),各個維度組合起來決定組件最終呈現的樣子與交互的邏輯。

數據傳遞

每一個組件之間的作用域是孤立的,這個意味着組件之間的數據不應該出現引用關係,即使出現了引用關係,也不允許組件操作組件內部以外的其他數據。Vue中,允許向組件內部傳遞prop數據,組件內部需要顯性地聲明該prop字段,如下聲明一個child組件:

<!-- child.vue -->
<template>
    <div>{{msg}}</div>
</template>
<script>
export default {
    props: {
        msg: {
            type: String,
            default: 'hello world' // 當default爲引用類型時,需要使用 function 形式返回
        }
    }
}
</script>

父組件向該組件傳遞數據:

<!-- parent.vue -->
<template>
    <child :msg="parentMsg"></child>
</template>
<script>
import child from './child';
export default {
    components: {
        child
    },
    data () {
        return {
            parentMsg: 'some words'
        }
    }
}
</script>

事件傳遞

Vue內部實現了一個事件總線系統,即EventBus。在Vue中可以使用 EventBus 來作爲溝通橋樑的概念,每一個Vue的組件實例都繼承了 EventBus,都可以接受事件onon和發送事件emit。
如上面一個例子,child.vue 組件想修改 parent.vue 組件的 parentMsg 數據,怎麼辦呢?爲了保證數據流的可追溯性,直接修改組件內 prop 的 msg 字段是不提倡的,且例子中爲非引用類型 String,直接修改也修改不了,這個時候需要將修改 parentMsg 的事件傳遞給 child.vue,讓 child.vue 來觸發修改 parentMsg 的事件。如:

<!-- child.vue -->
<template>
    <div>{{msg}}</div>
</template>
<script>
export default {
    props: {
        msg: {
            type: String,
            default: 'hello world'
        }
    },
    methods: {
        changeMsg(newMsg) {
            this.$emit('updateMsg', newMsg);
        }
    }
}
</script>

父組件:

<!-- parent.vue -->
<template>
    <child :msg="parentMsg" @updateMsg="changeParentMsg"></child>
</template>
<script>
import child from './child';
export default {
    components: {
        child
    },
    data () {
        return {
            parentMsg: 'some words'
        }
    },
    methods: {
        changeParentMsg: function (newMsg) {
            this.parentMsg = newMsg
        }
    }
}
</script>

父組件 parent.vue 向子組件 child.vue 傳遞了 updateMsg 事件,在子組件實例化的時候,子組件將 updateMsg 事件使用on調this.on函數註冊到組件內部,需要觸發事件的時候,調用函數this.emit來觸發事件。
除了父子組件之間的事件傳遞,還可以使用一個 Vue 實例爲多層級的父子組件建立數據通信的橋樑,如:

const eventBus = new Vue();

// 父組件中使用$on監聽事件
eventBus.$on('eventName', val => {
    //  ...do something
})

// 子組件使用$emit觸發事件
eventBus.$emit('eventName', 'this is a message.');

除了onon和emit以外,事件總線系統還提供了另外兩個方法,onceonce和off,所有事件如下:

$on:監聽、註冊事件。
$emit:觸發事件。
$once:註冊事件,僅允許該事件觸發一次,觸發結束後立即移除事件。
$off:移除事件。

默認插槽

又名單個插槽、匿名插槽,與具名插槽相對,這類插槽沒有具體名字,一個組件只能有一個該類插槽。

如:

<template>
<!-- 父組件 parent.vue -->
<div class="parent">
    <h1>父容器</h1>
    <child>
        <div class="tmpl">
            <span>菜單1</span>
        </div>
    </child>
</div>
</template>
<template>
<!-- 子組件 child.vue -->
<div class="child">
    <h1>子組件</h1>
    <slot></slot>
</div>
</template>

如上,渲染時子組件的slot標籤會被父組件傳入的div.tmpl替換。

具名插槽

匿名插槽沒有name屬性,所以叫匿名插槽。那麼,插槽加了name屬性,就變成了具名插槽。具名插槽可以在一個組件中出現N次,出現在不同的位置,只需要使用不同的name屬性區分即可。

如:

<template>
<!-- 父組件 parent.vue -->
<div class="parent">
    <h1>父容器</h1>
    <child>
        <div class="tmpl" slot="up">
            <span>菜單up-1</span>
        </div>
        <div class="tmpl" slot="down">
            <span>菜單down-1</span>
        </div>
        <div class="tmpl">
            <span>菜單->1</span>
        </div>
    </child>
</div>
</template>
<template>
    <div class="child">
        <!-- 具名插槽 -->
        <slot name="up"></slot>
        <h3>這裏是子組件</h3>
        <!-- 具名插槽 -->
        <slot name="down"></slot>
        <!-- 匿名插槽 -->
        <slot></slot>
    </div>
</template>

如上,slot 標籤會根據父容器給 child 標籤內傳入的內容的 slot 屬性值,替換對應的內容。
其實,默認插槽也有 name 屬性值,爲default,同樣指定 slot 的 name 值爲 default,一樣可以顯示父組件中傳入的沒有指定slot的內容。

作用域插槽
作用域插槽可以是默認插槽,也可以是具名插槽,不一樣的地方是,作用域插槽可以爲 slot 標籤綁定數據,讓其父組件可以獲取到子組件的數據。

如:

<template>
    <!-- parent.vue -->
    <div class="parent">
        <h1>這是父組件</h1>
        <current-user>
            <template slot="default" slot-scope="slotProps">
                {{ slotProps.user.name }}
            </template>
        </current-user>
    </div>
</template>
<template>
    <!-- child.vue -->
    <div class="child">
        <h1>這是子組件</h1>
        <slot :user="user"></slot>
    </div>
</template>
<script>
export default {
    data() {
        return {
            user: {
                name: '小趙'
            }
        }
    }
}
</script>

如上例子,子組件 child 在渲染默認插槽 slot 的時候,將數據 user 傳遞給了 slot 標籤,在渲染過程中,父組件可以通過slot-scope屬性獲取到 user 數據並渲染視圖。
slot 實現原理:當子組件vm實例化時,獲取到父組件傳入的 slot 標籤的內容,存放在vm.slotvm.slot中,默認插槽爲vm.slot.default,具名插槽爲vm.slot.xxxxxx<slot>使slot.xxx,xxx 爲 插槽名,當組件執行渲染函數時候,遇到<slot>標籤,使用slot中的內容進行替換,此時可以爲插槽傳遞數據,若存在數據,則可曾該插槽爲作用域插槽。
至此,父子組件的關係如下圖:

在這裏插入圖片描述
模板渲染

Vue.js 的核心是聲明式渲染,與命令式渲染不同,聲明式渲染只需要告訴程序,我們想要的什麼效果,其他的事情讓程序自己去做。而命令式渲染,需要命令程序一步一步根據命令執行渲染。如下例子區分

var arr = [1, 2, 3, 4, 5];

// 命令式渲染,關心每一步、關心流程。用命令去實現
var newArr = [];
for (var i = 0; i < arr.length; i++) {
    newArr.push(arr[i] * 2);
}

// 聲明式渲染,不用關心中間流程,只需要關心結果和實現的條件
var newArr1 = arr.map(function (item) {
    return item * 2;
});

Vue.js 實現了if、for、事件、數據綁定等指令,允許採用簡潔的模板語法來聲明式地將數據渲染出視圖。

模板編譯
爲什麼要進行模板編譯?實際上,我們組件中的 template 語法是無法被瀏覽器解析的,因爲它不是正確的 HTML 語法,而模板編譯,就是將組件的 template 編譯成可執行的 JavaScript 代碼,即將 template 轉化爲真正的渲染函數。
模板編譯分三個階段,parse、optimize、generate,最終生成render函數。
在這裏插入圖片描述
parse階段:使用正在表達式將template進行字符串解析,得到指令、class、style等數據,生成抽象語法樹 AST。
optimize階段:尋找 AST 中的靜態節點進行標記,爲後面 VNode 的 patch 過程中對比做優化。被標記爲 static 的節點在後面的 diff 算法中會被直接忽略,不做詳細的比較。
generate階段:根據 AST 結構拼接生成 render 函數的字符串。

預編譯
對於 Vue 組件來說,模板編譯只會在組件實例化的時候編譯一次,生成渲染函數之後在也不會進行編譯。因此,編譯對組件的 runtime 是一種性能損耗。而模板編譯的目的僅僅是將template轉化爲render function,而這個過程,正好可以在項目構建的過程中完成。
比如webpack的vue-loader依賴了vue-template-compiler模塊,在 webpack 構建過程中,將template預編譯成 render 函數,在 runtime 可直接跳過模板編譯過程。
回過頭看,runtime 需要是僅僅是 render 函數,而我們有了預編譯之後,我們只需要保證構建過程中生成 render 函數就可以。與 React 類似,在添加JSX的語法糖編譯器babel-plugin-transform-vue-jsx之後,我們可以在 Vue 組件中使用JSX語法直接書寫 render 函數

<script>
export default {
    data() {
        return {
            msg: 'Hello JSX.'
        }
    },
    render() {
        const msg = this.msg;
        return <div>
            {msg}
        </div>;
    }
}
</script>

如上面組件,使用 JSX 之後,可以在 JS 代碼中直接使用 html 標籤,而且聲明瞭 render 函數以後,我們不再需要聲明 template。當然,假如我們同時聲明瞭 template 標籤和 render 函數,構建過程中,template 編譯的結果將覆蓋原有的 render 函數,即 template 的優先級高於直接書寫的 render 函數。
相對於 template 而言,JSX 具有更高的靈活性,面對與一些複雜的組件來說,JSX 有着天然的優勢,而 template 雖然顯得有些呆滯,但是代碼結構上更符合視圖與邏輯分離的習慣,更簡單、更直觀、更好維護。
需要注意的是,最後生成的 render 函數是被包裹在with語法中運行的。

小結

Vue 組件通過 prop 進行數據傳遞,並實現了數據總線系統EventBus,組件集成了EventBus進行事件註冊監聽、事件觸發,使用slot進行內容分發。
除此以外,實現了一套聲明式模板系統,在runtime或者預編譯是對模板進行編譯,生成渲染函數,供組件渲染視圖使用。

響應式系統

Vue.js 是一款 MVVM 的JS框架,當對數據模型data進行修改時,視圖會自動得到更新,即框架幫我們完成了更新DOM的操作,而不需要我們手動的操作DOM。可以這麼理解,當我們對數據進行賦值的時候,Vue 告訴了所有依賴該數據模型的組件,你依賴的數據有更新,你需要進行重渲染了,這個時候,組件就會重渲染,完成了視圖的更新。

數據模型 && 計算屬性 && 監聽器

在組件中,可以爲每個組件定義數據模型data、計算屬性computed、監聽器watch。
數據模型:Vue 實例在創建過程中,對數據模型data的每一個屬性加入到響應式系統中,當數據被更改時,視圖將得到響應,同步更新。data必須採用函數的方式 return,不使用 return 包裹的數據會在項目的全局可見,會造成變量污染;使用return包裹後數據中變量只在當前組件中生效,不會影響其他組件。
計算屬性:computed基於組件響應式依賴進行計算得到結果並緩存起來。只在相關響應式依賴發生改變時它們纔會重新求值,也就是說,只有它依賴的響應式數據(data、prop、computed本身)發生變化了纔會重新計算。那什麼時候應該使用計算屬性呢?模板內的表達式非常便利,但是設計它們的初衷是用於簡單運算的。在模板中放入太多的邏輯會讓模板過重且難以維護。對於任何複雜邏輯,你都應當使用計算屬性。
監聽器:監聽器watch作用如其名,它可以監聽響應式數據的變化,響應式數據包括 data、prop、computed,當響應式數據發生變化時,可以做出相應的處理。當需要在數據變化時執行異步或開銷較大的操作時,這個方式是最有用的。

響應式原理

在 Vue 中,數據模型下的所有屬性,會被 Vue 使用Object.defineProperty(Vue3.0 使用 Proxy)進行數據劫持代理。響應式的核心機制是觀察者模式,數據是被觀察的一方,一旦發生變化,通知所有觀察者,這樣觀察者可以做出響應,比如當觀察者爲視圖時,視圖可以做出視圖的更新。
Vue.js 的響應式系統以來三個重要的概念,Observer、Dep、Watcher。

發佈者-Observer

Observe 扮演的角色是發佈者,他的主要作用是在組件vm初始化的時,調用defineReactive函數,使用Object.defineProperty方法對對象的每一個子屬性進行數據劫持/監聽,即爲每個屬性添加getter和setter,將對應的屬性值變成響應式。
在組件初始化時,調用initState函數,內部執行initState、initProps、initComputed方法,分別對data、prop、computed進行初始化,讓其變成響應式。
初始化props時,對所有props進行遍歷,調用defineReactive函數,將每個 prop 屬性值變成響應式,然後將其掛載到_props中,然後通過代理,把vm.xxx代理到vm._props.xxx中。
同理,初始化data時,與prop相同,對所有data進行遍歷,調用defineReactive函數,將每個 data 屬性值變成響應式,然後將其掛載到_data中,然後通過代理,把vm.xxx代理到vm._data.xxx中。
初始化computed,首先創建一個觀察者對象computed-watcher,然後遍歷computed的每一個屬性,對每一個屬性值調用defineComputed方法,使用Object.defineProperty將其變成響應式的同時,將其代理到組件實例上,即可通過vm.xxx訪問到xxx計算屬性

調度中心/訂閱器-Dep

Dep 扮演的角色是調度中心/訂閱器,在調用defineReactive將屬性值變成響應式的過程中,也爲每個屬性值實例化了一個Dep,主要作用是對觀察者(Watcher)進行管理,收集觀察者和通知觀察者目標更新,即當屬性值數據發生改變時,會遍歷觀察者列表(dep.subs),通知所有的 watcher,讓訂閱者執行自己的update邏輯。
其dep的任務是,在屬性的getter方法中,調用dep.depend()方法,將觀察者(即 Watcher,可能是組件的render function,可能是 computed,也可能是屬性監聽 watch)保存在內部,完成其依賴收集。在屬性的setter方法中,調用dep.notify()方法,通知所有觀察者執行更新,完成派發更新。

觀察者-Watcher

Watcher 扮演的角色是訂閱者/觀察者,他的主要作用是爲觀察屬性提供回調函數以及收集依賴,當被觀察的值發生變化時,會接收到來自調度中心Dep的通知,從而觸發回調函數。
而Watcher又分爲三類,normal-watcher、 computed-watcher、 render-watcher。

  • normal-watcher:在組件鉤子函數watch中定義,即監聽的屬性改變了,都會觸發定義好的回調函數。
  • computed-watcher:在組件鉤子函數computed中定義的,每一個computed屬性,最後都會生成一個對應的Watcher對象,但是這類Watcher有個特點:當計算屬性依賴於其他數據時,屬性並不會立即重新計算,只有之後其他地方需要讀取屬性的時候,它纔會真正計算,即具備lazy(懶計算)特性。
  • render-watcher:每一個組件都會有一個render-watcher,
    當data/computed中的屬性改變的時候,會調用該Watcher來更新組件的視圖。

這三種Watcher也有固定的執行順序,分別是:computed-render -> normal-watcher -> render-watcher。這樣就能儘可能的保證,在更新組件視圖的時候,computed 屬性已經是最新值了,如果 render-watcher 排在 computed-render 前面,就會導致頁面更新的時候 computed 值爲舊數據。
小結
在這裏插入圖片描述

發佈了50 篇原創文章 · 獲贊 26 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章