Vue3 中的 v-bind 指令:你不知道的那些工作原理

前端欧阳發表於2024-06-24

前言

v-bind指令想必大家都不陌生,並且都知道他支援各種寫法,比如<div v-bind:title="title"><div :title="title"><div :title>(vue3.4中引入的新的寫法)。這三種寫法的作用都是一樣的,將title變數繫結到div標籤的title屬性上。本文將透過debug原始碼的方式帶你搞清楚,v-bind指令是如何實現這麼多種方式將title變數繫結到div標籤的title屬性上的。注:本文中使用的vue版本為3.4.19

關注公眾號:【前端歐陽】,給自己一個進階vue的機會

看個demo

還是老套路,我們來寫個demo。程式碼如下:

<template>
  <div v-bind:title="title">Hello Word</div>
  <div :title="title">Hello Word</div>
  <div :title>Hello Word</div>
</template>

<script setup lang="ts">
import { ref } from "vue";
const title = ref("Hello Word");
</script>

上面的程式碼很簡單,使用三種寫法將title變數繫結到div標籤的title屬性上。

我們從瀏覽器中來看看編譯後的程式碼,如下:

const _sfc_main = _defineComponent({
  __name: "index",
  setup(__props, { expose: __expose }) {
    // ...省略
  }
});

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return _openBlock(), _createElementBlock(
    _Fragment,
    null,
    [
      _createElementVNode("div", { title: $setup.title }, "Hello Word", 8, _hoisted_1),
      _createElementVNode("div", { title: $setup.title }, "Hello Word", 8, _hoisted_2),
      _createElementVNode("div", { title: $setup.title }, "Hello Word", 8, _hoisted_3)
    ],
    64
    /* STABLE_FRAGMENT */
  );
}
_sfc_main.render = _sfc_render;
export default _sfc_main;

從上面的render函式中可以看到三種寫法生成的props物件都是一樣的: { title: $setup.title }。props屬性的key為title,值為$setup.title變數。

再來看看瀏覽器渲染後的樣子,如下圖:
div

從上圖中可以看到三個div標籤上面都有title屬性,並且屬性值都是一樣的。

transformElement函式

在之前的 面試官:來說說vue3是怎麼處理內建的v-for、v-model等指令?文章中我們講過了在編譯階段會執行一堆transform轉換函式,用於處理vue內建的v-for等指令。而v-bind指令就是在這一堆transform轉換函式中的transformElement函式中處理的。

還是一樣的套路啟動一個debug終端。這裡以vscode舉例,開啟終端然後點選終端中的+號旁邊的下拉箭頭,在下拉中點選Javascript Debug Terminal就可以啟動一個debug終端。
debug-terminal

transformElement函式打個斷點,transformElement函式的程式碼位置在:node_modules/@vue/compiler-core/dist/compiler-core.cjs.js

debug終端上面執行yarn dev後在瀏覽器中開啟對應的頁面,比如:http://localhost:5173/ 。此時斷點就會走到transformElement函式中,在我們這個場景中簡化後的transformElement函式程式碼如下:

const transformElement = (node, context) => {
  return function postTransformElement() {
    let vnodeProps;
    const propsBuildResult = buildProps(
      node,
      context,
      undefined,
      isComponent,
      isDynamicComponent
    );
    vnodeProps = propsBuildResult.props;

    node.codegenNode = createVNodeCall(
      context,
      vnodeTag,
      vnodeProps,
      vnodeChildren
      // ...省略
    );
  };
};

我們先來看看第一個引數node,如下圖:
node

從上圖中可以看到此時的node節點對應的就是<div v-bind:title="title">Hello Word</div>節點,其中的props陣列中只有一項,對應的就是div標籤中的v-bind:title="title"部分。

我們接著來看transformElement函式中的程式碼,可以分為兩部分。

第一部分為呼叫buildProps函式拿到當前node節點的props屬性賦值給vnodeProps變數。

第二部分為根據當前node節點vnodeTag也就是節點的標籤比如div、vnodeProps也就是節點的props屬性物件、vnodeChildren也就是節點的children子節點、還有一些其他資訊生成codegenNode屬性。在之前的 終於搞懂了!原來 Vue 3 的 generate 是這樣生成 render 函式的文章中我們已經講過了編譯階段最終生成render函式就是讀取每個node節點的codegenNode屬性然後進行字串拼接。

buildProps函式的名字我們不難猜出他的作用就是生成node節點的props屬性物件,所以我們接下來需要將目光聚焦到buildProps函式中,看看是如何生成props物件的。

buildProps函式

將斷點走進buildProps函式,在我們這個場景中簡化後的程式碼如下:

