前言
在上一篇 掉了兩根頭髮後,我悟了!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
終端。
接著我們需要給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為vue檔案的code程式碼字串。
在上一篇 掉了兩根頭髮後,我悟了!vue3的scoped原來是這樣避免樣式汙染(上) 文章中我們講過了createDescriptor
函式會生成一個descriptor
物件。而descriptor
物件的id屬性descriptor.id
,就是根據vue檔案的路徑呼叫node的createHash
加密函式生成的,也就是html標籤上的自定義屬性data-v-x
中的x
。
genTemplateCode
函式會生成編譯後的render函式,如下圖:
從上圖中可以看到在生成的render函式中,div標籤對應的是createElementBlock
方法,而在執行createElementBlock
方法時並沒有將descriptor.id
傳入進去。
將genTemplateCode
函式、genScriptCode
函式、genStyleCode
函式執行完了後,得到templateCode
、scriptCode
、stylesCode
,分別對應的是編譯後的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檔案,如下圖:
從上圖中可以看到編譯後的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元件物件是什麼樣的,如下圖:
從上圖中可以看到此時的vue元件物件中增加了很多屬性,其中我們需要關注的是__scopeId
屬性,他的值就是給html增加自定義屬性data-v-x
。
給render函式打斷點
前面我們講過了在render函式中渲染div標籤時是使用_createElementBlock("div", _hoisted_1, "hello world")
,並且傳入的引數中也並沒有data-v-x
。
所以我們需要搞清楚到底是在哪裡使用到__scopeId
的呢?我們給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
值為div
,props
值為物件{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
的函式呼叫的,如下圖:
將斷點走進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
是什麼樣的,如下圖:
從上圖可以看到vue例項instance
物件上有很多我們熟悉的屬性,比如props
、refs
等。
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賦值給變數el
和vnode.el
,所以虛擬DOM的el
屬性是指向對應的真實DOM。這裡的vnode.type
的值為div
,所以這裡就是生成一個div標籤。
然後執行hostSetElementText
函式給當前真實DOM的文字節點賦值,當前vnode.children
的值為文字hello world
。所以這裡就是給div標籤設定文字節點hello world
。
最後就是呼叫setScopeId
函式傳入el
和vnode.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的機會