掉了兩根頭髮後,我悟了!vue3的scoped原來是這樣避免樣式汙染(下)

前端欧阳發表於2024-07-04

前言

在上一篇 掉了兩根頭髮後,我悟了!vue3的scoped原來是這樣避免樣式汙染(上) 文章中我們講了使用scoped後,vue是如何給CSS選擇器新增對應的屬性選擇器[data-v-x]。這篇文章我們來接著講使用了scoped後,vue是如何給html增加自定義屬性data-v-x。注:本文中使用的vue版本為3.4.19@vitejs/plugin-vue的版本為5.0.4

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

看個demo

我們先來看個demo,程式碼如下:

<template>
<div class="block">hello world</div>
</template>

<style scoped>
.block {
color: red;
}
</style>

經過編譯後,上面的demo程式碼就會變成下面這樣:

<template>
<div data-v-c1c19b25 class="block">hello world</div>
</template>

<style>
.block[data-v-c1c19b25] {
color: red;
}
</style>

從上面的程式碼可以看到在div上多了一個data-v-c1c19b25自定義屬性,並且css的屬性選擇器上面也多了一個[data-v-c1c19b25]

接下來我將透過debug的方式帶你瞭解,vue使用了scoped後是如何給html增加自定義屬性data-v-x

transformMain 函式

透過debug搞清楚.vue檔案怎麼變成.js檔案文章中我們講過了transformMain 函式的作用是將vue檔案轉換成js檔案。

首先我們需要啟動一個debug終端。這裡以vscode舉例,開啟終端然後點選終端中的+號旁邊的下拉箭頭,在下拉中點選Javascript Debug Terminal就可以啟動一個debug終端。
debug-terminal

接著我們需要給transformMain 函式打個斷點,transformMain 函式的位置在node_modules/@vitejs/plugin-vue/dist/index.mjs

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

async function transformMain(code, filename, options) {
  const { descriptor } = createDescriptor(filename, code, options);

  const { code: templateCode } = await genTemplateCode(
    descriptor
    // ...省略
  );

  const { code: scriptCode } = await genScriptCode(
    descriptor
    // ...省略
  );

  const stylesCode = await genStyleCode(
    descriptor
    // ...省略
  );

  const output = [scriptCode, templateCode, stylesCode];

  const attachedProps = [];
  attachedProps.push([`__scopeId`, JSON.stringify(`data-v-${descriptor.id}`)]);

  output.push(
    `import _export_sfc from '${EXPORT_HELPER_ID}'`,
    `export default /*#__PURE__*/_export_sfc(_sfc_main, [${attachedProps
      .map(([key, val]) => `['${key}',${val}]`)
      .join(",")}])`
  );

  let resolvedCode = output.join("\n");

  return {
    code: resolvedCode,
  };
}

在debug終端來看看transformMain函式的入參code,如下圖:
code

從上圖中可以看到入參code為vue檔案的code程式碼字串。

在上一篇 掉了兩根頭髮後,我悟了!vue3的scoped原來是這樣避免樣式汙染(上) 文章中我們講過了createDescriptor函式會生成一個descriptor物件。而descriptor物件的id屬性descriptor.id,就是根據vue檔案的路徑呼叫node的createHash加密函式生成的,也就是html標籤上的自定義屬性data-v-x中的x

genTemplateCode函式會生成編譯後的render函式,如下圖:
templateCode

從上圖中可以看到在生成的render函式中,div標籤對應的是createElementBlock方法,而在執行createElementBlock方法時並沒有將descriptor.id傳入進去。

genTemplateCode函式、genScriptCode函式、genStyleCode函式執行完了後,得到templateCodescriptCodestylesCode,分別對應的是編譯後的render函式、編譯後的js程式碼、編譯後的style樣式。

然後將這三個變數const output = [scriptCode, templateCode, stylesCode];收集到output陣列中。

接著會執行attachedProps.push方法將一組鍵值對push到attachedProps陣列中,key為__scopeId,值為data-v-${descriptor.id}。看到這裡我想你應該已經猜到了,這裡的data-v-${descriptor.id}就是給html標籤上新增的自定義屬性data-v-x