function buildProps(node, context, props = node.props) {
  let propsExpression;
  let properties = [];

  for (let i = 0; i < props.length; i++) {
    const prop = props[i];
    const { name } = prop;
    const directiveTransform = context.directiveTransforms[name];
    if (directiveTransform) {
      const { props } = directiveTransform(prop, node, context);
      properties.push(...props);
    }
  }

  propsExpression = createObjectExpression(
    dedupeProperties(properties),
    elementLoc
  );
  return {
    props: propsExpression,
    // ...省略
  };
}

由於我們在呼叫buildProps函式時傳的第三個引數為undefined,所以這裡的props就是預設值node.props。如下圖:
props

從上圖中可以看到props陣列中只有一項,props中的name欄位為bind,說明v-bind指令還未被處理掉。

並且由於我們當前node節點是第一個div標籤:<div v-bind:title="title">,所以props中的rawName的值是v-bind:title

我們接著來看上面for迴圈遍歷props的程式碼:const directiveTransform = context.directiveTransforms[name],現在我們已經知道了這裡的name為bind。那麼這裡的context.directiveTransforms物件又是什麼東西呢?我們在debug終端來看看context.directiveTransforms,如下圖:
directiveTransforms

從上圖中可以看到context.directiveTransforms物件中包含許多指令的轉換函式,比如v-bindv-cloakv-htmlv-model等。

我們這裡name的值為bind,並且context.directiveTransforms物件中有name為bind的轉換函式。所以const directiveTransform = context.directiveTransforms[name]就是拿到處理v-bind指令的轉換函式,然後賦值給本地的directiveTransform函式。

接著就是執行directiveTransform轉換函式,拿到v-bind指令生成的props陣列。然後執行properties.push(...props)方法將所有的props陣列都收集到properties陣列中。

由於node節點中有多個props,在for迴圈遍歷props陣列時,會將經過transform轉換函式處理後拿到的props陣列全部push到properties陣列中。properties陣列中可能會有重複的prop,所以需要執行dedupeProperties(properties)函式對props屬性進行去重。

node節點上的props屬性本身也是一種node節點,所以最後就是執行createObjectExpression函式生成props屬性的node節點,程式碼如下:

propsExpression = createObjectExpression(
  dedupeProperties(properties),
  elementLoc
)

其中createObjectExpression函式的程式碼也很簡單,程式碼如下:

function createObjectExpression(properties, loc) {
  return {
    type: NodeTypes.JS_OBJECT_EXPRESSION,
    loc,
    properties,
  };
}

上面的程式碼很簡單,properties陣列就是node節點上的props陣列,根據properties陣列生成props屬性對應的node節點。

我們在debug終端來看看最終生成的props物件propsExpression是什麼樣的,如下圖:
propsExpression

從上圖中可以看到此時properties屬性陣列中已經沒有了v-bind指令了,取而代之的是keyvalue屬性。key.content的值為title,說明屬性名為titlevalue.content的值為$setup.title,說明屬性值為變數$setup.title

到這裡v-bind指令已經被完全解析了,生成的props物件中有keyvalue欄位,分別代表的是屬性名和屬性值。後續生成render函式時只需要遍歷所有的props,根據keyvalue欄位進行字串拼接就可以給div標籤生成title屬性了。

接下來我們繼續來看看處理v-bind指令的transform轉換函式具體是如何處理的。

transformBind函式

將斷點走進transformBind函式,在我們這個場景中簡化後的程式碼如下:

const transformBind = (dir, _node) => {
  const arg = dir.arg;
  let { exp } = dir;

  if (!exp) {
    const propName = camelize(arg.content);
    exp = dir.exp = createSimpleExpression(propName, false, arg.loc);
    exp = dir.exp = processExpression(exp, context);
  }

  return {
    props: [createObjectProperty(arg, exp)],
  };
};

我們先來看看transformBind函式接收的第一個引數dir,從這個名字我想你應該已經猜到了他裡面儲存的是指令相關的資訊。

在debug終端來看看三種寫法的dir引數有什麼不同。

第一種寫法:<div v-bind:title="title">dir如下圖:
dir1

從上圖中可以看到dir.name的值為bind,說明這個是v-bind指令。dir.rawName的值為v-bind:title說明沒有使用縮寫模式。dir.arg表示bind繫結的屬性名稱,這裡繫結的是title屬性。dir.exp表示bind繫結的屬性值,這裡繫結的是$setup.title變數。

第二種寫法:<div :title="title">dir如下圖:
dir2

