Vue 狀態管理與與SSR詳解

語言: CN / TW / HK

1、vuex簡介

1、定義

在vue項⽬中,每個元件的資料都有其獨⽴的作⽤域。當元件間需要跨層級或者同層之間頻繁傳遞的時候,資料互動就會⾮常繁瑣。vuex的主要作⽤就是集中管理所有元件的資料和狀態以及規範資料修改的⽅式。

官方解釋:Vuex 是⼀個專為 Vue.js 應⽤程式開發的狀態管理模式。它採⽤集中式儲存管理應⽤的所有元件的狀態,並以相應的規則保證狀態以⼀種可預測的⽅式發⽣變化。

2、使用場景

⼀般來講,是以項⽬中的資料互動複雜程度來決定的。具體包括以下場景:

  • 項⽬元件間資料互動不頻繁,元件數量較少:不使⽤狀態管理
  • 項⽬元件間資料互動頻繁,但元件數量較少:使⽤eventBus或者vue store解決
  • 項⽬元件間資料互動頻繁,元件數量較多:vuex解決

3、核心原理分析

// a.vue
<h1>{{ username }}</h1>

// b.vue
<h2>
  {{ username }}
</h2>

/**
* 如果 username 需要在每個元件都獲取一次,是不是很麻煩,雖然可以通過共同的父級傳入,但是不都是這種理想情況
*/
複製程式碼

Flux 架構主要思想是應用的狀態被集中存放到一個倉庫中,但是倉庫中的狀態不能被直接修改,必須通過特定的方式才能更新狀態。

vuex基於flux思想為vue框架定製,區分同步和非同步,定義兩種行為,Actions 用來處理非同步狀態變更(內部還是呼叫 Mutations),Mutations 處理同步的狀態變更,整個鏈路應該是一個閉環,單向的,完美契合 FLUX 的思想

「頁面 dispatch/commit」-> 「actions/mutations」-> 「狀態變更」-> 「頁面更新」-> 「頁面 dispatch/commit」...

2、vuex五大核心

  1. vue使用單一狀態樹,單一狀態樹讓我們能夠直接地定位任一特定的狀態片段,在除錯的過程中也能輕易地取得整個當前應用狀態的快照。
  • 用一個物件(主幹)就包含了全部的(分支)應用層級狀態。
  • 每個應用將僅僅包含一個 store 例項物件(主幹)。
  1. 每一個 Vuex 應用的核心就是 store(倉庫)。“store”基本上就是一個容器,它包含著你的應用中大部分的狀態 (state) 。Vuex 和單純的全域性物件有以下兩點不同:
  • Vuex 的狀態儲存是響應式的。當 Vue 元件從 store 中讀取狀態的時候,若 store 中的狀態發生變化,那麼相應的元件也會相應地得到高效更新。
  • 你不能直接改變 store 中的狀態。改變 store 中的狀態的唯一途徑就是顯式地提交 (commit) mutation。這樣使得我們可以方便地跟蹤每一個狀態的變化,從而讓我們能夠實現一些工具幫助我們更好地瞭解我們的應用。

1、State

當前應⽤狀態,可以理解為元件的data⽅法返回的Object

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

const store = new Vuex.Store({
  state: {
    count: 0
  }
})

new Vue({
  store, //把store的例項注入所有的子元件,this.$store可訪問
  render: h => h(App)
}).$mount('#app')
複製程式碼

2、Getters

Getter為state的計算屬性,當需要重複對某個資料進⾏某種操作的時候可以封裝在getter⾥⾯,當state中的資料改變了以後對應的getter也會相應的改變。

const store = new Vuex.Store({
  state: {
    date: new Date()
  },
  getters: {
    // Getter 接受 state 作為其第一個引數
    weekDate: (state) => {
      return moment(state.date).format('dddd'); 
    },
    //Getter 還也可以接收 getters 作為第二個引數
    dateLength: (state, getters) => {
    	return getters.weekDate.length;
  	},
    //Getter本身為一屬性,傳參需返回一個函式
    weekDate: (state) => (fm) => {
    	return moment(state.date).format(fm ? fm : 'dddd'); 
  	}
  }
})

//屬性訪問
console.log(store.getters.weekDate)
console.log(store.getters.dateLength)
//方法訪問,傳參
console.log(store.getters.weekDate('MM Do YY'))
複製程式碼

3、Mutations

  • 更改 Vuex 的 store 中的狀態的唯一方法是提交 mutation,必須是同步函式。
  • Vuex 中的 mutation 非常類似於事件:每個 mutation 都有一個字串的事件型別 (type) 和 一個 回撥函式 (handler)。
  • 回撥函式就是我們實際進行狀態更改的地方,並且它會接受 state 作為第一個引數,第二個引數為載荷(payload)物件。
