vuex原始碼解析

是呀呀呀發表於2019-02-19

vuex簡介

能看到此文章的人,應該大部分都已經使用過vuex了,想更深一步瞭解vuex的內部實現原理。所以簡介就少介紹一點。官網介紹說Vuex 是一個專為 Vue.js 應用程式開發的狀態管理模式。它採用集中式儲存管理應用的所有元件的狀態,並以相應的規則保證狀態以一種可預測的方式發生變化。資料流的狀態非常清晰,按照 元件dispatch Action -> action內部commit Mutation -> Mutation再 mutate state 的資料,在觸發render函式引起檢視的更新。附上一張官網的流程圖及vuex的官網地址:vuex.vuejs.org/zh/

vuex原始碼解析

Questions

在使用vuex的時候,大家有沒有如下幾個疑問,帶著這幾個疑問,再去看原始碼,從中找到解答,這樣對vuex的理解可以加深一些。

  1. 官網在嚴格模式下有說明:在嚴格模式下,無論何時發生了狀態變更且不是由 mutation 函式引起的,將會丟擲錯誤。vuex是如何檢測狀態改變是由mutation函式引起的?
  2. 通過在根例項中註冊 store 選項,該 store 例項會注入到根元件下的所有子元件中。為什麼所有子元件都可以取到store?
  3. 為什麼用到的屬性在state中也必須要提前定義好,vue檢視才可以響應?
  4. 在呼叫dispatch和commit時,只需傳入(type, payload),為什麼action函式和mutation函式能夠在第一個引數中解構出來state、commit等? 帶著這些問題,我們來看看vuex的原始碼,從中尋找到答案。

原始碼目錄結構

vuex的原始碼結構非常簡潔清晰,程式碼量也不是很大,大家不要感到恐慌。

vuex原始碼解析

vuex掛載

vue使用外掛的方法很簡單,只需Vue.use(Plugins),對於vuex,只需要Vue.use(Vuex)即可。在use 的內部是如何實現外掛的註冊呢?讀過vue原始碼的都知道,如果傳入的引數有 install 方法,則呼叫外掛的 install 方法,如果傳入的引數本身是一個function,則直接執行。那麼我們接下來就需要去 vuex 暴露出來的 install 方法去看看具體幹了什麼。

store.js

export function install(_Vue) {
  // vue.use原理:呼叫外掛的install方法進行外掛註冊,並向install方法傳遞Vue物件作為第一個引數
  if (Vue && _Vue === Vue) {
    if (process.env.NODE_ENV !== "production") {
      console.error(
        "[vuex] already installed. Vue.use(Vuex) should be called only once."
      );
    }
    return;
  }
  Vue = _Vue; // 為了引用vue的watch方法
  applyMixin(Vue);
}
複製程式碼

在 install 中,將 vue 物件賦給了全域性變數 Vue,並作為引數傳給了 applyMixin 方法。那麼在 applyMixin 方法中幹了什麼呢?

mixin.js

function vuexInit() {
    const options = this.$options;
    // store injection
    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;
    }
  }
複製程式碼

在這裡首先檢查了一下 vue 的版本,2以上的版本把 vuexInit 函式混入 vuex 的 beforeCreate 鉤子函式中。 在 vuexInit 中,將 new Vue() 時傳入的 store 設定到 this 物件的 $store 屬性上,子元件則從其父元件上引用其 $store 屬性進行層層巢狀設定,保證每一個元件中都可以通過 this.$store 取到 store 物件。 這也就解答了我們問題 2 中的問題。通過在根例項中註冊 store 選項,該 store 例項會注入到根元件下的所有子元件中,注入方法是子從父拿,root從options拿。

接下來讓我們看看new Vuex.Store()都幹了什麼。

store建構函式

store物件構建的主要程式碼都在store.js中,是vuex的核心程式碼。

首先,在 constructor 中進行了 Vue 的判斷,如果沒有通過 Vue.use(Vuex) 進行 Vuex 的註冊,則呼叫 install 函式註冊。( 通過 script 標籤引入時不需要手動呼叫 Vue.use(Vuex) ) 並在非生產環境進行判斷: 必須呼叫 Vue.use(Vuex) 進行註冊,必須支援 Promise,必須用 new 建立 store。