從上圖中可以看到第二種寫法的dir和第一種寫法的dir只有一項不一樣,那就是dir.rawName。在第二種寫法中dir.rawName的值為:title,說明我們這裡是採用了縮寫模式。

可能有的小夥伴有疑問了,這裡的dir是怎麼來的?vue是怎麼區分第一種全寫模式和第二種縮寫模式呢?

答案是在parse階段將html編譯成AST抽象語法樹階段時遇到v-bind:title:title時都會將其當做v-bind指令處理,並且將解析處理的指令繫結的屬性名塞到dir.arg中,將屬性值塞到dir.exp中。

第三種寫法:<div :title>dir如下圖:
dir3

第三種寫法也是縮寫模式,並且將屬性值也一起給省略了。所以這裡的dir.exp儲存的屬性值為undefined。其他的和第二種縮寫模式基本一樣。

我們再來看transformBind中的程式碼,if (!exp)說明將值也一起省略了,是第三種寫法。就會執行如下程式碼:

if (!exp) {
  const propName = camelize(arg.content);
  exp = dir.exp = createSimpleExpression(propName, false, arg.loc);
  exp = dir.exp = processExpression(exp, context);
}

這裡的arg.content就是屬性名title,執行camelize函式將其從kebab-case命名法轉換為駝峰命名法。比如我們給div上面綁一個自定義屬性data-type,採用第三種縮寫模式就是這樣的:<div :data-type>。大家都知道變數名稱是不能帶短橫線的,所以這裡的要執行camelize函式將其轉換為駝峰命名法:改為繫結dataType變數。

從前面的那幾張dir變數的圖我們知道 dir.exp變數的值是一個物件,所以這裡需要執行createSimpleExpression函式將省略的變數值也補全。createSimpleExpression的函式程式碼如下:

function createSimpleExpression(
  content,
  isStatic,
  loc,
  constType
): SimpleExpressionNode {
  return {
    type: NodeTypes.SIMPLE_EXPRESSION,
    loc,
    content,
    isStatic,
    constType: isStatic ? ConstantTypes.CAN_STRINGIFY : constType,
  };
}

經過這一步處理後 dir.exp變數的值如下圖:
exp1

還記得前面兩種模式的 dir.exp.content的值嗎?他的值是$setup.title,表示屬性值為setup中定義的title變數。而我們這裡的dir.exp.content的值為title變數,很明顯是不對的。

所以需要執行exp = dir.exp = processExpression(exp, context)dir.exp.content中的值替換為$setup.title,執行processExpression函式後的dir.exp變數的值如下圖:
exp2

我們來看transformBind函式中的最後一塊return的程式碼:

return {
  props: [createObjectProperty(arg, exp)],
}

這裡的arg就是v-bind繫結的屬性名,exp就是v-bind繫結的屬性值。createObjectProperty函式程式碼如下:

function createObjectProperty(key, value) {
  return {
    type: NodeTypes.JS_PROPERTY,
    loc: locStub,
    key: isString(key) ? createSimpleExpression(key, true) : key,
    value,
  };
}

經過createObjectProperty函式的處理就會生成包含keyvalue屬性的物件。key中存的是繫結的屬性名,value中存的是繫結的屬性值。

其實transformBind函式中做的事情很簡單,解析出v-bind指令繫結的屬性名稱和屬性值。如果發現v-bind指令沒有繫結值,那麼就說明當前v-bind將值也給省略掉了,繫結的屬性和屬性值同名才能這樣寫。然後根據屬性名和屬性值生成一個包含keyvalue鍵的props物件。後續生成render函式時只需要遍歷所有的props,根據keyvalue欄位進行字串拼接就可以給div標籤生成title屬性了。

總結

在transform階段處理vue內建的v-for、v-model等指令時會去執行一堆transform轉換函式,其中有個transformElement轉換函式中會去執行buildProps函式。

buildProps函式會去遍歷當前node節點的所有props陣列,此時的props中還是存的是v-bind指令,每個prop中存的是v-bind指令繫結的屬性名和屬性值。

在for迴圈遍歷node節點的所有props時,每次都會執行transformBind轉換函式。如果我們在寫v-bind時將值也給省略了,此時v-bind指令繫結的屬性值就是undefined。這時就需要將省略的屬性值補回來,補回來的屬性值的變數名稱和屬性名是一樣的。

transformBind轉換函式的最後會根據屬性名和屬性值生成一個包含keyvalue鍵的props物件。key對應的就是屬性名,value對應的就是屬性值。後續生成render函式時只需要遍歷所有的props,根據keyvalue欄位進行字串拼接就可以給div標籤生成title屬性了。

關注公眾號:【前端歐陽】,給自己一個進階vue的機會

相關文章