const store = new Vuex.Store({
  state: {
    count: 1
  },
  mutations: {
    // 事件型別 type 為 increment
    increment (state) {
      state.count++
    },
    // 新增第二個引數
    increment1 (state, payload) {
    	state.count += payload.amount
    }
  }
})

//引數呼叫
store.commit('increment')

// 1、把載荷和type分開提交
store.commit('increment1', {
  amount: 10
})

// 2、整個物件都作為載荷傳給 mutation 函式
store.commit({
  type: 'increment1',
  amount: 10
})

//----- 修改引數並使用常量,必須遵循vue規則,使用set或者物件解構 -------
// mutation-types.js
export const ADD_MUTATION = 'SOME_MUTATION'
// store.js
import Vuex from 'vuex'
import { ADD_MUTATION } from './mutation-types'
const store = new Vuex.Store({
  state: {
    student: {
      name: '小明',
      sex: '女'
    }
  },
  mutations: {
    // 使用 ES2015 風格的計算屬性命名功能來使用一個常量作為函式名
    [ADD_MUTATION] (state) {
      Vue.set(state.student, 'age', 18) //新增age屬性
      // state.student = { ...state.student, age: 18 }
    }
  }
})
//使用
import {ADD_PROPERTY} from '@/store/mutation-types'
this.$store.commit(ADD_PROPERTY)
複製程式碼

4、Actions

Action 類似於 mutation,不同在於:

  • Action 提交的是 mutation,而不是直接變更狀態。
  • Action 可以包含任意非同步操作
  • Action 函式接受一個 context 引數,它與 store 例項有著相同的方法和屬性,可以使用 context.commit 來提交一個 mutation,或者通過 context.state 和 context.getters 來獲取 state 和 getters
const store = new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
    increment (state) {
      state.count++
    }
  },
  actions: {
    increment (context) {
      context.commit('increment')
    },
    //使用解構簡化
    increment ({ commit }) {
    	commit('increment')
  	}
  }
})

//分發actions
store.dispatch('increment')
// 以載荷形式分發
store.dispatch('incrementAsync', {
  amount: 10
})
// 以物件形式分發
store.dispatch({
  type: 'incrementAsync',
  amount: 10
})
複製程式碼

5、Modules

modules的主要功能是為了防⽌state過於龐⼤和冗餘,所以對其進⾏模組化分隔

  • 模組內部的 state 是區域性的,只屬於模組本身所有,所以外部必須通過對應的模組名進行訪問
  • 模組內部的 action、mutation 和 getter 預設可是註冊在全域性名稱空間的,通過新增 namespaced: true 的方式使其成為帶名稱空間的模組。當模組被註冊後,它的所有 getter、action 及 mutation 都會自動根據模組註冊的路徑調整命名。
//無名稱空間
<script>
    import {mapState, mapMutations} from 'vuex';
    export default {
        computed: { //state不同
            ...mapState({
                name: state => (state.moduleA.text + '和' + state.moduleB.text)
            }),
        },
        methods: { //mutation全域性
            ...mapMutations(['setText']),
            modifyNameAction() {
                this.setText();
            }
        },
    }
</script>

//使用名稱空間
export default {
    namespaced: true,
    // ...
}
<script>
    import {mapActions, mapGetters} from 'vuex';
    export default {
        computed: {
            ...mapState({
                name: state => (state.moduleA.text + '和' + state.moduleB.text)
            }),
            ...mapGetters({
                name: 'moduleA/detail'
            }),
        },
        methods: {
            ...mapActions({
                call: 'moduleA/callAction'
            }),
            /* 另外寫法 */
            ...mapActions('moduleA', {
                call: 'callAction'
            }),
            ...mapActions('moduleA', ['callAction']),
            modifyNameAction() {
                this.call();
            }
        },
    }
</script>
複製程式碼

3、輔助函式

1、mapStates

  • 使用 mapState 輔助函式幫助我們生成計算屬性,入參為物件
  • 當對映的計算屬性的名稱與 state 的子節點名稱相同時,我們也可以給 mapState 傳一個字串陣列
// 在單獨構建的版本中輔助函式為 Vuex.mapState
import { mapState } from 'vuex'

