Vue3 源碼解析(九):setup 揭祕與 expose 的妙用

在前幾篇文章中我們一起學習了 Vue3 中新穎的 Composition API,而今天筆者要帶大家一起看一下 Vue3 中的另一個新鮮的寫法 —— setup。

在絕大多數情況,我們書寫的組件都是有狀態的組件,而這類組件在初始化的過程中會被標記爲 stateful comonents,當 Vue3 檢測到我們在處理這類有狀態組件時,就會調用函數 setupStatefulComponent ,來初始化一個狀態化組件。處理組件部分的源碼位置在: @vue/runtime-core/src/component.ts

setupStatefulComponent

接下來筆者就帶着大家一起來剖析一下 setupStatefulComponent 的過程:

function setupStatefulComponent(
  instance: ComponentInternalInstance,
  isSSR: boolean
) {
  const Component = instance.type as ComponentOptions

  if (__DEV__) { /* 檢測組件名稱、指令、編譯選項等等,有錯誤則報警 */ }
    
  // 0. 創建一個渲染代理的屬性的訪問緩存
  instance.accessCache = Object.create(null)
  // 1. 創建一個公共的示例或渲染器代理
  // 它將被標記爲 raw,所以它不會被追蹤
  instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandlers)

  // 2. 調用 setup()
  const { setup } = Component
  if (setup) {
    const setupContext = (instance.setupContext =
      setup.length > 1 ? createSetupContext(instance) : null)

    currentInstance = instance
    pauseTracking()
    const setupResult = callWithErrorHandling(
      setup,
      instance,
      ErrorCodes.SETUP_FUNCTION,
      [__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext]
    )
    resetTracking()
    currentInstance = null

    if (isPromise(setupResult)) {
      if (isSSR) {
        // 返回一個 promise,因此服務端渲染可以等待它執行。
        return setupResult
          .then((resolvedResult: unknown) => {
            handleSetupResult(instance, resolvedResult, isSSR)
          })
          .catch(e => {
            handleError(e, instance, ErrorCodes.SETUP_FUNCTION)
          })
      }
    } else {
      // 捕獲 Setup 執行結果
      handleSetupResult(instance, setupResult, isSSR)
    }
  } else {
    // 完成組件初始化
    finishComponentSetup(instance, isSSR)
  }
}

組件一開始會初始化一個 Component 變量,其中保存着組件的選項。接下來如果是 DEV 環境,則會開始檢測組件中的各種選項的命名,比如 name、components、directives 等,如果檢測有問題,就會在開發環境報出警告。

在檢測完畢後,會開始正經的初始化過程,首先會在實例上創建一個 accessCache 的屬性,該屬性用以緩存渲染器代理屬性,以減少讀取次數。之後會在組件實例上初始化一個代理屬性,這個代理屬性代理了組件的上下文,並且將它設置爲觀察原始值,這樣這個代理對象將不會被追蹤。

之後就開始處理我們本文關心的 setup 邏輯了。首先從組件中取出 setup 函數,這裏判斷是否存在 setup 函數,如果不存在,則直接跳轉到底部邏輯,執行 finishComponentSetup,完成組件初始化。否則就會進入 if (setup) 之後的分支條件中。

是否執行 createSetupContext 生成 setup 的上下文對象,取決於 setup 函數中形參的數量是否大於 1。

這裏需要注意的一個知識點是:在 function 函數對象上調用 length 時,返回值是這個函數的形參數量。

舉個例子:

setup() // setup.length === 0

setup(props) // setup.length === 1

setup(props, { emit, attrs }) // setup.length === 2

默認情況下,props 是調用 setup 時必傳的參數,所以是否需要去生成 setup 的上下文的條件就是 setup.length > 1 。

那麼順着代碼邏輯,我們一起來看一下 setup 上下文中究竟有些什麼東西。