接著就是遍歷attachedProps陣列將裡面存的鍵值對拼接到output陣列中,程式碼如下:

output.push(
  `import _export_sfc from '${EXPORT_HELPER_ID}'`,
  `export default /*#__PURE__*/_export_sfc(_sfc_main, [${attachedProps
    .map(([key, val]) => `['${key}',${val}]`)
    .join(",")}])`
);

最後就是執行output.join("\n"),使用換行符將output陣列中的內容拼接起來就能得到vue檔案編譯後的js檔案,如下圖:
resolvedCode

從上圖中可以看到編譯後的js檔案export default匯出的是_export_sfc函式的執行結果,該函式接收兩個引數。第一個引數為當前vue元件物件_sfc_main,第二個引數是由很多組鍵值對組成的陣列。

第一組鍵值對的key為render,值是名為_sfc_render的render函式。

第二組鍵值對的key為__scopeId,值為data-v-c1c19b2

第三組鍵值對的key為__file,值為當前vue檔案的路徑。

編譯後的js檔案

從前面我們知道編譯後的js檔案export default匯出的是_export_sfc函式的執行結果,我們在瀏覽器中給_export_sfc函式打個斷點。重新整理頁面,程式碼會走到斷點中,_export_sfc函式程式碼如下:

function export_sfc(sfc, props) {
  const target = sfc.__vccOpts || sfc;
  for (const [key, val] of props) {
    target[key] = val;
  }
  return target;
}

export_sfc函式的第一個引數為當前vue元件物件sfc,第二個引數為多組鍵值對組成的陣列props

由於我們這裡的vue元件物件上沒有__vccOpts屬性,所以target的值還是sfc

接著就是遍歷傳入的多組鍵值對,使用target[key] = val給vue元件物件上面額外新增三個屬性,分別是render__scopeId__file

在控制檯中來看看經過export_sfc函式處理後的vue元件物件是什麼樣的,如下圖:
sfc

從上圖中可以看到此時的vue元件物件中增加了很多屬性,其中我們需要關注的是__scopeId屬性,他的值就是給html增加自定義屬性data-v-x

給render函式打斷點

前面我們講過了在render函式中渲染div標籤時是使用_createElementBlock("div", _hoisted_1, "hello world"),並且傳入的引數中也並沒有data-v-x

所以我們需要搞清楚到底是在哪裡使用到__scopeId的呢?我們給render函式打一個斷點,如下圖:
render

重新整理頁面程式碼會走到render函式的斷點中,將斷點走進_createElementBlock函式中,在我們這個場景中簡化後的_createElementBlock函式程式碼如下:

function createElementBlock(
  type,
  props,
  children,
  patchFlag,
  dynamicProps,
  shapeFlag
) {
  return setupBlock(
    createBaseVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      shapeFlag,
      true
    )
  );
}

從上面的程式碼可以看到createElementBlock並不是幹活的地方,而是在裡層先呼叫createBaseVNode函式,然後使用其結果再去呼叫setupBlock函式。

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

function createBaseVNode(type, props, children) {
  const vnode = {
    type,
    props,
    scopeId: currentScopeId,
    children,
    // ...省略
  };
  return vnode;
}

此時傳入的type值為divprops值為物件{class: 'block'}children值為字串hello world

createBaseVNode函式的作用就是建立div標籤對應的vnode虛擬DOM,在虛擬DOM中有個scopeId屬性。後續將虛擬DOM轉換成真實DOM時就會讀取這個scopeId屬性給html標籤增加自定義屬性data-v-x

scopeId屬性的值是由一個全域性變數currentScopeId賦值的,接下來我們需要搞清楚全域性變數currentScopeId是如何被賦值的。

renderComponentRoot函式

從Call Stack中可以看到render函式是由一個名為renderComponentRoot的函式呼叫的,如下圖:
call-stack

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

function renderComponentRoot(instance) {
  const { props, render, renderCache, data, setupState, ctx } = instance;

  let result;
  const prev = setCurrentRenderingInstance(instance);

  result = normalizeVNode(
    render.call(
      thisProxy,
      proxyToUse!,
      renderCache,
      props,
      setupState,
      data,
      ctx
    )
  );
  setCurrentRenderingInstance(prev);
  return result;
}