export default {
  computed: {
    ...mapState({
      // 箭頭函式可使程式碼更簡練
      a: state => state.a,

      // 傳字串引數 'b'
      // 等同於 `state => state.b`
      bAlias: 'b',

      // 為了能夠使用 `this` 獲取區域性狀態
      // 必須使用常規函式
      cInfo (state) {
        return state.c + this.info
      }
  	}),
    ...mapState([
      // 對映 this.a 為 store.state.a
      'a',
      'b',
      'c'
    ])
}
複製程式碼

2、mapGetters

import { mapGetters } from 'vuex'

export default {
  // ...
  computed: {
    ...mapGetters([
      'doneTodosCount',
      'anotherGetter',
      // ...
    ]),
    ...mapGetters({
      doneCount: 'doneTodosCount'
    })
  }
}
複製程式碼

3、mapMutaions

import { mapMutations } from 'vuex'

export default {
  // ...
  methods: {
    ...mapMutations([
      // 將 `this.increment()` 對映為 
      // `this.$store.commit('increment')`
      'increment', 
      // `mapMutations` 也支援載荷:
      // 將 `this.incrementBy(amount)` 對映為 
      // `this.$store.commit('incrementBy', amount)`
      'incrementBy' 
    ]),
    ...mapMutations({
      // 將 `this.add()` 對映為 
      // `this.$store.commit('increment')`
      add: 'increment' 
    })
  }
}
複製程式碼

4、mapActions

import { mapActions } from 'vuex'

export default {
  // ...
  methods: {
    ...mapActions([
      // 將 `this.increment()` 對映為 
      // `this.$store. dispatch('increment')`
      'increment', 
      // `mapActions` 也支援載荷:
      // 將 `this.incrementBy(amount)` 對映為 
      // `this.$store. dispatch('incrementBy', amount)`
      'incrementBy' 
    ]),
    ...mapActions({
      // 將 `this.add()` 對映為 
      // `this.$store. dispatch('increment')`
      add: 'increment' 
    })
  }
}
複製程式碼

4、原始碼解析

1、思路

  • flux思想

    • 問題:在開發中面臨最多的場景是狀態重複但是不集中,在不同的元件中依賴了同樣的狀態,重複就會導致不對等的風險。
    • 思想:基於 FLUX 的思想,我們設計的狀態管理將是中心化的工具,也就是集中式儲存管理應用的所有元件的狀態,將所有的狀態放在一個全域性的 Tree 結構中,集中放在一起的好處是可以有效避免重複的問題,也更好的管理,將狀態和檢視層解耦。
    • 解決:使用全域性的store物件管理狀態和資料,單一狀態樹
  • 狀態流轉

    • 單一流轉
    • 同步和非同步分層:mutations負責同步狀態管理、actions負責非同步事件(內部通過mutations改變狀態)
  • 與vue整合

    • 通過外掛將 vue 整合在一起,通過 mixin 將 $store 這樣的快速訪問 store 的快捷屬性注入到每一個 vue 例項中
  • 響應式

    • 利用vue的data響應式實現
  • 擴充套件

    • 輔助函式
    • 模組化
    • 外掛支援

2、原始碼解析

1、store註冊

/**
* store.js - store 註冊
*/
let Vue

// vue 外掛必須要這個 install 函式
export function install(_Vue) {
  Vue = _Vue // 拿到 Vue 的構造器,存起來
  // 通過 mixin 注入到每一個vue例項 👉 https://cn.vuejs.org/v2/guide/mixins.html
  Vue.mixin({ beforeCreate: vuexInit })
  
  function vuexInit () {
    const options = this.$options //建立物件入參
    // 這樣就可以通過 this.$store 訪問到 Vuex 例項,拿到 store 了
    if (options.store) {
      this.$store = typeof options.store === 'function'
        ? options.store()
        : options.store
    } else if (options.parent && options.parent.$store) {
      this.$store = options.parent.$store
    }
  }
}
複製程式碼

2、store的響應式

/**
* store.js - 實現響應式
*/
export class Store {
  constructor(options = {}) {
    resetStoreVM(this, options.state)
  }
  
  get state () {
    return this._vm._data.$$state
  }
}

function resetStoreVM(store, state) {
  // 因為 vue 例項的 data 是響應式的,正好利用這一點,就可以實現 state 的響應式
  store._vm = new Vue({
    data: {
      $$state: state
    }
  })
}
複製程式碼

3、衍生資料

/**
* store.js - 衍生資料(getters)
*/
export class Store {
  constructor(options = {}) {
    
    const state = options.state
    
    resetStoreVM(this, state)
    
    // 我們用 getters 來收集衍生資料 computed
    this.getters = {}
    
    // 簡單處理一下,衍生不就是計算一下嘛,傳人 state
    _.forEach(this.getters, (name, getterFn) => {
      Object.defineProperty(this.getters, name, {
        get: () => getterFn(this.state)
      })
    })
  }
  
  get state () {
    return this._vm._data.$$state
  }
}

function resetStoreVM(store, state) {
  store._vm = new Vue({
    data: {
      $$state: state
    }
  })
}
複製程式碼

4、Actions/Mutations

/**
* store.js - Actions/Mutations 行為改變資料
*/
export class Store {
  constructor(options = {}) {
    
    const state = options.state
    
    resetStoreVM(this, state)
    
    this.getters = {}
    
    _.forEach(options.getters, (name, getterFn) => {
      Object.defineProperty(this.getters, name, {
        get: () => getterFn(this.state)
      })
    })
    
    // 定義的行為,分別對應非同步和同步行為處理
    this.actions = {}
    this.mutations = {}
    
    _.forEach(options.mutations, (name, mutation) => {
      this.mutations[name] = payload => {
        // 最終執行的就是 this._vm_data.$$state.xxx = xxx 這種操作
        mutation(this.state, payload)
      }
    })
    
    _.forEach(options.actions, (name, action) => {
      this.actions[name] = payload => {
        // action 專注於處理非同步,這裡傳入 this,這樣就可以在非同步裡面通過 commit 觸發 mutation 同步資料變化了
        action(this, payload)
      }
    })
  }
  
  // 觸發 mutation 的方式固定是 commit
  commit(type, payload) {
    this.mutations[type](payload)
  }
  
  // 觸發 action 的方式固定是 dispatch
  dispatch(type, payload) {
    this.actions[type](payload)
  }
  
  get state () {
    return this._vm._data.$$state
  }
}

function resetStoreVM(store, state) {
  store._vm = new Vue({
    data: {
      $$state: state
    }
  })
}
複製程式碼

5、分形,拆分出多個 Module

// module 可以對狀態模型進行分層,每個 module 又含有自己的 state、getters、actions 等

// 定義一個 module 基類
class Module {
	constructor(rawModule) {
    this.state = rawModule || {}
    this._rawModule = rawModule
    this._children = {}
  }
  
  getChild (key) {
    return this._children[key]
  }
  
   addChild (key, module) {
    this._children[key] = module
  }
}

// module-collection.js 把 module 收集起來
class ModuleCollection {
  constructor(options = {}) {
    this.register([], options)
  }
  
  register(path, rawModule) {
    const newModule = new Module(rawModule)
    if (path.length === 0 ) {
      // 如果是根模組 將這個模組掛在到根例項上
      this.root = newModule
    }
    else {
      const parent = path.slice(0, -1).reduce((module, key) => {
        return module.getChild(key)
      }, this.root)
      
      parent.addChild(path[path.length - 1], newModule)
    }
    
    // 如果有 modules,開始遞迴註冊一波
    if (rawModule.modules) {
      _.forEach(rawModule.modules, (key, rawChildModule) => {
        this.register(path.concat(key), rawChildModule)
      })
    }
  }
}

// store.js 中
export class Store {
  constructor(options = {}) {
    // 其餘程式碼...
    
    // 所有的 modules 註冊進來
    this._modules = new ModuleCollection(options)
    
    // 但是這些 modules 中的 actions, mutations, getters 都沒有註冊,所以我們原來的方法要重新寫一下
    // 遞迴的去註冊一下就行了,這裡抽離一個方法出來實現
    installModule(this, this.state, [], this._modules.root);
  }
}

function installModule(store, state, path, root) {
  // getters
  const getters = root._rawModule.getters
  if (getters) {
    _.forEach(getters, (name, getterFn) => {
      Object.defineProperty(store.getters, name, {
        get: () => getterFn(root.state)
      })
    })
  }
  
  // mutations
  const mutations = root._rawModule.mutations
  if (mutations) {
    _.forEach(mutations, (name, mutation) => {
      let _mutations = store.mutations[name] || (store.mutations[name] = [])
      _mutations.push(payload => {
        mutation(root.state, payload)
      })
      
      store.mutations[name] = _mutations
    })
  }
  
  // actions
  const actions = root._rawModule.actions
  if (actions) {
    _.forEach(actions, (name, action) => {
      let _actions = store.actions[name] || (store.actions[name] = [])
      _actions.push(payload => {
        action(store, payload)
      })
      
      store.actions[name] = _actions
    })
  }
  
  // 遞迴
  _.forEach(root._children, (name, childModule) => {
    installModule(this, this.state, path.concat(name), childModule)
  })
}
複製程式碼

6、外掛機制

(options.plugins || []).forEach(plugin => plugin(this))
複製程式碼

以上只是以最簡化的程式碼實現了 vuex 核心的 state module actions mutations getters 機制,如果對原始碼感興趣,可以看看若川的文章

二、Vue-ssr

參考資料:

1、vue-ssr官網指南

1、模版渲染

1、例項render屬性

  • 渲染函式:(createElement: () => VNode, [context]) => VNode

    • 入參:接收一個 createElement 方法作為第一個引數用來建立 VNode
    • 出參:VNode物件
    • 如果元件是一個函式元件,渲染函式還會接收一個額外的 context 引數,為沒有例項的函式元件提供上下文資訊
    • Vue 選項中的 render 函式若存在,則 Vue 建構函式不會從 template 選項或通過 el 選項指定的掛載元素中提取出的 HTML 模板編譯渲染函式,注意:.vue元件中temlate部分會渲染,需去除。
    • 元件樹中的所有 VNode 必須是唯一的,需要重複很多次的元素/元件,你可以使用工廠函式來實現
Vue.component('anchored-heading', {
  render: function (createElement) {
    return createElement(
      'h' + this.level,   // 標籤名稱
      this.$slots.default // 子節點陣列
    )
  },
  props: {
    level: {
      type: Number,
      required: true
    }
  }
})
/* 渲染模版如下:
<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>
</script>
*/

//VNode唯一,使用工程函式實現
{
  render: function(h){
    return h('div',
      Array.apply(null, { length: 20 }).map(function () {
        return createElement('p', 'hi')
      })
  	)
  }
}
複製程式碼

2、createElement函式

通常將 h 作為 createElement 的別名

/** 建立虛擬dom函式:
 * createElement({String | Object | Function}, [object], {string | array}): VNode
 */
createElement(
	//1、必輸:{String | Object | Function}
  //HTML 標籤名、元件選項物件,或者resolve了任何一種的一個async函式
	'div',
  
  //2、可選 {Object}
  //與模板中 attribute 對應的資料物件
  {
      // 與 `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
    },
  
    //3、可選:{String | Array}
    //子級虛擬節點 (VNodes),由 `createElement()` 構建而成,也可以使用字串來生成“文字虛擬節點”
		[
      '先寫一些文字',
      createElement('h1', '一則頭條'),
      createElement(MyComponent, {
        props: {
          someProp: 'foobar'
        }
      })
    ]
)
複製程式碼

3、Vnode物件

// VNode物件
{
  asyncFactory: undefined
  asyncMeta: undefined
  children: (2) [VNode, VNode]
  componentInstance: undefined
  componentOptions: undefined
  context: VueComponent {_uid: 4, _isVue: true, $options: {…}, _renderProxy: Proxy, _self: VueComponent, …}
  data: {
    attr: {
      id: 'div-id',
      name: 'div-name'
    }
  },
  elm: div
  fnContext: undefined
  fnOptions: undefined
  fnScopeId: undefined
  isAsyncPlaceholder: false
  isCloned: false
  isComment: false
  isOnce: false
  isRootInsert: true
  isStatic: false
  key: undefined  //key屬性
  ns: undefined
  parent: VNode {tag: "vue-component-92", data: {…}, children: undefined, text: undefined, elm: div, …}
  raw: false
  tag: "div"   //元件名
  text: undefined
  child: undefined
  [[Prototype]]: Object
}
複製程式碼

4、模版渲染

1、v-if/v-for

使用if和map函式實現

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

<!-- 如下渲染函式實現 -->
<!--
{
	render(h){
		if(this.items.length){
			return h('ul', this.items.map(item => {
					return h('li', item.name)
			})
		)else{
			return h('p', 'No items found.')
		}
	}
}
-->
複製程式碼

2、v-model

自己實現相關邏輯

<input v-model="value"></input>

<!-- 如下渲染函式實現 -->
<!--
{
	props:[value],
	render(h){
		let self = this
		return h('input', {
      //DOM property
			domPops:{
				value: self.value
			},
      on: {
				input: function(event){
					self.$emit('input', event.target.value)
				}
			}
		})
	}
}
-->
複製程式碼

3、事件&按鍵修飾符

  • .passive/.capture/.once:提供相應字首使用

    • .passive:字首&
    • .capture:字首!
    • .once:字首~
    • .capture.once或.once.capture:字首~!
  • 其他修飾符號可以呼叫私有方法

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

//其他修飾符
on: {
  keyup: function (event) {
    //.self
    // 如果觸發事件的元素不是事件繫結的元素
    // 則返回
    if (event.target !== event.currentTarget) return
    
    //.shift | .enter/.13
    // 如果按下去的不是 enter 鍵或者
    // 沒有同時按下 shift 鍵
    // 則返回
    if (!event.shiftKey || event.keyCode !== 13) return
    
    //.stop
    // 阻止 事件冒泡
    event.stopPropagation()
    
    //.prevent
    // 阻止該元素預設的 keyup 事件
    event.preventDefault()
    // ...
  }
}
複製程式碼

4、插槽

  • 靜態插槽:this.$slots
  • 作用域插槽:this.$scopedSlots
  • 元件中傳遞作用域插槽:scopedSlots屬性
<!-- <div><slot></slot></div> -->
render: function(createElement){
  return createElement('div', this.$slots.default)
}

<!-- <div><slot :text="message"></slot></div> -->
render: function(createELement){
  return createElement('div', this.$scopedSlots.default({
    text: this.message
  }))
}

<!-- 
	<div>
      <child v-slot="props">
        <span>{{props.text}}</span>
      </child>
  </div>
-->
render: function(createElement){
  return createElement('div', [
    createElement('child', {
      scopedSlots: {
        // 在資料物件中傳遞 `scopedSlots`
        // 格式為 { name: props => VNode | Array<VNode> }
        default: function(props){
          return createElement('span', props.text)
        }
      }
    })
  })
}        
複製程式碼

5、jsx

使用babel外掛,渲染函式使用h

import AnchoredHeading from './AnchoredHeading.vue'

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

2、csr與ssr

首先讓我們看看 CSR 的過程(劃重點,瀏覽器渲染原理基本流程)

1、csr

  • 瀏覽器渲染原理基本流程
  1. 瀏覽器通過請求得到一個HTML文字
  2. 渲染程序解析HTML文字,構建DOM
  3. 解析HTML的同時,如果遇到內聯樣式或者樣式指令碼,則下載並構建樣式規則(stytle rules),若遇到JavaScript指令碼,則會下載執行指令碼。
  4. DOM樹和樣式規則構建完成之後,渲染程序將兩者合併成渲染樹(render tree
  5. 渲染程序開始對渲染樹進行佈局,生成佈局樹(layout tree
  6. 渲染程序對佈局樹進行繪製,生成繪製記錄
  7. 渲染程序的對佈局樹進行分層,分別柵格化每一層,並得到合成幀
  8. 渲染程序將合成幀資訊傳送給GPU程序顯示到頁面中
  • 流程:載入html檔案、下載資源、指令碼解析、執行往div中插入元素,渲染頁面
  • 特點:不利用seo、首屏載入時間長(time-to-content)

很容易發現,CSR 的特點就是會在瀏覽器端的執行時去動態的渲染、更新 DOM 節點,特別是 SPA 應用來說,其模版 HTML 只有一個 DIV,然後是執行時(ReactVueSvelte 等)動態的往裡插入內容,這樣的話各種 BaiduSpider 拿不到啥有效資訊,自然 SEO 就不好了,專案一旦複雜起來, bundle 可能超乎尋常的大...這也是一個開銷。

2、ssr

服務端完成了渲染過程,將渲染完成的 HTML 字串或者流返回給瀏覽器,就少了指令碼解析、執行這一環節,理論上 FP 表現的更佳,SEO 同樣提升。缺點:

  • 複雜,同構專案的程式碼複雜度直線上升,因為要相容兩種環境
  • 對服務端的開銷大,既然 HTML 都是拼接好的,那麼傳輸的資料肯定就大多了,同時,拿 Node 舉例,在處理 Computed 密集型邏輯的時候是阻塞的,不得不上負載均衡、快取策略等來提升
  • CI/CD 更麻煩了,需要在一個 Server 環境,比如 Node

一般來說,ToB 的業務場景基本不需要 SSR,需要 SSR 的一定是對首屏或者 SEO 有強訴求的,部分場景可使用預渲染。

CSRSSR ,我們現今常見的渲染方案有6-7種:

3、同構直出

一份程式碼,既可以客戶端渲染,也可以服務端渲染,採用水合 hydration 方案,對 FP 有幫助,但是不能提升 TTI(可互動時間)。

1、原理

ssr渲染頁面整體結構提升fp,在此基礎上csr中新增事件等操作完成最終可互動的頁面。ssr只⽀持beforeCreate、created⽣命週期,csr中支援所有操作建議放置在mounted事件後。csr為頁面 = 模組 + 資料,應用 = 路由 + 頁面,同構就是同構路由模版資料

2、實踐

  1. css如何處理:服務端的 webpack 不用關注 CSS,客戶端會打包出來的,到時候推 CDN,然後修改 public path 即可

  2. 服務端的程式碼不需要分 chunkNode 基於記憶體一次性讀取反而更高效

  3. 如果有一些方法需要在特定的環境執行,比如客戶端環境中上報日誌,可以利用 beforeMouted 之後的生命週期都不會在服務端執行這一特點,當然也可以使用 isBrowser 這種判斷

  4. CSR 和 SSR 的切換和降級

    // 總有一些奇奇怪怪的場景,比如就只需要 CSR,不需要 SSR
    // 或者在 SSR 渲染的時候出錯了,頁面最好不要崩潰啊,可以降級成 CSR 渲染,保證頁面能夠出來
    
    // 互相切換的話,總得有個標識是吧,告訴我用 CSR 還是 SSR
    // search 就不錯,/demo?ssr=true
    module.exports = function(req, res) {
      if(req.query.ssr === 'true'){
        const context = { url: req.url }
        renderer.renderToString(context, (err, html) => {
          if(err){
            res.render('demo') // views 檔案下的 demo.html
          }
          res.end(html)
        })
      } else {
        res.render('demo')
      }
    }
    複製程式碼
  5. Axios 封裝,至少區分環境,在客戶端環境是需要做代理的

3、Vue-ssr 優化方案

  1. 頁面級別的快取,比如 nginx micro-caching
  2. 設定 serverCacheKey,如果相同,將使用快取,元件級別的快取
  3. CGI 快取,通過 memcache 等,將相同的資料返回快取一下,注意設定快取更新機制
  4. 流式傳輸,但是必須在asyncData 之後,否則沒有資料,說明也可能會被 CGI 耗時阻塞(服務端獲取資料後,載入至全域性,客戶端直接使用即可)
  5. 分塊傳輸,這樣前置的 CGI 完成就會渲染輸出
  6. JSC,就是不用 vue-loader,最快的方案

4、程式碼實戰

1、Npm Script

"build": "npm run build:client && npm run build:server",
"server": "cross-env NODE_ENV=production nodemon ./server/index.js",
"dev:server": "cross-env NODE_ENV=development nodemon ./server/index.js",
"build:client": "vue-cli-service build",
"build:server": "cross-env VUE_ENV=server vue-cli-service build  --no-clean",
"dev:client": "vue-cli-service serve"

vue inspect --mode "production" > output.js
複製程式碼

2、Project Info

  • cli: vue-cli @vue/cli 4.5.13
  • ui: element-ui
  • state: vuex
  • router: vue-router hash mode
  • lang: vue-i18n
  • http: axios
  • package manager: npm
  • unit-test: jest、mocha
  • 靜態檢查和格式化工具: eslint、prettier
  • Mock:mockjs
  • server: koa、koa-mount、koa-router、koa-static、xss
  • vue plugins: vue-infinite-loading、vue-meta、vue-server-renderer(ssr)
  • tools: reset-css、cross-env(環境變數配置)
  • 推薦服務端渲染框架:nuxt.js

3、Project Structure

├── mock              //mock腳步及資料
├── node_modules      //模組內容
├── public            //靜態資原始檔,不參與webpack打包,包含server.html和index.html
├── server            //web伺服器程式碼,實現ssr
├── utils             //server和client公用utils工具類
├── src               //原始碼目錄
│   ├── assets        //靜態資源,被webpack打包
│   ├── components    //公用元件位置
│   ├── directives    //全域性指令
│   ├── lang          //語言檔案
│   ├── router        //router
│   ├── store         //store
│   ├── utils         //工具方法,request為axios庫檔案
│   ├── views         //按照功能板塊劃分出的頁面
│   |   └── home      //子功能模組
│   |       ├── store //子功能模組資料
│   |       ├── router//子功能模組路由
│   |       ├── i18n  //子功能模組語言
│   |       └── mock  //子功能模組mock資料
│   ├── App.vue       //入口頁面
│   ├── app.js        //應用入口檔案,app工程函式
│   ├── client-entry.js  //客戶端入口檔案,初始化store、掛載dom
│   └── server-entry.js  //服務端入口檔案,返回promise,處理路由、掛載store
├── test              //單元測試
├── .browserslistrc   //browserify配置
├── .eslintrc.js      //eslint配置
├── .babel.config.js  //bable配置
├── package.json      //npm包配置
├── nodemon.json      //nodemon配置檔案,設定node環境變數
├── vue.config.js     //vue配置,區分server和client
└── README.md         //專案說明檔案
複製程式碼

4、SSR改造步驟

  1. 相關外掛及庫安裝,配置打包package.json指令碼

    1. server:koa、koa-mount、koa-router、koa-static、xss、nodemon
    2. vue-ssr:vue-server-renderer(使用server-plugin和client-plugin)
    3. 環境變數配置:cross-env NODE_ENV=production 或者 nodemon.json
  2. server程式碼新增:

    1. koa程式碼啟動:監聽埠

    2. 靜態資源:使用koa-static、koa-mount掛載靜態伺服器

    3. api介面:使用koa-mount掛載api介面,app.use(KoaMount("/api", ApiRequest))

    4. 路由掛載:返回ssr渲染頁面

      1. 使用koa-router啟動路由,監聽任何根路徑,返回promise:router.get('/(.*)', async ctx => {})
      2. 使用vue-server-renderer外掛,渲染返回最終的ssr渲染頁面
      3. 使用vue-ssr-server-manifest.json渲染頁面,使用vue-ssr-client-manifest.json啟用頁面
     const VueServerRenderer = require("vue-server-renderer"); //外掛
     const serverBundle = require("../dist/vue-ssr-server-bundle.json"); //服務端bundle
     const clientManifest = require("../dist/vue-ssr-client-manifest.json");//客戶端啟用bundle
     const template = fs.readFileSync( //渲染模版
       path.resolve(__dirname, "../dist/server.html"),
       "utf-8"
     );
    
     router.get("/(.*)", async (ctx) => {
       ctx.body = await new Promise((resolve, reject) => {
         const render = VueServerRenderer.createBundleRenderer(serverBundle, {
           runInNewContext: false,
           template,
           clientManifest, //注入客戶端js,script標籤
         });
         //渲染函式入參context,訪問的路徑path傳入渲染函式中,獲取ssr渲染頁面
         const context = { url: ctx.path }; 
         render.renderToString(context, (err, html) => {
           if (err) {
             reject(err);
           }
           resolve(html);
         });
       });
     });
    複製程式碼
  3. 原client程式碼改造為同構程式碼

    1. 新增server.html入口模版,用於伺服器渲染

    2. 拆分webpack入口:server-entry.js、client-entry.js,修改webpack配置

      1. client-entry.js: 掛載dom,同步伺服器端store資料

      2. server-entry.js: 返回promise函式,resolve(app),處理路由和store資料

      3. vue.config.js: 區分server和client

        1. server:服務端ajax請求必須是是絕對路徑,新增DefinePlugin外掛
        2. server和client區分:entry、target、output、server新增server-plugin,client新增client-plugin
        3. server:node端不需要打包node_modules⾥⾯的模組但允許css檔案,externals = nodeExternals({allowlist: /.css$/})
        4. 其他優化:vue-loader的cache處理、extract-css的bug修復
     //server-entry.js
     export default (context) => {
       return new Promise((resolve, reject) => {
         const { app, router, store } = createVueApp();
         const meta = app.$meta();
         // 根據服務端傳入的url,設定當前客戶端的url
         router.push(context.url);
         // 有可能是非同步路由,要等待非同步路由和鉤子函式解析完
         router.onReady(() => {
           // 等待serverPrefetch中的資料返回收,設定state
           context.rendered = () => {
             // After the app is rendered, our store is now
             // filled with the state from our components.
             // When we attach the state to the context, and the `template` option
             // is used for the renderer, the state will automatically be
             // serialized and injected into the HTML as `window.__INITIAL_STATE__`.
             context.state = store.state;
             context.meta = meta;
           };
           resolve(app);
         }, reject);
       });
     };
     //client-entry.js
     if (window.__INITIAL_STATE__) {
       store.replaceState(window.__INITIAL_STATE__);
     }
     app.$mount("#app");
    複製程式碼
    1. vue例項拆分:app.js,使用工程函式,訪問伺服器多次請求同例項汙染,下同

    2. router例項拆分:使用工廠函式

      1. 路由如何處理:server程式碼中通過context傳入url,打包入口server-entry.js中傳入router.push(context.url);
      2. 非同步路由處理:使用router.onReady(() => {resolve(app)}, reject)
    3. store例項拆分:使用工廠函式

    4. view頁面資料處理:新增serverPrefetch服務端資料獲取函式,可在伺服器獲取資料,注意:老api為asycndata只能在根元件使用、serverPrefect新api任意元件使用

5、SSR相關問題

  1. 如何等待非同步請求的資料返回後,再渲染⻚⾯;客戶端中的store如何同步服務端的資料?

    1. 元件中新增serverPrefetch選項,返回promise
    2. 服務端⼊⼝server-entry.js新增context.rendered回撥,渲染完成後執⾏,自動序列化注入window.__INITIAL_STATE__變數
    3. 設定context.state = store.state
    4. 客戶端⼊⼝獲取window.__INITIAL_STATE__,設定store初始狀態
  2. meta、title等標籤如何注⼊?

    1. 使用vue-meta外掛:vue.use(vueMeta)
    2. 主元件App.vue,使用metaInfo新增meta和title
    3. 子元件中可使用metaInfo函式,覆蓋預設的metaInfo
    4. server-entry中拿到meta,並將其傳⼊到context中,渲染server.html中的meta資訊
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width,initial-scale=1.0">
        {{{ meta.inject().title.text() }}} 
        {{{ meta.inject().meta.text() }}}
      </head>
      <body>
        <!--vue-ssr-outlet-->
      </body>
    </html>
    複製程式碼
  3. 服務端渲染會執⾏的⽣命週期: 只執⾏beforeCreate created,不要在這兩個⽣命週期⾥⾯新增定時器或者使⽤window等

  4. 伺服器本地開發熱更新:webpack配置讀取在記憶體中

最後

如果你覺得此文對你有一丁點幫助,點個贊。或者可以加入我的開發交流群:1025263163相互學習,我們會有專業的技術答疑解惑

如果你覺得這篇文章對你有點用的話,麻煩請給我們的開源專案點點star:http://github.crmeb.net/u/defu不勝感激 !

PHP學習手冊:https://doc.crmeb.com
技術交流論壇:https://q.crmeb.com