if (!Vue && typeof window !== "undefined" && window.Vue) {
    install(window.Vue);
}

if (process.env.NODE_ENV !== "production") {
    assert(Vue, `must call Vue.use(Vuex) before creating a store instance.`);
    assert(
        typeof Promise !== "undefined",
        `vuex requires a Promise polyfill in this browser.`
    );
    assert(
        this instanceof Store,
        `store must be called with the new operator.`
    );
}
複製程式碼

然後進行一系列的屬性初始化。其中的重點是 new ModuleCollection(options),這個我們放在後面再講。先把 constructor 中的程式碼過完。

const { plugins = [], strict = false } = options;

// store internal state
this._committing = false; // 是否在進行提交mutation狀態標識
this._actions = Object.create(null); // 儲存action,_actions裡的函式已經是經過包裝後的
this._actionSubscribers = []; // action訂閱函式集合
this._mutations = Object.create(null); // 儲存mutations,_mutations裡的函式已經是經過包裝後的
this._wrappedGetters = Object.create(null); // 封裝後的getters集合物件
// Vuex支援store分模組傳入,在內部用Module建構函式將傳入的options構造成一個Module物件,
// 如果沒有命名模組,預設繫結在this._modules.root上
// ModuleCollection 內部呼叫 new Module建構函式
this._modules = new ModuleCollection(options);
this._modulesNamespaceMap = Object.create(null); // 模組名稱空間map
this._subscribers = []; // mutation訂閱函式集合
this._watcherVM = new Vue(); // Vue元件用於watch監視變化
複製程式碼

屬性初始化完畢後,首先從 this 中解構出原型上的 dispatchcommit 方法,並進行二次包裝,將 this 指向當前 store。

const store = this;
const { dispatch, commit } = this;
/**
 把 Store 類的 dispatch 和 commit 的方法的 this 指標指向當前 store 的例項上. 
 這樣做的目的可以保證當我們在元件中通過 this.$store 直接呼叫 dispatch/commit 方法時, 
 能夠使 dispatch/commit 方法中的 this 指向當前的 store 物件而不是當前元件的 this.
*/
this.dispatch = function boundDispatch(type, payload) {
    return dispatch.call(store, type, payload);
};
this.commit = function boundCommit(type, payload, options) {
    return commit.call(store, type, payload, options);
};

複製程式碼

接著往下走,包括嚴格模式的設定、根state的賦值、模組的註冊、state的響應式、外掛的註冊等等,其中的重點在 installModule 函式中,在這裡實現了所有modules的註冊。

//options中傳入的是否啟用嚴格模式
this.strict = strict;

// new ModuleCollection 構造出來的_mudules
const state = this._modules.root.state;

// 初始化元件樹根元件、註冊所有子元件,並將其中所有的getters儲存到this._wrappedGetters屬性中
installModule(this, state, [], this._modules.root);

//通過使用vue例項,初始化 store._vm,使state變成可響應的,並且將getters變成計算屬性
resetStoreVM(this, state);

// 註冊外掛
plugins.forEach(plugin => plugin(this));

// 除錯工具註冊
const useDevtools =
    options.devtools !== undefined ? options.devtools : Vue.config.devtools;
if (useDevtools) {
   devtoolPlugin(this);
}
複製程式碼

到此為止,constructor 中所有的程式碼已經分析完畢。其中的重點在 new ModuleCollection(options)installModule ,那麼接下來我們到它們的內部去看看,究竟都幹了些什麼。

ModuleCollection

由於 Vuex 使用單一狀態樹,應用的所有狀態會集中到一個比較大的物件。當應用變得非常複雜時,store 物件就有可能變得相當臃腫。Vuex 允許我們將 store 分割成模組(module),每個模組擁有自己的 state、mutation、action、getter、甚至是巢狀子模組。例如下面這樣:

const childModule = {
  state: { ... },
  mutations: { ... },
  actions: { ... }
}

const store = new Vuex.Store({
  state,
  getters,
  actions,
  mutations,
  modules: {
    childModule: childModule,
  }
})

複製程式碼

有了模組的概念,可以更好的規劃我們的程式碼。對於各個模組公用的資料,我們可以定義一個common store,別的模組用到的話直接通過 modules 的方法引入即可,無需重複的在每一個模組都寫一遍相同的程式碼。這樣我們就可以通過 store.state.childModule 拿到childModule中的 state 狀態, 對於Module的內部是如何實現的呢?