從上面的程式碼可以看到renderComponentRoot函式的入參是一個vue例項instance,我們在控制檯來看看instance是什麼樣的,如下圖:
instance

從上圖可以看到vue例項instance物件上有很多我們熟悉的屬性,比如propsrefs等。

instance物件上的type屬性物件有沒有覺得看著很熟悉?

這個type屬性物件就是由vue檔案編譯成js檔案後export default匯出的vue元件物件。前面我們講過了裡面的__scopeId屬性就是根據vue檔案的路徑呼叫node的createHash加密函式生成的。

在生成vue例項的時候會將“vue檔案編譯成js檔案後export default匯出的vue元件物件”塞到vue例項物件instance的type屬性中,生成vue例項是在createComponentInstance函式中完成的,感興趣的小夥伴可以打斷點除錯一下。

我們接著來看renderComponentRoot函式,首先會從instance例項中解構出render函式。

然後就是執行setCurrentRenderingInstance將全域性維護的vue例項物件變數設定為當前的vue例項物件。

接著就是執行render函式,拿到生成的虛擬DOM賦值給result變數。

最後就是再次執行setCurrentRenderingInstance函式將全域性維護的vue例項物件變數重置為上一次的vue例項物件。

setCurrentRenderingInstance函式

接著將斷點走進setCurrentRenderingInstance函式,程式碼如下:

let currentScopeId = null;
let currentRenderingInstance = null;
function setCurrentRenderingInstance(instance) {
  const prev = currentRenderingInstance;
  currentRenderingInstance = instance;
  currentScopeId = (instance && instance.type.__scopeId) || null;
  return prev;
}

setCurrentRenderingInstance函式中會將當前的vue例項賦值給全域性變數currentRenderingInstance,並且會將instance.type.__scopeId賦值給全域性變數currentScopeId

在整個render函式執行期間全域性變數currentScopeId的值都是instance.type.__scopeId。而instance.type.__scopeId我們前面已經講過了,他的值是根據vue檔案的路徑呼叫node的createHash加密函式生成的,也是給html標籤增加自定義屬性data-v-x

componentUpdateFn函式

前面講過了在renderComponentRoot函式中會執行render函式,render函式會返回對應的虛擬DOM,然後將虛擬DOM賦值給變數result,最後renderComponentRoot函式會將變數result進行return返回。

將斷點走出renderComponentRoot函式,此時斷點走到了執行renderComponentRoot函式的地方,也就是componentUpdateFn函式。在我們這個場景中簡化後的componentUpdateFn函式程式碼如下:

const componentUpdateFn = () => {
  const subTree = (instance.subTree = renderComponentRoot(instance));

  patch(null, subTree, container, anchor, instance, parentSuspense, namespace);
};

從上面的程式碼可以看到會將renderComponentRoot函式的返回結果(也就是元件的render函式生成的虛擬DOM)賦值給subTree變數,然後去執行大名鼎鼎的patch函式。

這個patch函式相比你多多少少聽過,他接收的前兩個引數分別是:舊的虛擬DOM、新的虛擬DOM。由於我們這裡是初次載入沒有舊的虛擬DOM,所以呼叫patch函式傳入的第一個引數是null。第二個引數是render函式生成的新的虛擬DOM。

patch函式

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

const patch = (
  n1,
  n2,
  container,
  anchor = null,
  parentComponent = null,
  parentSuspense = null,
  namespace = undefined,
  slotScopeIds = null,
  optimized = !!n2.dynamicChildren
) => {
  processElement(
    n1,
    n2,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    namespace,
    slotScopeIds,
    optimized
  );
};

從上面的程式碼可以看到在patch函式中主要是執行了processElement函式,引數也是透傳給了processElement函式。

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

const processElement = (
  n1,
  n2,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  namespace,
  slotScopeIds,
  optimized
) => {
  if (n1 == null) {
    mountElement(
      n2,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      namespace,
      slotScopeIds,
      optimized
    );
  }
};

