首先我們看看效果:
實現這樣的功能需要學習以下幾點內容。
1.認識<img/><map><area/></map>基本結構
首先復制一個html框架,命名為“圖片區域鏈接.html”,示例代碼如下:
<!DOCTYPE HTML>
<html>
<head>
<title>圖片區域鏈接</title>
<meta charset="utf-8">
</head>
<body>
</body>
</html>
向<body></body>中添加<img><map><area/></map>基本結構,示例代碼如下:
<body>
<img/>
<map>
<area/>
</map>
</body>
指定要添加區域鏈接的圖片的路徑,如下:
<img src="img/image1.jpg"/>
<map>
<area/>
</map>
讓<img>標簽通過<map>的名字來驅使<map>為自己工作。
需要兩步,第一,給<map>起名字,name=“map”,為了兼容所有的瀏覽器,還要加上id=“map”(有的瀏覽器只認id)。
第二,讓<img>叫出<map>的名字或id,usemap="#map"。大家要注意,叫名字時要加#。這個在之前的教程中也經常出現。
示例代碼如下:
<img src="img/image1.jpg" usemap="#map"/>
<map name="map" id="map">
<area/>
</map>
下面來劃分區域。
2.為圖片劃分區域的方法
<area>是用來劃分區域的標簽,area也是“”區域“”的意思。
默認的shape(形狀)屬性有“矩形(rect)”、“圓形(circ)”、“多邊形(poly)”三個值。
分別添加三個形狀,示例代碼如下:
<img src="img/image1.jpg" usemap="#map"/>
<map name="map" id="map">
<area shape="rect"/>
<area shape="circ"/>
<area shape="poly"/>
</map>
下面我們就要為區域規定參數,也就是在圖像上的位置和范圍大小。
為<area>添加coords屬性可以指定區域的位置和范圍。
如果shape="rect" 則coords由四個參數組成。例如coords="0,0,50,50"。從左到右,兩兩一組,組成兩個平面坐標,即(0,0)和(50,50),單位是“像素”,矩形區域如下:
如果shape=“circ”,coords=“50,50,10”。(50,50)定義了圓心,10是半徑。如圖:
如果shape=“poly”,coords的參數不少于3對!注意是“對”!從左到右,兩個數就是一組坐標,三組坐標可以確定一個三角形,多組坐標可以確定多邊形。例如
這組參數畫出了下圖中殲20的邊框線(600像素*400像素,如果圖像的長寬像素數變了,參數就不正確了),如圖:
這時,大家會有一個問題:如何才能知道圖像中某個像素點的坐標呢?
3.使用Gimp軟件精準定位圖片區域
使用Gimp軟件可以解決這個問題。
Gimp是一款類似于Photoshop的數字圖像處理軟件,不同的是,Gimp是開源免費的。
下載地址:https://www.gimp.org/
雙擊安裝即可,注意選擇一下安裝目錄。
完成安裝之后打開,界面如下:
點擊“文件”找到“打開”:
選擇要打開的圖片名字:
點擊名稱后,右邊會有圖像預覽,點擊“打開”即可:
打開后如圖:
把鼠標放到圖像的任意位置,看左下角:
這里就會顯示我們鼠標所在的像素坐標數值。
這樣我們就能方便地寫“poly”的coords了。
請在空閑時找一張圖片演練一下吧!
4.為區域添加鏈接
在<area/>標簽中添加href屬性即可指定鏈接路徑,如下:
href="https://www.zhihu.com/question/284642168"
添加title屬性可以在鼠標滑過鏈接區域時提示讀者,如下:
title="殲20氣動外形分析"
今天的內容結束了,圖像區域鏈接在使用時還有一些注意事項,我們下次再詳細討論。
使用碎片時間,學習完整知識!關注大魚師兄,一起精研技藝。
HTML序章(學習目的、對象、基本概念)——零基礎自學網頁制作
HTML是什么?——零基礎自學網頁制作
第一個HTML頁面如何寫?——零基礎自學網頁制作
HTML頁面中head標簽有啥用?——零基礎自學網頁制作
初識meta標簽與SEO——零基礎自學網頁制作
HTML中的元素使用方法1——零基礎自學網頁制作
HTML中的元素使用方法2——零基礎自學網頁制作
HTML元素中的屬性1——零基礎自學網頁制作
HTML元素中的屬性2(路徑詳解)——零基礎自學網頁制作
使用HTML添加表格1(基本元素)——零基礎自學網頁制作
使用HTML添加表格2(表格頭部與腳部)——零基礎自學網頁制作
使用HTML添加表格3(間距與顏色)——零基礎自學網頁制作
使用HTML添加表格4(行顏色與表格嵌套)——零基礎自學網頁制作
16進制顏色表示與RGB色彩模型——零基礎自學網頁制作
HTML中的塊級元素與內聯元素——零基礎自學網頁制作
初識HTML中的<div>塊元素——零基礎自學網頁制作
在HTML頁面中嵌入其他頁面的方法——零基礎自學網頁制作
封閉在家學網頁制作!為頁面嵌入PDF文件——零基礎自學網頁制作
HTML表單元素初識1——零基礎自學網頁制作
HTML表單元素初識2——零基礎自學網頁制作
HTML表單3(下拉列表、多行文字輸入)——零基礎自學網頁制作
HTML表單4(form的action、method屬性)——零基礎自學網頁制作
HTML列表制作講解——零基礎自學網頁制作
為HTML頁面添加視頻、音頻的方法——零基礎自學網頁制作
音視頻格式轉換神器與html視頻元素加字幕——零基礎自學網頁制作
HTML中使用<a>標簽實現文本內鏈接——零基礎自學網頁制作
om-to-image庫可以幫你把dom節點轉換為圖片,它的核心原理很簡單,就是利用svg的foreignObject標簽能嵌入html的特性,然后通過img標簽加載svg,最后再通過canvas繪制img實現導出,好了,本文到此結束。
另一個知名的html2canvas庫其實也支持這種方式。
雖然原理很簡單,但是dom-to-image畢竟也有1000多行代碼,所以我很好奇它具體都做了哪些事情,本文就來詳細剖析一下,需要說明的是dom-to-image庫已經六七年前沒有更新了,可能有點過時,所以我們要看的是基于它修改的dom-to-image-more庫,這個庫修復了一些bug,以及增加了一些特性,接下來我們就來詳細了解一下。
我們用的最多的api應該就是toPng(node),所以以這個方法為入口:
function toPng(node, options) {
return draw(node, options).then(function (canvas) {
return canvas.toDataURL();
});
}
toPng方法會調用draw方法,然后返回一個canvas,最后通過canvas的toDataURL方法獲取到圖片的base64格式的data:URL,我們就可以直接下載為圖片。
看一下draw方法:
function draw(domNode, options) {
options = options || {};
return toSvg(domNode, options)// 轉換成svg
.then(util.makeImage)// 轉換成圖片
.then(function (image) {// 通過canvas繪制圖片
// ...
});
}
一共分為了三個步驟,一一來看。
toSvg方法如下:
function toSvg(node, options) {
const ownerWindow = domtoimage.impl.util.getWindow(node);
options = options || {};
copyOptions(options);
let restorations = [];
return Promise.resolve(node)
.then(ensureElement)// 檢查和包裝元素
.then(function (clonee) {// 深度克隆節點
return cloneNode(clonee, options, null, ownerWindow);
})
.then(embedFonts)// 嵌入字體
.then(inlineImages)// 內聯圖片
.then(makeSvgDataUri)// svg轉data:URL
.then(restoreWrappers)// 恢復包裝元素
}
node就是我們要轉換成圖片的DOM節點,首先調用了getWindow方法獲取window對象:
function getWindow(node) {
const ownerDocument = node ? node.ownerDocument : undefined;
return (
(ownerDocument ? ownerDocument.defaultView : undefined) ||
global ||
window
);
}
說實話前端寫了這么多年,但是ownerDocument和defaultView兩個屬性我完全沒用過,ownerDocument屬性會返回當前節點的頂層的 document對象,而在瀏覽器中,defaultView屬性會返回當前 document 對象所關聯的 window 對象,如果沒有,會返回 null。
所以這里優先通過我們傳入的DOM節點獲取window對象,可能是為了處理iframe嵌入之類的情況把。
接下來合并了選項后,就通過Promise實例的then方法鏈式的調用一系列的方法,一一來看。
ensureElement方法如下:
function ensureElement(node) {
// ELEMENT_NODE:1
if (node.nodeType === ELEMENT_NODE) return node;
const originalChild = node;
const originalParent = node.parentNode;
const wrappingSpan = document.createElement('span');
originalParent.replaceChild(wrappingSpan, originalChild);
wrappingSpan.append(node);
restorations.push({
parent: originalParent,
child: originalChild,
wrapper: wrappingSpan,
});
return wrappingSpan;
}
html節點的nodeType有如下類型:
值為1也就是我們普通的html標簽,其他的比如文本節點、注釋節點、document節點也是比較常用的,如果我們傳入的節點的類型為1,ensureElement方法什么也不做直接返回該節點,否則會創建一個span標簽替換掉原節點,并把原節點添加到該span標簽里,可以猜測這個主要是處理文本節點,畢竟應該沒有人會傳其他類型的節點進行轉換了。
同時它還把原節點,原節點的父節點,span標簽都收集到restorations數組里,很明顯,這是為了后面進行還原。
接下來執行了cloneNode方法:
cloneNode(clonee, options, null, ownerWindow)
// 參數:需要克隆的節點、選項、父節點的樣式、所屬window對象
function cloneNode(node, options, parentComputedStyles, ownerWindow) {
const filter = options.filter;
if (
node === sandbox ||
util.isHTMLScriptElement(node) ||
util.isHTMLStyleElement(node) ||
util.isHTMLLinkElement(node) ||
(parentComputedStyles !== null && filter && !filter(node))
) {
return Promise.resolve();
}
return Promise.resolve(node)
.then(makeNodeCopy)// 處理canvas元素
.then(function (clone) {// 克隆子節點
return cloneChildren(clone, getParentOfChildren(node));
})
.then(function (clone) {// 處理克隆的節點
return processClone(clone, node);
});
}
先做了一堆判斷,如果是script、style、link標簽,或者需要過濾掉的節點,那么會直接返回。
sandbox、parentComputedStyles后面會看到。
接下來又調用了幾個方法,沒辦法,跟著它一起入棧把。
function makeNodeCopy(original) {
if (util.isHTMLCanvasElement(original)) {
return util.makeImage(original.toDataURL());
}
return original.cloneNode(false);
}
如果元素是canvas,那么會通過makeImage方法將其轉換成img標簽:
function makeImage(uri) {
if (uri === 'data:,') {
return Promise.resolve();
}
return new Promise(function (resolve, reject) {
const image = new Image();
if (domtoimage.impl.options.useCredentials) {
image.crossOrigin = 'use-credentials';
}
image.onload = function () {
if (window && window.requestAnimationFrame) {
// 解決 Firefox 的一個bug (webcompat/web-bugs#119834)
// 需要等待一幀
window.requestAnimationFrame(function () {
resolve(image);
});
} else {
// 如果沒有window對象或者requestAnimationFrame方法,那么立即返回
resolve(image);
}
};
image.onerror = reject;
image.src = uri;
});
}
crossOrigin屬性用于定義一些元素如何處理跨域請求,主要有兩個取值:
anonymous:元素的跨域資源請求不需要憑證標志設置。
use-credentials:元素的跨域資源請求需要憑證標志設置,意味著該請求需要提供憑證。
除了use-credentials,給crossOrigin設置其他任何值都會解析成anonymous,為了解決跨域問題,我們一般都會設置成anonymous,這個就相當于告訴服務器,你不需要返回任何非匿名信息過來,例如cookie,所以肯定是安全的。不過在使用這兩個值時都需要服務端返回Access-Control-Allow-Credentials響應頭,否則肯定無法跨域使用的。
非canvas元素的其他元素,會直接調用它們的cloneNode方法進行克隆,參數傳了false,代表只克隆自身,不克隆子節點。
接下來調用了cloneChildren方法:
cloneChildren(clone, getParentOfChildren(node));
getParentOfChildren方法如下:
function getParentOfChildren(original) {
// 如果該節點是Shadow DOM的附加節點,那么返回附加的Shadow DOM的根節點
if (util.isElementHostForOpenShadowRoot(original)) {
return original.shadowRoot;
}
return original;
}
function isElementHostForOpenShadowRoot(value) {
return isElement(value) && value.shadowRoot !== null;
}
這里涉及到了shadow DOM,有必要先簡單了解一下。
shadow DOM是一種封裝技術,可以將標記結構、樣式和行為隱藏起來,比如我們熟悉的video標簽,我們看到的只是一個video標簽,但實際上它里面有很多我們看不到的元素,這個特性一般會和Web components結合使用,也就是可以創建自定義元素,就和Vue和React組件一樣。
先了解一些術語:
Shadow host:一個常規 DOM 節點,Shadow DOM 會被附加到這個節點上。
Shadow tree:Shadow DOM 內部的 DOM 樹。
Shadow boundary:Shadow DOM 結束的地方,也是常規 DOM 開始的地方。
Shadow root: Shadow tree 的根節點。
一個普通的DOM元素可以使用attachShadow方法來添加shadow DOM:
let shadow = div.attachShadow({ mode: "open" });
這樣就可以給div元素附加一個shadow DOM,然后我們可以和創建普通元素一樣創建任何元素添加到shadow下:
let para = document.createElement('p');
shadow.appendChild(para);
當mode設為open,我們就可以通過div.shadowRoot獲取到Shadow DOM,如果設置的是closed,那么外部就獲取不到。
所以前面的getParentOfChildren方法會判斷當前節點是不是一個Shadow host節點,是的話就返回它內部的Shadow root節點,否則返回自身。
回到cloneChildren方法,它接收兩個參數:克隆的節點、原節點。
function cloneChildren(clone, original) {
// 獲取子節點,如果原節點是slot節點,那么會返回slot內的節點,
const originalChildren = getRenderedChildren(original);
let done = Promise.resolve();
if (originalChildren.length !== 0) {
// 獲取原節點的計算樣式,如果原節點是shadow root節點,那么會獲取它所附加到的普通元素的樣式
const originalComputedStyles = getComputedStyle(
getRenderedParent(original)
);
// 遍歷子節點
util.asArray(originalChildren).forEach(function (originalChild) {
done = done.then(function () {
// 遞歸調用cloneNode方法
return cloneNode(
originalChild,
options,
originalComputedStyles,
ownerWindow
).then(function (clonedChild) {
// 克隆完后的子節點添加到該節點
if (clonedChild) {
clone.appendChild(clonedChild);
}
});
});
});
}
return done.then(function () {
return clone;
});
}
首先通過getRenderedChildren方法獲取子節點:
function getRenderedChildren(original) {
// 如果是slot元素,那么通過assignedNodes方法返回該插槽中的節點
if (util.isShadowSlotElement(original)) {
return original.assignedNodes();
}
// 普通元素直接通過childNodes獲取子節點
return original.childNodes;
}
// 判斷是否是html slot元素
function isShadowSlotElement(value) {
return (
isInShadowRoot(value) && value instanceof getWindow(value).HTMLSlotElement
);
}
// 判斷一個節點是否處于shadow DOM樹中
function isInShadowRoot(value) {
// 如果是普通節點,getRootNode方法會返回document對象,如果是Shadow DOM,那么會返回shadow root
return (
value !== null &&
Object.prototype.hasOwnProperty.call(value, 'getRootNode') &&
isShadowRoot(value.getRootNode())
);
}
// 判斷是否是shadow DOM的根節點
function isShadowRoot(value) {
return value instanceof getWindow(value).ShadowRoot;
}
這一連串的判斷,如果對于shadow DOM不熟悉的話大概率很難看懂,不過沒關系,跳過這部分也可以,反正就是獲取子節點。
獲取到子節點后又調用了如下方法:
const originalComputedStyles = getComputedStyle(
getRenderedParent(original)
);
function getRenderedParent(original) {
// 如果該節點是shadow root,那么返回它附加到的普通的DOM節點
if (util.isShadowRoot(original)) {
return original.host;
}
return original;
}
調用getComputedStyle獲取原節點的樣式,這個方法其實就是window.getComputedStyle方法,會返回節點的所有樣式和值。
接下來就是遍歷子節點,然后對每個子節點再次調用cloneNode方法,只不過會把原節點的樣式也傳進去。對于子元素又會遞歸處理它們的子節點,這樣就能深度克隆完整棵DOM樹。
對于每個克隆節點,又調用了processClone(clone, node)方法:
function processClone(clone, original) {
// 如果不是普通節點,或者是slot節點,那么直接返回
if (!util.isElement(clone) || util.isShadowSlotElement(original)) {
return Promise.resolve(clone);
}
return Promise.resolve()
.then(cloneStyle)// 克隆樣式
.then(clonePseudoElements)// 克隆偽元素
.then(copyUserInput)// 克隆輸入框
.then(fixSvg)// 修復svg
.then(function () {
return clone;
});
}
又是一系列的操作,穩住,我們繼續。
function cloneStyle() {
copyStyle(original, clone);
}
調用了copyStyle方法,傳入原節點和克隆節點:
function copyStyle(sourceElement, targetElement) {
const sourceComputedStyles = getComputedStyle(sourceElement);
if (sourceComputedStyles.cssText) {
// ...
} else {
// ...
}
}
window.getComputedStyle方法返回的是一個CSSStyleDeclaration對象,和我們使用div.style獲取到的對象類型是一樣的,但是div.style對象只能獲取到元素的內聯樣式,使用div.style.color = '#fff'設置的也能獲取到,因為這種方式設置的也是內聯樣式,其他樣式是獲取不到的,但是window.getComputedStyle能獲取到所有css樣式。
div.style.cssText屬性我們都用過,可以獲取和批量設置內聯樣式,如果要設置多個樣式,比單個調用div.style.xxx方便一點,但是cssText會覆蓋整個內聯樣式,比如下面的方式設置的字號是會丟失的,內聯樣式最終只有color:
div.style.fontSize = '23px'
div.style.cssText = 'color: rgb(102, 102, 102)'
但是window.getComputedStyle方法返回的對象的cssText和div.style.cssText不是同一個東西,即使有內聯樣式,window.getComputedStyle方法返回對象的cssText值也是空,并且它無法修改,所以不清楚什么情況下它才會有值。
假設有值的話,接下來的代碼我也不是很能理解:
if (sourceComputedStyles.cssText) {
targetElement.style.cssText = sourceComputedStyles.cssText;
copyFont(sourceComputedStyles, targetElement.style);
}
function copyFont(source, target) {
target.font = source.font;
target.fontFamily = source.fontFamily;
// ...
}
為什么不直接把原節點的style.cssText復制給克隆節點的style.cssText呢,另外為啥文本相關的樣式又要單獨設置一遍呢,無法理解。
我們看看另外一個分支:
else {
copyUserComputedStyleFast(
options,
sourceElement,
sourceComputedStyles,
parentComputedStyles,
targetElement
);
// ...
}
先調用了copyUserComputedStyleFast方法,這個方法內部非常復雜,就不把具體代碼放出來了,大致介紹一下它都做了什么:
1.首先會獲取原節點的所謂的默認樣式,這個步驟也比較復雜:
1.1.先獲取原節點及祖先節點的元素標簽列表,其實就是一個向上遞歸的過程,不過存在終止條件,就是當遇到塊級元素的祖先節點。比如原節點是一個span標簽,它的父節點也是一個span,再上一個父節點是一個div,那么獲取到的標簽列表就是[span, span, div]。
? 1.2.接下來會創建一個沙箱,也就是一個iframe,這個iframe的DOCTYPE和charset會設置成和當前頁面的一樣。
? 1.3.再接下來會根據前面獲取到的標簽列表,在iframe中創建對應結構的DOM節點,也就是會創建這樣一棵DOM樹:div -> span -> span。并且會給最后一個節點添加一個零寬字符的文本,并返回這個節點。
? 1.4.使用iframe的window.getComputedStyle方法獲取上一步返回節點的樣式,對于width和height會設置成auto。
? 1.5.刪除iframe里前面創建的節點。
? 16.返回1.4步獲取到的樣式對象。
2.遍歷原節點的樣式,也就是sourceComputedStyles對象,對于每一個樣式屬性,都會獲取到三個值:sourceValue、defaultValue、parentValue,分別來自原節點的樣式對象sourceComputedStyles、第一步獲取到的默認樣式對象、父節點的樣式對象parentComputedStyles,然后會做如下判斷:
if (
sourceValue !== defaultValue ||
(parentComputedStyles && sourceValue !== parentValue)
) {
// 樣式優先級,比如important
const priority = sourceComputedStyles.getPropertyPriority(name);
// 將樣式設置到克隆節點的style對象上
setStyleProperty(targetStyle, name, sourceValue, priority);
}
如果原節點的某個樣式值和默認的樣式值不一樣,并且和父節點的也不一樣,那么就需要給克隆的節點手動設置成內聯樣式,否則其實就是繼承樣式或者默認樣式,就不用管了,不得不說,還是挺巧妙的。
copyUserComputedStyleFast方法執行完后還做了如下操作:
if (parentComputedStyles === null) {
[
'inset-block',
'inset-block-start',
'inset-block-end',
].forEach((prop) => targetElement.style.removeProperty(prop));
['left', 'right', 'top', 'bottom'].forEach((prop) => {
if (targetElement.style.getPropertyValue(prop)) {
targetElement.style.setProperty(prop, '0px');
}
});
}
對于我們傳入的節點,parentComputedStyles是null,本質相當于根節點,所以直接移除它的位置信息,防止發生偏移。
克隆完樣式,接下來就是處理偽元素了:
function clonePseudoElements() {
const cloneClassName = util.uid();
[':before', ':after'].forEach(function (element) {
clonePseudoElement(element);
});
}
分別調用clonePseudoElement方法處理兩種偽元素:
function clonePseudoElement(element) {
// 獲取原節點偽元素的樣式
const style = getComputedStyle(original, element);
// 獲取偽元素的content
const content = style.getPropertyValue('content');
// 如果偽元素的內容為空就直接返回
if (content === '' || content === 'none') {
return;
}
// 獲取克隆節點的類名
const currentClass = clone.getAttribute('class') || '';
// 給克隆元素增加一個唯一的類名
clone.setAttribute('class', `${currentClass} ${cloneClassName}`);
// 創建一個style標簽
const styleElement = document.createElement('style');
// 插入偽元素的樣式
styleElement.appendChild(formatPseudoElementStyle());
// 將樣式標簽添加到克隆節點內
clone.appendChild(styleElement);
}
window.getComputedStyle方法是可以獲取元素的偽元素的樣式的,通過第二個參數指定要獲取的偽元素即可。
如果偽元素的content為空就不管了,總感覺有點不妥,畢竟我經常會用偽元素渲染一些三角形,content都是設置成空的。
如果不為空,那么會給克隆的節點新增一個唯一的類名,并且創建一個style標簽添加到克隆節點內,這個style標簽里會插入偽元素的樣式,通過formatPseudoElementStyle方法獲取偽元素的樣式字符串:
function formatPseudoElementStyle() {
const selector = `.${cloneClassName}:${element}`;
// style為原節點偽元素的樣式對象
const cssText = style.cssText
? formatCssText()
: formatCssProperties();
return document.createTextNode(`${selector}{${cssText}}`);
}
如果樣式對象的cssText有值,那么調用formatCssText方法:
function formatCssText() {
return `${style.cssText} content: ${content};`;
}
但是前面說了,這個屬性一般都是沒值的,所以會走formatCssProperties方法:
function formatCssProperties() {
const styleText = util
.asArray(style)
.map(formatProperty)
.join('; ');
return `${styleText};`;
function formatProperty(name) {
const propertyValue = style.getPropertyValue(name);
const propertyPriority = style.getPropertyPriority(name)
? ' !important'
: '';
return `${name}: ${propertyValue}${propertyPriority}`;
}
}
很簡單,遍歷樣式對象,然后拼接成css的樣式字符串。
對于輸入框的處理很簡單:
function copyUserInput() {
if (util.isHTMLTextAreaElement(original)) {
clone.innerHTML = original.value;
}
if (util.isHTMLInputElement(original)) {
clone.setAttribute('value', original.value);
}
}
如果是textarea或者input元素,直接將原節點的值設置到克隆后的元素上即可。但是我測試發現克隆輸入框也會把它的值給克隆過去,所以這一步可能沒有必要。
最后就是處理svg節點:
function fixSvg() {
if (util.isSVGElement(clone)) {
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (util.isSVGRectElement(clone)) {
['width', 'height'].forEach(function (attribute) {
const value = clone.getAttribute(attribute);
if (value) {
clone.style.setProperty(attribute, value);
}
});
}
}
}
給svg節點添加命名空間,另外對于rect節點,還把寬高的屬性設置成對應的樣式,這個是何原因,我們也不得而知。
到這里,節點的克隆部分就結束了,不得不說,還是有點復雜的,很多操作其實我們也沒有看懂為什么要這么做,開發一個庫就是這樣,要處理很多邊界和異常情況,這個只有遇到了才知道為什么。
節點克隆完后接下來會處理字體:
function embedFonts(node) {
return fontFaces.resolveAll().then(function (cssText) {
if (cssText !== '') {
const styleNode = document.createElement('style');
node.appendChild(styleNode);
styleNode.appendChild(document.createTextNode(cssText));
}
return node;
});
}
調用resolveAll方法,會返回一段css字符串,然后創建一個style標簽添加到克隆的節點內,接下來看看resolveAll方法都做了什么:
function resolveAll() {
return readAll()
// ...
}
又調用了readAll方法:
function readAll() {
return Promise.resolve(util.asArray(document.styleSheets))
.then(getCssRules)
.then(selectWebFontRules)
.then(function (rules) {
return rules.map(newWebFont);
});
}
document.styleSheets屬性可以獲取到文檔中所有的style標簽和通過link標簽引入的樣式,結果是一個類數組,數組的每一項是一個CSSStyleSheet對象。
function getCssRules(styleSheets) {
const cssRules = [];
styleSheets.forEach(function (sheet) {
if (
Object.prototype.hasOwnProperty.call(
Object.getPrototypeOf(sheet),
'cssRules'
)
) {
util.asArray(sheet.cssRules || []).forEach(
cssRules.push.bind(cssRules)
);
}
});
return cssRules;
}
通過CSSStyleSheet對象的cssRules屬性可以獲取到具體的css規則,cssRules的每一項也就是我們寫的一條css語句:
function selectWebFontRules(cssRules) {
return cssRules
.filter(function (rule) {
return rule.type === CSSRule.FONT_FACE_RULE;
})
.filter(function (rule) {
return inliner.shouldProcess(rule.style.getPropertyValue('src'));
});
}
遍歷所有的css語句,找出其中的@font-face語句,shouldProcess方法會判斷@font-face語句的src屬性是否存在url()值,找出了所有存在的字體規則后會遍歷它們調用newWebFont方法:
function newWebFont(webFontRule) {
return {
resolve: function resolve() {
const baseUrl = (webFontRule.parentStyleSheet || {}).href;
return inliner.inlineAll(webFontRule.cssText, baseUrl);
},
src: function () {
return webFontRule.style.getPropertyValue('src');
},
};
}
inlineAll方法會找出@font-face語句中定義的所有字體的url,然后通過XMLHttpRequest發起請求,將字體文件轉換成data:URL形式,然后替換css語句中的url,核心就是使用下面這個正則匹配和替換。
const URL_REGEX = /url\(['"]?([^'"]+?)['"]?\)/g;
繼續resolveAll方法:
function resolveAll() {
return readAll()
.then(function (webFonts) {
return Promise.all(
webFonts.map(function (webFont) {
return webFont.resolve();
})
);
})
.then(function (cssStrings) {
return cssStrings.join('\n');
});
}
將所有@font-face語句的遠程字體url都轉換成data:URL形式后再將它們拼接成css字符串即可完成嵌入字體的操作。
說實話,Promise鏈太長,看著容易暈。
內聯完了字體后接下來就是內聯圖片:
function inlineImages(node) {
return images.inlineAll(node).then(function () {
return node;
});
}
處理圖片的inlineAll方法如下:
function inlineAll(node) {
if (!util.isElement(node)) {
return Promise.resolve(node);
}
return inlineCSSProperty(node).then(function () {
// ...
});
}
inlineCSSProperty方法會判斷節點background和 background-image屬性是否設置了圖片,是的話也會和嵌入字體一樣將遠程圖片轉換成data:URL嵌入:
function inlineCSSProperty(node) {
const properties = ['background', 'background-image'];
const inliningTasks = properties.map(function (propertyName) {
const value = node.style.getPropertyValue(propertyName);
const priority = node.style.getPropertyPriority(propertyName);
if (!value) {
return Promise.resolve();
}
// 如果設置了背景圖片,那么也會調用inliner.inlineAll方法將遠程url的形式轉換成data:URL形式
return inliner.inlineAll(value).then(function (inlinedValue) {
// 將樣式設置成轉換后的值
node.style.setProperty(propertyName, inlinedValue, priority);
});
});
return Promise.all(inliningTasks).then(function () {
return node;
});
}
處理完節點的背景圖片后:
function inlineAll(node) {
return inlineCSSProperty(node).then(function () {
if (util.isHTMLImageElement(node)) {
return newImage(node).inline();
} else {
return Promise.all(
util.asArray(node.childNodes).map(function (child) {
return inlineAll(child);
})
);
}
});
}
會檢查節點是否是圖片節點,是的話會調用newImage方法處理,這個方法也很簡單,也是發個請求獲取圖片數據,然后將它轉換成data:URL設置回圖片的src。
如果是其他節點,那么就遞歸處理子節點。
圖片也處理完了接下來就可以將svg轉換成data:URL了:
function makeSvgDataUri(node) {
let width = options.width || util.width(node);
let height = options.height || util.height(node);
return Promise.resolve(node)
.then(function (svg) {
svg.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
return new XMLSerializer().serializeToString(svg);
})
.then(util.escapeXhtml)
.then(function (xhtml) {
const foreignObjectSizing =
(util.isDimensionMissing(width)
? ' width="100%"'
: ` width="${width}"`) +
(util.isDimensionMissing(height)
? ' height="100%"'
: ` height="${height}"`);
const svgSizing =
(util.isDimensionMissing(width) ? '' : ` width="${width}"`) +
(util.isDimensionMissing(height) ? '' : ` height="${height}"`);
return `<svg xmlns="http://www.w3.org/2000/svg"${svgSizing}>
<foreignObject${foreignObjectSizing}>${xhtml}</foreignObject>
</svg>`;
})
.then(function (svg) {
return `data:image/svg+xml;charset=utf-8,${svg}`;
});
}
其中的isDimensionMissing方法就是判斷是否是不合法的數字。
主要做了四件事。
一是給節點添加命名空間,并使用XMLSerializer對象來將DOM節點序列化成字符串。
二是轉換DOM字符串中的一些字符:
function escapeXhtml(string) {
return string.replace(/%/g, '%25').replace(/#/g, '%23').replace(/\n/g, '%0A');
}
第三步就是拼接svg字符串了,將序列化后的字符串使用foreignObject標簽包裹,同時會計算一下DOM節點的寬高設置到svg上。
最后一步是拼接成data:URL的形式。
在最開始的【檢查和包裝元素】步驟會替換掉節點類型不為1的節點,這一步就是用來恢復這個操作:
function restoreWrappers(result) {
while (restorations.length > 0) {
const restoration = restorations.pop();
restoration.parent.replaceChild(restoration.child, restoration.wrapper);
}
return result;
}
這一步結束后將節點轉換成svg的操作就結束了。
現在我們可以回到draw方法:
function draw(domNode, options) {
options = options || {};
return toSvg(domNode, options)
.then(util.makeImage)
.then(function (image) {
// ...
});
}
獲取到了svg的data:URL后會調用makeImage方法將它轉換成圖片,這個方法前面我們已經看過了,這里就不重復說了。
繼續draw方法:
function draw(domNode, options) {
options = options || {};
return toSvg(domNode, options)
.then(util.makeImage)
.then(function (image) {
const scale = typeof options.scale !== 'number' ? 1 : options.scale;
const canvas = newCanvas(domNode, scale);
const ctx = canvas.getContext('2d');
ctx.msImageSmoothingEnabled = false;// 禁用圖像平滑
ctx.imageSmoothingEnabled = false;// 禁用圖像平滑
if (image) {
ctx.scale(scale, scale);
ctx.drawImage(image, 0, 0);
}
return canvas;
});
}
先調用newCanvas方法創建一個canvas:
function newCanvas(node, scale) {
let width = options.width || util.width(node);
let height = options.height || util.height(node);
// 如果寬度高度都沒有,那么默認設置成300
if (util.isDimensionMissing(width)) {
width = util.isDimensionMissing(height) ? 300 : height * 2.0;
}
// 如果高度沒有,那么默認設置成寬度的一半
if (util.isDimensionMissing(height)) {
height = width / 2.0;
}
// 創建canvas
const canvas = document.createElement('canvas');
canvas.width = width * scale;
canvas.height = height * scale;
// 設置背景顏色
if (options.bgcolor) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = options.bgcolor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
return canvas;
}
把svg圖片繪制到canvas上后,就可以通過canvas.toDataURL()方法轉換成圖片的data:URL,你可以渲染到頁面,也可以直接進行下載。
本文通過源碼詳細介紹了dom-to-image-more的原理,核心就是克隆節點和節點樣式,內聯字體、背景圖片、圖片,然后通過svg的foreignObject標簽嵌入克隆后的節點,最后將svg轉換成圖片,圖片繪制到canvas上進行導出。
可以看到源碼中大量的Promise,很多不是異步的邏輯也會通過then方法來進行管道式調用,大部分情況會讓代碼很清晰,一眼就知道大概做了什么事情,但是部分地方串聯了太長,反倒不太容易理解。
限于篇幅,源碼中其實還要很多有意思的細節沒有介紹,比如為了修改iframe的DOCTYPE和charset,居然寫了三種方式,雖然我覺得第一種就夠了,又比如獲取節點默認樣式的方式,通過iframe創建同樣標簽同樣層級的元素,說實話我是從來沒見過,再比如解析css中的字體的url時用的是如下方法:
function resolveUrl(url, baseUrl) {
const doc = document.implementation.createHTMLDocument();
const base = doc.createElement('base');
doc.head.appendChild(base);
const a = doc.createElement('a');
doc.body.appendChild(a);
base.href = baseUrl;
a.href = url;
return a.href;
}
base標簽我也是從來沒有見過。等等。
所以看源碼還是挺有意思的一件事,畢竟平時寫業務代碼局限性太大了,很多東西都了解不到,強烈推薦各位去閱讀一下。
前面一篇文章:「高頻面試題」瀏覽器從輸入url到頁面展示中間發生了什么 中,我們有對瀏覽器的渲染流程做了一個概括性的介紹,今天這篇文章我們將深入學習這部分內容。
對于很多前端開發來說,平常做工主要專注于業務開發,對瀏覽器的渲染階段可能不是很了解。實際上這個階段很重要,了解瀏覽器的渲染過程,能讓我們知道我們寫的HTML、CSS、JS代碼是如何被解析,并最終渲染成一個頁面的,在頁面性能優化的時候有相應的解決思路。
我們先來看一個問題:
HTML、CSS、JS文件在瀏覽器中是如何轉化成頁面的?
如果你回答不上來,那就往下看吧。
按照渲染的時間順序,渲染過程可以分為下面幾個子階段:構建DOM樹、樣式計算、布局階段、分層、柵格化和合成顯示。
下面詳細看下每個階段都做了哪些事情。
HTML文檔描述一個頁面的結構,但是瀏覽器無法直接理解和使用HTML,所以需要通過HTML解析器將HTML轉換成瀏覽器能夠理解的結構——DOM樹。
HTML文檔中所有內容皆為節點,各節點之間有層級關系,彼此相連,構成DOM樹。
構建過程:讀取HTML文檔的字節(Bytes),將字節轉換成字符(Chars),依據字符確定標簽(Tokens),將標簽轉換成節點(Nodes),以節點為基準構建DOM樹。參考下圖:
打開Chrome的開發者工具,在控制臺輸入 document 后回車,就能看到一個完整的DOM樹結構,如下圖所示:
在控制臺打印出來的DOM結構和HTML內容幾乎一樣,但和HTML不同的是,DOM是保存在內存中的樹狀結構,可以通過JavaScript來查詢或修改其內容。
樣式計算這個階段,是為了計算出DOM節點中每個元素的表現樣式。
CSS樣式可以通過下面三種方式引入:
和HTML一樣,瀏覽器無法直接理解純文本的CSS樣式,需要通過CSS解析器將CSS解析成 styleSheets 結構,也就是我們常說的 CSSOM樹。
styleSheets結構同樣具備查詢和修改功能:
document.styleSheets
屬性值標準化看字面意思有點不好理解,我們通過下面一個例子來看看什么是屬性值標準化:
在寫CSS樣式的時候,我們在設置color屬性值的時候,經常會用white、red等,但是這種值瀏覽器的渲染引擎不容易理解,所以需要將所有值轉換成渲染引擎容易理解的、標準化的計算值,這個過程就是屬性值標準化。
white標準化后的值為 rgb(255, 255, 255)
完成樣式的屬性值標準化后,就需要計算每個節點的樣式屬性,這個階段CSS有兩個規則我們需要清楚:
樣式計算階段是為了計算出DOM節點中每個元素的具體樣式,在計算過程中需要遵守CSS的繼承和層疊兩個規則。
該階段最終輸出的內容是每個DOM節點的樣式,并被保存在 ComputedStyle 的結構中。
經過上面的兩個步驟,我們已經拿到了DOM樹和DOM樹中元素的樣式,接下來需要計算DOM樹中可見元素的幾何位置,這個計算過程就是布局。
在DOM樹中包含了一些不可見的元素,例如 head 標簽,設置了 display:none 屬性的元素,所以我們需要額外構建一棵只包含可見元素的布局樹。
構建過程:從DOM樹的根節點開始遍歷,將所有可見的節點加到布局樹中,忽略不可見的節點。
到這里我們就有了一棵構建好的布局樹,就可以開始計算布局樹節點的坐標位置了。從根節點開始遍歷,結合上面計算得到的樣式,確定每個節點對象在頁面上的具體大小和位置,將這些信息保存在布局樹中。
布局階段的輸出是一個盒子模型,它會精確地捕獲每個元素在屏幕內的確切位置與大小。
現在我們已經有了布局樹,也知道了每個元素的具體位置信息,但是還不能開始繪制頁面,因為頁面中會有像3D變換、頁面滾動、或者用 z-index 進行z軸排序等復雜效果,為了更方便實現這些效果,渲染引擎還需要為特定的節點生成專用的圖層,并生成一棵對應的圖層樹(LayerTree)。
在Chrome瀏覽器中,我們可以打開開發者工具,選擇 Elements-Layers 標簽,就可以看到頁面的分層情況,如下圖所示:
瀏覽器的頁面實際上被分成了很多圖層,這些圖層疊加后合成了最終的頁面。
到這里,我們構建了兩棵樹:布局樹和圖層樹。下面我們來看下這兩棵樹之間的關系:
正常情況下,并不是布局樹的每個節點都包含一個圖層,如果一個節點沒有對應的圖層,那么這個節點就從屬于父節點的圖層。
那節點要滿足什么條件才會被提升為一個單獨的圖層?只要滿足下面其中一個條件即可:
構建好圖層樹之后,渲染引擎就會對圖層樹中的每個圖層進行繪制。
渲染引擎實現圖層繪制,會把一個圖層的繪制拆分成很多小的繪制指令,然后將這些指令按照順序組成一個繪制列表。
繪制一個圖層時會生成一個繪制列表,這只是用來記錄繪制順序和繪制指令的列表,實際上繪制操作是由渲染引擎中的合成線程來完成的。
通過下圖來看下渲染主線程和合成線程之間的關系:
當圖層的繪制列表準備好后,主線程會把該繪制列表提交給合成線程,合成線程開始工作。
首先合成線程會將圖層劃分為圖塊(tile),圖塊大小通常是 256256 或者 512512。
然后合成線程會按照視口附近的圖塊來優先生成位圖,實際生成位圖的操作是由柵格化來執行的。所謂柵格化,是指將圖塊轉換為位圖。而圖塊是柵格化執行的最小單位。渲染進程維護了一個柵格化的線程池,所有的圖塊柵格化都是在線程池內執行的,運行方式如下圖所示:
一旦所有圖塊都被光柵化,合成線程就會生成一個繪制圖塊的命令——“DrawQuad”,然后將該命令提交給瀏覽器進程。
瀏覽器進程里面有一個名字叫做 viz 的組件,用來接收合成線程發過來的 DrawQuad 命令,然后根據命令執行。 DrawQuad 命令,將其頁面內容繪制到內存中,最后再將內存顯示在屏幕上。
多年開發老碼農福利贈送:網頁制作,網站開發,web前端開發,從最零基礎開始的的HTML+CSS+JavaScript。jQuery,Vue、React、Ajax,node,angular框架等到移動端小程序項目實戰【視頻+工具+電子書+系統路線圖】都有整理,需要的伙伴可以私信我,發送“前端”等3秒后就可以獲取領取地址,送給每一位對編程感興趣的小伙伴
一個完整的渲染流程可以總結如下:
渲染過程中還有兩個我們經常聽到的概念:重排和重繪。在這篇文章中就不細說了,下一篇文章再詳細介紹。
*請認真填寫需求信息,我們會在24小時內與您取得聯系。