export default class ModuleCollection {
  constructor(rawRootModule) {
    // 註冊根module,引數是new Vuex.Store時傳入的options
    this.register([], rawRootModule, false);
  }

  register(path, rawModule, runtime = true) {
    if (process.env.NODE_ENV !== "production") {
      assertRawModule(path, rawModule);
    }

    const newModule = new Module(rawModule, runtime);
    if (path.length === 0) {
      // 註冊根module
      this.root = newModule;
    } else {
      // 註冊子module,將子module新增到父module的_children屬性上
      const parent = this.get(path.slice(0, -1));
      parent.addChild(path[path.length - 1], newModule);
    }

    // 如果當前模組有子modules,迴圈註冊
    if (rawModule.modules) {
      forEachValue(rawModule.modules, (rawChildModule, key) => {
        this.register(path.concat(key), rawChildModule, runtime);
      });
    }
  }
}
複製程式碼

在ModuleCollection中又呼叫了Module建構函式,構造一個Module。

Module建構函式

constructor (rawModule, runtime) {
    // 初始化時為false
    this.runtime = runtime
    // 儲存子模組
    this._children = Object.create(null)
    // 將原來的module儲存,以備後續使用
    this._rawModule = rawModule
    const rawState = rawModule.state
    // 儲存原來module的state
    this.state = (typeof rawState === 'function' ? rawState() : rawState) || {}
  }
複製程式碼

通過以上程式碼可以看出,ModuleCollection 主要將傳入的 options 物件整個構造為一個 Module 物件,並迴圈呼叫 this.register([key], rawModule, false) 為其中的 modules 屬性進行模組註冊,使其都成為 Module 物件,最後 options 物件被構造成一個完整的 Module 樹。

經過 ModuleCollection 構造後的樹結構如下:(以上面的例子生成的樹結構)

vuex原始碼解析

模組已經建立好之後,接下來要做的就是 installModule。

installModule

首先我們來看一看執行完 constructor 中的 installModule 函式後,這棵樹的結構如何?

vuex原始碼解析

從上圖中可以看出,在執行完installModule函式後,每一個 module 中的 state 屬性都增加了 其子 module 中的 state 屬性,但此時的 state 還不是響應式的,並且新增加了 context 這個物件。裡面包含 dispatch 、 commit 等函式以及 state 、 getters 等屬性。它就是 vuex 官方文件中所說的Action 函式接受一個與 store 例項具有相同方法和屬性的 context 物件 這個 context 物件。我們平時在 store 中呼叫的 dispatch 和 commit 就是從這裡解構出來的。接下來讓我們看看 installModule 裡面執行了什麼。

function installModule(store, rootState, path, module, hot) {
  // 判斷是否是根節點,跟節點的path = []
  const isRoot = !path.length;

  // 取名稱空間,形式類似'childModule/'
  const namespace = store._modules.getNamespace(path);

  // 如果namespaced為true,存入_modulesNamespaceMap中
  if (module.namespaced) {
    store._modulesNamespaceMap[namespace] = module;
  }

  // 不是根節點,把子元件的每一個state設定到其父級的state屬性上
  if (!isRoot && !hot) {
    // 獲取當前元件的父元件state
    const parentState = getNestedState(rootState, path.slice(0, -1));
    // 獲取當前Module的名字
    const moduleName = path[path.length - 1];
    store._withCommit(() => {
      Vue.set(parentState, moduleName, module.state);
    });
  }

  // 給context物件賦值
  const local = (module.context = makeLocalContext(store, namespace, path));

  // 迴圈註冊每一個module的Mutation
  module.forEachMutation((mutation, key) => {
    const namespacedType = namespace + key;
    registerMutation(store, namespacedType, mutation, local);
  });

  // 迴圈註冊每一個module的Action
  module.forEachAction((action, key) => {
    const type = action.root ? key : namespace + key;
    const handler = action.handler || action;
    registerAction(store, type, handler, local);
  });

  // 迴圈註冊每一個module的Getter
  module.forEachGetter((getter, key) => {
    const namespacedType = namespace + key;
    registerGetter(store, namespacedType, getter, local);
  });

  // 迴圈_childern屬性
  module.forEachChild((child, key) => {
    installModule(store, rootState, path.concat(key), child, hot);
  });
}
複製程式碼