從上面的程式碼可以看到如果n1 == null也就是當前沒有舊的虛擬DOM,就會去執行mountElement函式將新的虛擬DOM掛載到真實DOM上。很明顯我們這裡n1的值確實是null,所以程式碼會走到mountElement函式中。

mountElement函式

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

const mountElement = (
  vnode,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  namespace,
  slotScopeIds,
  optimized
) => {
  let el;
  el = vnode.el = hostCreateElement(vnode.type);
  hostSetElementText(el, vnode.children);
  setScopeId(el, vnode, vnode.scopeId, slotScopeIds, parentComponent);
};

從上面的程式碼可以看到在mountElement函式中首先會執行hostCreateElement函式生成真實DOM,並且將真實DOM賦值給變數elvnode.el,所以虛擬DOM的el屬性是指向對應的真實DOM。這裡的vnode.type的值為div,所以這裡就是生成一個div標籤。

然後執行hostSetElementText函式給當前真實DOM的文字節點賦值,當前vnode.children的值為文字hello world。所以這裡就是給div標籤設定文字節點hello world

最後就是呼叫setScopeId函式傳入elvnode.scopeId,給div標籤增加自定義屬性data-v-x

接下來我們來看看上面這三個函式。

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

function hostCreateElement(tag) {
  const el = document.createElement(tag, undefined);
  return el;
}

由於傳入的tag變數的值是div,所以此時hostCreateElement函式就是呼叫了document.createElement方法生成一個div標籤,並且將其return返回。

經過hostCreateElement函式的處理後,已經生成了一個div標籤,並且將其賦值給變數el。接著將斷點走進hostSetElementText函式,程式碼如下:

function hostSetElementText(el, text) {
  el.textContent = text;
}

hostSetElementText函式接收的第一個引數為el,也就是生成的div標籤。第二個引數為text,也就是要向div標籤填充的文字節點,在我們這裡是字串hello world

這裡的textContent屬性你可能用的比較少,他的作用和innerText差不多。給textContent屬性賦值就是設定元素的文字內容,在這裡就是將div標籤的文字設定為hello world

經過hostSetElementText函式的處理後生成的div標籤已經有了文字節點hello world。接著將斷點走進setScopeId函式,在我們這個場景中簡化後的程式碼如下:

const setScopeId = (el, vnode, scopeId) => {
  if (scopeId) {
    hostSetScopeId(el, scopeId);
  }
};

function hostSetScopeId(el, id) {
  el.setAttribute(id, "");
}

setScopeId函式中如果傳入了scopeId,就會執行hostSetScopeId函式。而這個scopeId就是我們前面講過的data-v-x

hostSetScopeId函式中會呼叫DOM的setAttribute方法,給div標籤增加data-v-x屬性,由於呼叫setAttribute方法的時候傳入的第二個引數為空字串,所以div上面的data-v-x屬性是沒有屬性值的。所以最終生成的div標籤就是這樣的:<div data-v-c1c19b25 class="block">hello world</div>

總結

這篇文章講了當使用了scoped後,vue是如何給html增加自定義屬性data-v-x

首先在編譯時會根據當前vue檔案的路徑進行加密演算法生成一個id,這個id就是自定義屬性data-v-x中的x

然後給編譯後的vue元件物件增加一個屬性__scopeId,屬性值就是data-v-x

在執行時的renderComponentRoot函式中,這個函式接收的引數是vue例項instance物件,instance.type的值就是編譯後的vue元件物件。

renderComponentRoot函式中會執行setCurrentRenderingInstance函式,將全域性變數currentScopeId的值賦值為instance.type.__scopeId,也就是data-v-x

renderComponentRoot函式中接著會執行render函式,在生成虛擬DOM的過程中會去讀取全域性變數currentScopeId,並且將其賦值給虛擬DOM的scopeId屬性。

接著就是拿到render函式生成的虛擬DOM去執行patch函式生成真實DOM,在我們這個場景中最終生成真實DOM的是mountElement函式。

mountElement函式中首先會呼叫document.createElement函式去生成一個div標籤,然後使用textContent屬性將div標籤的文字節點設定為hello world

最後就是呼叫setAttribute方法給div標籤設定自定義屬性data-v-x

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

相關文章