export function createSetupContext(
  instance: ComponentInternalInstance
): SetupContext {
  const expose: SetupContext['expose'] = exposed => {
    instance.exposed = proxyRefs(exposed)
  }

  if (__DEV__) {
    /* DEV 邏輯忽略,對上下文選項設置 getter */
  } else {
    return {
      attrs: instance.attrs,
      slots: instance.slots,
      emit: instance.emit,
      expose
    }
  }
}

expose 的妙用

看到這段 createSetupContext 函數的邏輯,我們發現 setup 上下文中就如文檔中描述的一樣,有 attrs、slots、emit 這三種熟悉的屬性,而在這裏驚奇的發現竟然還有一個文檔中未說明的 expose 屬性返回。

expose 是早先 Vue RFC 中的一個提案,expose 的設想是提供一個像 expose({ ...publicMembers }) 這樣的組合式 API,這樣組件的作者就可以在 setup() 中使用該 API 來清除地控制哪些內容會明確地公開暴露給組件使用者。

當你在封裝組件時,如果嫌 ref 中暴露的內容過多,不妨用 expose 來約束一下輸出。當然這還僅僅是一個 RFC 提案,感興趣的小夥伴可以偷偷嚐鮮哦。

import { ref } from 'vue'
export default {
  setup(_, { expose }) {
    const count = ref(0)

    function increment() {
      count.value++
    }
    
    // 僅僅暴露 increment 給父組件
    expose({
      increment
    })

    return { increment, count }
  }
}

例如當你像上方代碼一樣使用 expose 時,父組件獲取的 ref 對象裏只會有 increment 屬性,而 count 屬性將不會暴露出去。

執行 setup 函數

在處理完 setupContext 的上下文後,組件會停止依賴收集,並且開始執行 setup 函數。

const setupResult = callWithErrorHandling(
  setup,
  instance,
  ErrorCodes.SETUP_FUNCTION,
  [__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext]
)

Vue 會通過 callWithErrorHandling 調用 setup 函數,這裏我們可以看最後一行,是作爲 args 參數傳入的,與上文描述一樣,props 會始終傳入,若是 setup.length <= 1 , setupContext 則爲 null。

調用完 setup 之後,會重置依賴收集狀態。接下來判斷 setupResult 的返回值類型。

如果 setup 函數的返回值是 promise 類型,並且是服務端渲染的,則會等待繼續執行。否則就會報錯,說當前版本的 Vue 並不支持 setup 返回 promise 對象。

如果不是 promise 類型返回值,則會通過 handleSetupResult 函數來處理返回結果。

export function handleSetupResult(
  instance: ComponentInternalInstance,
  setupResult: unknown,
  isSSR: boolean
) {
  if (isFunction(setupResult)) {
    // setup 返回了一個行內渲染函數
    if (__NODE_JS__ && (instance.type as ComponentOptions).__ssrInlineRender) {
      // 當這個函數的名字是 ssrRender (通過 SFC 的行內模式編譯)
      // 將函數作爲服務端渲染函數
      instance.ssrRender = setupResult
    } else {
      // 否則將函數作爲渲染函數
      instance.render = setupResult as InternalRenderFunction
    }
  } else if (isObject(setupResult)) {
    // 將返回對象轉換爲響應式對象,並設置爲實例的 setupState 屬性
    instance.setupState = proxyRefs(setupResult)
  }
  finishComponentSetup(instance, isSSR)
}

在 handleSetupResult 這個結果捕獲函數中,首先判斷 setup 返回結果的類型,如果是一個函數,並且又是服務端的行內模式渲染函數,則將該結果作爲 ssrRender 屬性;而在非服務端渲染的情況下,會直接當做 render 函數來處理。

接着會判斷 setup 返回結果如果是對象,就會將這個對象轉換成一個代理對象,並設置爲組件實例的 setupState 屬性。

最終還是會跟其他沒有 setup 函數的組件一樣,調用 finishComponentSetup 完成組件的創建。

finishComponentSetup

這個函數的主要作用是獲取併爲組件設置渲染函數,對於模板(template)以及渲染函數的獲取方式有以下三種規範行爲:

1、渲染函數可能已經存在,通過 setup 返回了結果。例如我們在上一節講的 setup 的返回值爲函數的情況。

2、如果 setup 沒有返回,則嘗試獲取組件模板並編譯,從 Component.render 中獲取渲染函數,

3、如果這個函數還是沒有渲染函數,則將 instance.render 設置爲空,以便它能從 mixins/extend 等方式中獲取渲染函數。

這個在這種規範行爲的指導下,首先判斷了服務端渲染的情況,接着判斷沒有 instance.render 存在的情況,當進行這種判斷時已經說明組件並沒有從 setup 中獲得渲染函數,在進行第二種行爲的嘗試。從組件中獲取模板,設置好編譯選項後調用 Component.render = compile(template, finalCompilerOptions) 進行編譯,這部分編譯的知識在我的第一篇文章編譯流程中有過詳細介紹。

最後將編譯後的渲染函數賦值給組件實例的 render 屬性,如果沒有則賦值爲 NOOP 空函數。

接着判斷渲染函數是否是使用了 with 塊包裹的運行時編譯的渲染函數,如果是這種情況則會將渲染代理設置爲一個不同的 has handler 代理陷阱,它的性能更強並且能夠去避免檢測一些全局變量。

至此組件的初始化完畢,渲染函數也設置結束了。

export function finishComponentSetup(
  instance: ComponentInternalInstance,
  isSSR: boolean,
  skipOptions?: boolean
) {
  const Component = instance.type as ComponentOptions

  // 模板 / 渲染函數的規範行爲
  // 1、渲染函數可能已經存在,通過 setup 返回
  // 2、除此之外嘗試使用 `Component.render` 當做渲染函數
  // 3、如果這個函數沒有渲染函數,設置 `instance.render` 爲空函數,以便它能從 mixins/extend 中獲得渲染函數
  if (__NODE_JS__ && isSSR) {
    instance.render = (instance.render ||
      Component.render ||
      NOOP) as InternalRenderFunction
  } else if (!instance.render) {
    // 可以在 setup() 中設置
    if (compile && !Component.render) {
      const template = Component.template
      if (template) {
        const { isCustomElement, compilerOptions } = instance.appContext.config
        const {
          delimiters,
          compilerOptions: componentCompilerOptions
        } = Component
        const finalCompilerOptions: CompilerOptions = extend(
          extend(
            {
              isCustomElement,
              delimiters
            },
            compilerOptions
          ),
          componentCompilerOptions
        )
        Component.render = compile(template, finalCompilerOptions)
      }
    }

    instance.render = (Component.render || NOOP) as InternalRenderFunction

    // 對於使用 `with` 塊的運行時編譯的渲染函數,這個渲染代理需要不一樣的 `has` handler 陷阱,它有更好的
    // 性能表現並且只允許白名單內的 globals 屬性通過。
    if (instance.render._rc) {
      instance.withProxy = new Proxy(
        instance.ctx,
        RuntimeCompiledPublicInstanceProxyHandlers
      )
    }
  }
}

總結

今天筆者介紹了一個有狀態的組件的初始化的過程,在 setup 函數初始化部分進行了仔細的講解,我們不僅學習了 setup 上下文初始化的條件,也明確的知曉了 setup 上下文究竟給我們暴露了哪些屬性,並且從中學到了一個新的 RFC 提案: expose 屬性。

我們學習了 setup 函數執行的過程以及 Vue 是如何處理捕獲 setup 的返回結果的。

最後我們講解了組件初始化時,不論是否使用 setup 都會執行的 finishComponentSetup 函數,通過這個函數內部的邏輯我們瞭解了一個組件在初始化完畢時,渲染函數設置的規則。

最後,如果這篇文章能夠幫助到你瞭解 Vue3 中 setup 的小細節,希望能給本文點一個喜歡❤️。如果想繼續追蹤後續文章,也可以關注我的賬號或 follow 我的 github,再次謝謝各位可愛的看官老爺。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章