在installModule函式裡,首先判斷是否是根節點、是否設定了名稱空間。在設定了名稱空間的前提下,把 module 存入 store._modulesNamespaceMap 中。在不是跟節點並且不是 hot 的情況下,通過 getNestedState 獲取到父級的 state,並獲取當前 module 的名字, 用 Vue.set() 方法將當前 module 的 state 掛載到父 state 上。然後呼叫 makeLocalContext 函式給 module.context 賦值,設定區域性的 dispatch、commit方法以及getters和state。那麼來看一看這個函式。

function makeLocalContext(store, namespace, path) {
  // 是否有名稱空間
  const noNamespace = namespace === "";

  const local = {
    // 如果沒有名稱空間,直接返回store.dispatch;否則給type加上名稱空間,類似'childModule/'這種
    dispatch: noNamespace
      ? store.dispatch
      : (_type, _payload, _options) => {
          const args = unifyObjectStyle(_type, _payload, _options);
          const { payload, options } = args;
          let { type } = args;

          if (!options || !options.root) {
            type = namespace + type;
            if (
              process.env.NODE_ENV !== "production" &&
              !store._actions[type]
            ) {
              console.error(
                `[vuex] unknown local action type: ${
                  args.type
                }, global type: ${type}`
              );
              return;
            }
          }

          return store.dispatch(type, payload);
        },
    // 如果沒有名稱空間,直接返回store.commit;否則給type加上名稱空間
    commit: noNamespace
      ? store.commit
      : (_type, _payload, _options) => {
          const args = unifyObjectStyle(_type, _payload, _options);
          const { payload, options } = args;
          let { type } = args;

          if (!options || !options.root) {
            type = namespace + type;
            if (
              process.env.NODE_ENV !== "production" &&
              !store._mutations[type]
            ) {
              console.error(
                `[vuex] unknown local mutation type: ${
                  args.type
                }, global type: ${type}`
              );
              return;
            }
          }

          store.commit(type, payload, options);
        }
  };

  // getters and state object must be gotten lazily
  // because they will be changed by vm update
  Object.defineProperties(local, {
    getters: {
      get: noNamespace
        ? () => store.getters
        : () => makeLocalGetters(store, namespace)
    },
    state: {
      get: () => getNestedState(store.state, path)
    }
  });

  return local;
}
複製程式碼

經過 makeLocalContext 處理的返回值會賦值給 local 變數,這個變數會傳遞給 registerMutation、forEachAction、registerGetter 函式去進行相應的註冊。

mutation可以重複註冊,registerMutation 函式將我們傳入的 mutation 進行了一次包裝,將 state 作為第一個引數傳入,因此我們在呼叫 mutation 的時候可以從第一個引數中取到當前的 state 值。

function registerMutation(store, type, handler, local) {
  const entry = store._mutations[type] || (store._mutations[type] = []);
  entry.push(function wrappedMutationHandler(payload) {
    // 將this指向store,將makeLocalContext返回值中的state作為第一個引數,呼叫值執行的payload作為第二個引數
    // 因此我們呼叫commit去提交mutation的時候,可以從mutation的第一個引數中取到當前的state值。
    handler.call(store, local.state, payload);
  });
}
複製程式碼

action也可以重複註冊。註冊 action 的方法與 mutation 相似,registerAction 函式也將我們傳入的 action 進行了一次包裝。但是 action 中引數會變多,裡面包含 dispatch 、commit、local.getters、local.state、rootGetters、rootState,因此可以在一個 action 中 dispatch 另一個 action 或者去 commit 一個 mutation。這裡也就解答了問題4中提出的疑問。

function registerAction(store, type, handler, local) {
  const entry = store._actions[type] || (store._actions[type] = []);
  entry.push(function wrappedActionHandler(payload, cb) {
    //與mutation不同,action的第一個引數是一個物件,裡面包含dispatch、commit、getters、state、rootGetters、rootState
    let res = handler.call(
      store,
      {
        dispatch: local.dispatch,
        commit: local.commit,
        getters: local.getters,
        state: local.state,
        rootGetters: store.getters,
        rootState: store.state
      },
      payload,
      cb
    );
    if (!isPromise(res)) {
      res = Promise.resolve(res);
    }
    if (store._devtoolHook) {
      return res.catch(err => {
        store._devtoolHook.emit("vuex:error", err);
        throw err;
      });
    } else {
      return res;
    }
  });
}
複製程式碼

註冊 getters,從getters的第一個引數中可以取到local state、local getters、root state、root getters。getters不允許重複註冊。

function registerGetter(store, type, rawGetter, local) {
  // getters不允許重複
  if (store._wrappedGetters[type]) {
    if (process.env.NODE_ENV !== "production") {
      console.error(`[vuex] duplicate getter key: ${type}`);
    }
    return;
  }

  store._wrappedGetters[type] = function wrappedGetter(store) {
    // getters的第一個引數包含local state、local getters、root state、root getters
    return rawGetter(
      local.state, // local state
      local.getters, // local getters
      store.state, // root state
      store.getters // root getters
    );
  };
}
複製程式碼

現在 store 的 _mutation、_action 中已經有了我們自行定義的的 mutation 和 action函式,並且經過了一層內部報裝。當我們在元件中執行 this.$store.dispatch()this.$store.commit() 的時候,是如何呼叫到相應的函式的呢?接下來讓我們來看一看 store 上的 dispatch 和 commit 函式。

commit

commit 函式先進行引數的適配處理,然後判斷當前 action type 是否存在,如果存在則呼叫 _withCommit 函式執行相應的 mutation 。

  // 提交mutation函式
  commit(_type, _payload, _options) {
    // check object-style commit
    //commit支援兩種呼叫方式,一種是直接commit('getName','vuex'),另一種是commit({type:'getName',name:'vuex'}),
    //unifyObjectStyle適配兩種方式
    const { type, payload, options } = unifyObjectStyle(
      _type,
      _payload,
      _options
    );

    const mutation = { type, payload };
    // 這裡的entry取值就是我們在registerMutation函式中push到_mutations中的函式,已經經過處理
    const entry = this._mutations[type];
    if (!entry) {
      if (process.env.NODE_ENV !== "production") {
        console.error(`[vuex] unknown mutation type: ${type}`);
      }
      return;
    }

    // 專用修改state方法,其他修改state方法均是非法修改,在嚴格模式下,無論何時發生了狀態變更且不是由 mutation 函式引起的,將會丟擲錯誤
    // 不要在釋出環境下啟用嚴格模式!嚴格模式會深度監測狀態樹來檢測不合規的狀態變更——請確保在釋出環境下關閉嚴格模式,以避免效能損失。
    this._withCommit(() => {
      entry.forEach(function commitIterator(handler) {
        handler(payload);
      });
    });

    // 訂閱者函式遍歷執行,傳入當前的mutation物件和當前的state
    this._subscribers.forEach(sub => sub(mutation, this.state));

    if (process.env.NODE_ENV !== "production" && options && options.silent) {
      console.warn(
        `[vuex] mutation type: ${type}. Silent option has been removed. ` +
          "Use the filter functionality in the vue-devtools"
      );
    }
  }
複製程式碼

在 commit 函式中呼叫了 _withCommit 這個函式, 程式碼如下。 _withCommit 是一個代理方法,所有觸發 mutation 的進行 state 修改的操作都經過它,由此來統一管理監控 state 狀態的修改。在嚴格模式下,會深度監聽 state 的變化,如果沒有通過 mutation 去修改 state,則會報錯。官方建議 不要在釋出環境下啟用嚴格模式! 請確保在釋出環境下關閉嚴格模式,以避免效能損失。這裡就解答了問題1中的疑問。

_withCommit(fn) {
    // 儲存之前的提交狀態false
    const committing = this._committing;

    // 進行本次提交,若不設定為true,直接修改state,strict模式下,Vuex將會產生非法修改state的警告
    this._committing = true;

    // 修改state
    fn();

    // 修改完成,還原本次修改之前的狀態false
    this._committing = committing;
}
複製程式碼

dispatch

dispatch 和 commit 的原理相同。如果有多個同名 action,會等到所有的 action 函式完成後,返回的 Promise 才會執行。

// 觸發action函式
  dispatch(_type, _payload) {
    // check object-style dispatch
    const { type, payload } = unifyObjectStyle(_type, _payload);

    const action = { type, payload };
    const entry = this._actions[type];
    if (!entry) {
      if (process.env.NODE_ENV !== "production") {
        console.error(`[vuex] unknown action type: ${type}`);
      }
      return;
    }

    // 執行所有的訂閱者函式
    this._actionSubscribers.forEach(sub => sub(action, this.state));

    return entry.length > 1
      ? Promise.all(entry.map(handler => handler(payload)))
      : entry[0](payload);
  }
複製程式碼

至此,整個 installModule 裡涉及到的內容已經分析完畢。現在我們來看一看store樹結構。

vuex原始碼解析

我們在 options 中傳進來的 action 和 mutation 已經在 store 中。但是 state 和 getters 還沒有。這就是接下來的 resetStoreVM 方法做的事情。

resetStoreVM

resetStoreVM 函式中包括初始化 store._vm,觀測 state 和 getters 的變化以及執行是否開啟嚴格模式等。state 屬性賦值給 vue 例項的 data 屬性,因此資料是可響應的。這也就解答了問題 3,用到的屬性在 state 中也必須要提前定義好,vue 檢視才可以響應。

function resetStoreVM(store, state, hot) {
  //儲存老的vm
  const oldVm = store._vm;

  // 初始化 store 的 getters
  store.getters = {};

  // _wrappedGetters 是之前在 registerGetter 函式中賦值的
  const wrappedGetters = store._wrappedGetters;

  const computed = {};
  
  forEachValue(wrappedGetters, (fn, key) => {
    // 將getters放入計算屬性中,需要將store傳入
    computed[key] = () => fn(store);
    // 為了可以通過this.$store.getters.xxx訪問getters
    Object.defineProperty(store.getters, key, {
      get: () => store._vm[key],
      enumerable: true // for local getters
    });
  });

  // use a Vue instance to store the state tree
  // suppress warnings just in case the user has added
  // some funky global mixins
  // 用一個vue例項來儲存store樹,將getters作為計算屬性傳入,訪問this.$store.getters.xxx實際上訪問的是store._vm[xxx]
  const silent = Vue.config.silent;
  Vue.config.silent = true;
  store._vm = new Vue({
    data: {
      $$state: state
    },
    computed
  });
  Vue.config.silent = silent;

  // enable strict mode for new vm
  // 如果是嚴格模式,則啟用嚴格模式,深度 watch state 屬性
  if (store.strict) {
    enableStrictMode(store);
  }

  // 若存在oldVm,解除對state的引用,等dom更新後把舊的vue例項銷燬
  if (oldVm) {
    if (hot) {
      // dispatch changes in all subscribed watchers
      // to force getter re-evaluation for hot reloading.
      store._withCommit(() => {
        oldVm._data.$$state = null;
      });
    }
    Vue.nextTick(() => oldVm.$destroy());
  }
}
複製程式碼

開啟嚴格模式時,會深度監聽 $$state 的變化,如果不是通過this._withCommit()方法觸發的state修改,也就是store._committing如果是false,就會報錯。

function enableStrictMode(store) {
  store._vm.$watch(
    function() {
      return this._data.$$state;
    },
    () => {
      if (process.env.NODE_ENV !== "production") {
        assert(
          store._committing,
          `do not mutate vuex store state outside mutation handlers.`
        );
      }
    },
    { deep: true, sync: true }
  );
}
複製程式碼

讓我們來看一看執行完 resetStoreVM 後的 store 結構。現在的 store 中已經有了 getters 屬性,並且 getters 和 state 都是響應式的。

vuex原始碼解析

至此 vuex 的核心程式碼初始化部分已經分析完畢。原始碼裡還包括一些外掛的註冊及暴露出來的 API 像 mapState mapGetters mapActions mapMutation等函式就不在這裡介紹了,感興趣的可以自行去原始碼裡看看,比較好理解。這裡就不做過多介紹。

總結

vuex的原始碼相比於vue的原始碼來說還是很好理解的。分析原始碼之前建議大家再細讀一遍官方文件,遇到不太理解的地方記下來,帶著問題去讀原始碼,有目的性的研究,可以加深記憶。閱讀的過程中,可以先寫一個小例子,引入 clone 下來的原始碼,一步一步分析執行過程。

相關文章