整合營銷服務商

          電腦端+手機端+微信端=數(shù)據(jù)同步管理

          免費咨詢熱線:

          dom-to-image庫是如何將html轉換成圖片的

          om-to-image庫可以幫你把dom節(jié)點轉換為圖片,它的核心原理很簡單,就是利用svg的foreignObject標簽能嵌入html的特性,然后通過img標簽加載svg,最后再通過canvas繪制img實現(xiàn)導出,好了,本文到此結束。

          另一個知名的html2canvas庫其實也支持這種方式。

          雖然原理很簡單,但是dom-to-image畢竟也有1000多行代碼,所以我很好奇它具體都做了哪些事情,本文就來詳細剖析一下,需要說明的是dom-to-image庫已經(jīng)六七年前沒有更新了,可能有點過時,所以我們要看的是基于它修改的dom-to-image-more庫,這個庫修復了一些bug,以及增加了一些特性,接下來我們就來詳細了解一下。

          將節(jié)點轉換成圖片

          我們用的最多的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繪制圖片
                      // ...
                });
          }
          

          一共分為了三個步驟,一一來看。

          將節(jié)點轉換成svg

          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) {// 深度克隆節(jié)點
                      return cloneNode(clonee, options, null, ownerWindow);
                  })
                  .then(embedFonts)// 嵌入字體
                  .then(inlineImages)// 內聯(lián)圖片
                  .then(makeSvgDataUri)// svg轉data:URL
                  .then(restoreWrappers)// 恢復包裝元素
          }
          

          node就是我們要轉換成圖片的DOM節(jié)點,首先調用了getWindow方法獲取window對象:

          function getWindow(node) {
              const ownerDocument = node ? node.ownerDocument : undefined;
              return (
                  (ownerDocument ? ownerDocument.defaultView : undefined) ||
                  global ||
                  window
              );
          }
          

          說實話前端寫了這么多年,但是ownerDocument和defaultView兩個屬性我完全沒用過,ownerDocument屬性會返回當前節(jié)點的頂層的 document對象,而在瀏覽器中,defaultView屬性會返回當前 document 對象所關聯(lián)的 window 對象,如果沒有,會返回 null。

          所以這里優(yōu)先通過我們傳入的DOM節(jié)點獲取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節(jié)點的nodeType有如下類型:

          值為1也就是我們普通的html標簽,其他的比如文本節(jié)點、注釋節(jié)點、document節(jié)點也是比較常用的,如果我們傳入的節(jié)點的類型為1,ensureElement方法什么也不做直接返回該節(jié)點,否則會創(chuàng)建一個span標簽替換掉原節(jié)點,并把原節(jié)點添加到該span標簽里,可以猜測這個主要是處理文本節(jié)點,畢竟應該沒有人會傳其他類型的節(jié)點進行轉換了。

          同時它還把原節(jié)點,原節(jié)點的父節(jié)點,span標簽都收集到restorations數(shù)組里,很明顯,這是為了后面進行還原。

          克隆節(jié)點

          接下來執(zhí)行了cloneNode方法:

          cloneNode(clonee, options, null, ownerWindow)
          
          // 參數(shù):需要克隆的節(jié)點、選項、父節(jié)點的樣式、所屬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) {// 克隆子節(jié)點
                      return cloneChildren(clone, getParentOfChildren(node));
                  })
                  .then(function (clone) {// 處理克隆的節(jié)點
                      return processClone(clone, node);
                  });
          }
          

          先做了一堆判斷,如果是script、style、link標簽,或者需要過濾掉的節(jié)點,那么會直接返回。

          sandbox、parentComputedStyles后面會看到。

          接下來又調用了幾個方法,沒辦法,跟著它一起入棧把。

          處理canvas元素的克隆

          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方法進行克隆,參數(shù)傳了false,代表只克隆自身,不克隆子節(jié)點。

          克隆子節(jié)點

          接下來調用了cloneChildren方法:

          cloneChildren(clone, getParentOfChildren(node));
          

          getParentOfChildren方法如下:

          function getParentOfChildren(original) {
              // 如果該節(jié)點是Shadow DOM的附加節(jié)點,那么返回附加的Shadow DOM的根節(jié)點
              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結合使用,也就是可以創(chuàng)建自定義元素,就和Vue和React組件一樣。

          先了解一些術語:

          Shadow host:一個常規(guī) DOM 節(jié)點,Shadow DOM 會被附加到這個節(jié)點上。

          Shadow tree:Shadow DOM 內部的 DOM 樹。

          Shadow boundary:Shadow DOM 結束的地方,也是常規(guī) DOM 開始的地方。

          Shadow root: Shadow tree 的根節(jié)點。

          一個普通的DOM元素可以使用attachShadow方法來添加shadow DOM:

          let shadow = div.attachShadow({ mode: "open" });
          

          這樣就可以給div元素附加一個shadow DOM,然后我們可以和創(chuàng)建普通元素一樣創(chuàng)建任何元素添加到shadow下:

          let para = document.createElement('p');
          shadow.appendChild(para);
          

          當mode設為open,我們就可以通過div.shadowRoot獲取到Shadow DOM,如果設置的是closed,那么外部就獲取不到。

          所以前面的getParentOfChildren方法會判斷當前節(jié)點是不是一個Shadow host節(jié)點,是的話就返回它內部的Shadow root節(jié)點,否則返回自身。

          回到cloneChildren方法,它接收兩個參數(shù):克隆的節(jié)點、原節(jié)點。

          function cloneChildren(clone, original) {
              // 獲取子節(jié)點,如果原節(jié)點是slot節(jié)點,那么會返回slot內的節(jié)點,
              const originalChildren = getRenderedChildren(original);
              let done = Promise.resolve();
              if (originalChildren.length !== 0) {
                  // 獲取原節(jié)點的計算樣式,如果原節(jié)點是shadow root節(jié)點,那么會獲取它所附加到的普通元素的樣式
                  const originalComputedStyles = getComputedStyle(
                      getRenderedParent(original)
                  );
                  // 遍歷子節(jié)點
                  util.asArray(originalChildren).forEach(function (originalChild) {
                      done = done.then(function () {
                          // 遞歸調用cloneNode方法
                          return cloneNode(
                              originalChild,
                              options,
                              originalComputedStyles,
                              ownerWindow
                          ).then(function (clonedChild) {
                              // 克隆完后的子節(jié)點添加到該節(jié)點
                              if (clonedChild) {
                                  clone.appendChild(clonedChild);
                              }
                          });
                      });
                  });
              }
              return done.then(function () {
                  return clone;
              });
          }
          

          首先通過getRenderedChildren方法獲取子節(jié)點:

          function getRenderedChildren(original) {
              // 如果是slot元素,那么通過assignedNodes方法返回該插槽中的節(jié)點
              if (util.isShadowSlotElement(original)) {
                  return original.assignedNodes();
              }
              // 普通元素直接通過childNodes獲取子節(jié)點
              return original.childNodes;
          }
          // 判斷是否是html slot元素
          function isShadowSlotElement(value) {
              return (
                  isInShadowRoot(value) && value instanceof getWindow(value).HTMLSlotElement
              );
          }
          // 判斷一個節(jié)點是否處于shadow DOM樹中
          function isInShadowRoot(value) {
              // 如果是普通節(jié)點,getRootNode方法會返回document對象,如果是Shadow DOM,那么會返回shadow root
              return (
                  value !== null &&
                  Object.prototype.hasOwnProperty.call(value, 'getRootNode') &&
                  isShadowRoot(value.getRootNode())
              );
          }
          // 判斷是否是shadow DOM的根節(jié)點
          function isShadowRoot(value) {
              return value instanceof getWindow(value).ShadowRoot;
          }
          

          這一連串的判斷,如果對于shadow DOM不熟悉的話大概率很難看懂,不過沒關系,跳過這部分也可以,反正就是獲取子節(jié)點。

          獲取到子節(jié)點后又調用了如下方法:

          const originalComputedStyles = getComputedStyle(
              getRenderedParent(original)
          );
          function getRenderedParent(original) {
              // 如果該節(jié)點是shadow root,那么返回它附加到的普通的DOM節(jié)點
              if (util.isShadowRoot(original)) {
                  return original.host;
              }
              return original;
          }
          

          調用getComputedStyle獲取原節(jié)點的樣式,這個方法其實就是window.getComputedStyle方法,會返回節(jié)點的所有樣式和值。

          接下來就是遍歷子節(jié)點,然后對每個子節(jié)點再次調用cloneNode方法,只不過會把原節(jié)點的樣式也傳進去。對于子元素又會遞歸處理它們的子節(jié)點,這樣就能深度克隆完整棵DOM樹。

          處理克隆的節(jié)點

          對于每個克隆節(jié)點,又調用了processClone(clone, node)方法:

          function processClone(clone, original) {
              // 如果不是普通節(jié)點,或者是slot節(jié)點,那么直接返回
              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;
                  });
          }
          

          又是一系列的操作,穩(wěn)住,我們繼續(xù)。

          克隆樣式

          function cloneStyle() {
              copyStyle(original, clone);
          }
          

          調用了copyStyle方法,傳入原節(jié)點和克隆節(jié)點:

          function copyStyle(sourceElement, targetElement) {
              const sourceComputedStyles = getComputedStyle(sourceElement);
              if (sourceComputedStyles.cssText) {
                 // ...
              } else {
                 // ...
              }
          }
          

          window.getComputedStyle方法返回的是一個CSSStyleDeclaration對象,和我們使用div.style獲取到的對象類型是一樣的,但是div.style對象只能獲取到元素的內聯(lián)樣式,使用div.style.color = '#fff'設置的也能獲取到,因為這種方式設置的也是內聯(lián)樣式,其他樣式是獲取不到的,但是window.getComputedStyle能獲取到所有css樣式。

          div.style.cssText屬性我們都用過,可以獲取和批量設置內聯(lián)樣式,如果要設置多個樣式,比單個調用div.style.xxx方便一點,但是cssText會覆蓋整個內聯(lián)樣式,比如下面的方式設置的字號是會丟失的,內聯(lián)樣式最終只有color:

          div.style.fontSize = '23px'
          div.style.cssText = 'color: rgb(102, 102, 102)'
          

          但是window.getComputedStyle方法返回的對象的cssText和div.style.cssText不是同一個東西,即使有內聯(lián)樣式,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;
              // ...
          }
          

          為什么不直接把原節(jié)點的style.cssText復制給克隆節(jié)點的style.cssText呢,另外為啥文本相關的樣式又要單獨設置一遍呢,無法理解。

          我們看看另外一個分支:

          else {
              copyUserComputedStyleFast(
                  options,
                  sourceElement,
                  sourceComputedStyles,
                  parentComputedStyles,
                  targetElement
              );
              // ...
          }
          

          先調用了copyUserComputedStyleFast方法,這個方法內部非常復雜,就不把具體代碼放出來了,大致介紹一下它都做了什么:

          1.首先會獲取原節(jié)點的所謂的默認樣式,這個步驟也比較復雜:

          1.1.先獲取原節(jié)點及祖先節(jié)點的元素標簽列表,其實就是一個向上遞歸的過程,不過存在終止條件,就是當遇到塊級元素的祖先節(jié)點。比如原節(jié)點是一個span標簽,它的父節(jié)點也是一個span,再上一個父節(jié)點是一個div,那么獲取到的標簽列表就是[span, span, div]。

          ? 1.2.接下來會創(chuàng)建一個沙箱,也就是一個iframe,這個iframe的DOCTYPE和charset會設置成和當前頁面的一樣。

          ? 1.3.再接下來會根據(jù)前面獲取到的標簽列表,在iframe中創(chuàng)建對應結構的DOM節(jié)點,也就是會創(chuàng)建這樣一棵DOM樹:div -> span -> span。并且會給最后一個節(jié)點添加一個零寬字符的文本,并返回這個節(jié)點。

          ? 1.4.使用iframe的window.getComputedStyle方法獲取上一步返回節(jié)點的樣式,對于width和height會設置成auto。

          ? 1.5.刪除iframe里前面創(chuàng)建的節(jié)點。

          ? 16.返回1.4步獲取到的樣式對象。

          2.遍歷原節(jié)點的樣式,也就是sourceComputedStyles對象,對于每一個樣式屬性,都會獲取到三個值:sourceValue、defaultValue、parentValue,分別來自原節(jié)點的樣式對象sourceComputedStyles、第一步獲取到的默認樣式對象、父節(jié)點的樣式對象parentComputedStyles,然后會做如下判斷:

          if (
              sourceValue !== defaultValue ||
              (parentComputedStyles && sourceValue !== parentValue)
          ) {
              // 樣式優(yōu)先級,比如important
              const priority = sourceComputedStyles.getPropertyPriority(name);
              // 將樣式設置到克隆節(jié)點的style對象上
              setStyleProperty(targetStyle, name, sourceValue, priority);
          }
          

          如果原節(jié)點的某個樣式值和默認的樣式值不一樣,并且和父節(jié)點的也不一樣,那么就需要給克隆的節(jié)點手動設置成內聯(lián)樣式,否則其實就是繼承樣式或者默認樣式,就不用管了,不得不說,還是挺巧妙的。

          copyUserComputedStyleFast方法執(zhí)行完后還做了如下操作:

          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');
                  }
              });
          }
          

          對于我們傳入的節(jié)點,parentComputedStyles是null,本質相當于根節(jié)點,所以直接移除它的位置信息,防止發(fā)生偏移。

          克隆偽元素

          克隆完樣式,接下來就是處理偽元素了:

          function clonePseudoElements() {
              const cloneClassName = util.uid();
              [':before', ':after'].forEach(function (element) {
                  clonePseudoElement(element);
              });
          }
          

          分別調用clonePseudoElement方法處理兩種偽元素:

          function clonePseudoElement(element) {
              // 獲取原節(jié)點偽元素的樣式
              const style = getComputedStyle(original, element);
              // 獲取偽元素的content
              const content = style.getPropertyValue('content');
              // 如果偽元素的內容為空就直接返回
              if (content === '' || content === 'none') {
                  return;
              }
              // 獲取克隆節(jié)點的類名
              const currentClass = clone.getAttribute('class') || '';
              // 給克隆元素增加一個唯一的類名
              clone.setAttribute('class', `${currentClass} ${cloneClassName}`);
           // 創(chuàng)建一個style標簽
              const styleElement = document.createElement('style');
              // 插入偽元素的樣式
              styleElement.appendChild(formatPseudoElementStyle());
              // 將樣式標簽添加到克隆節(jié)點內
              clone.appendChild(styleElement);
          }
          

          window.getComputedStyle方法是可以獲取元素的偽元素的樣式的,通過第二個參數(shù)指定要獲取的偽元素即可。

          如果偽元素的content為空就不管了,總感覺有點不妥,畢竟我經(jīng)常會用偽元素渲染一些三角形,content都是設置成空的。

          如果不為空,那么會給克隆的節(jié)點新增一個唯一的類名,并且創(chuàng)建一個style標簽添加到克隆節(jié)點內,這個style標簽里會插入偽元素的樣式,通過formatPseudoElementStyle方法獲取偽元素的樣式字符串:

          function formatPseudoElementStyle() {
              const selector = `.${cloneClassName}:${element}`;
              // style為原節(jié)點偽元素的樣式對象
              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元素,直接將原節(jié)點的值設置到克隆后的元素上即可。但是我測試發(fā)現(xiàn)克隆輸入框也會把它的值給克隆過去,所以這一步可能沒有必要。

          修復svg

          最后就是處理svg節(jié)點:

          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節(jié)點添加命名空間,另外對于rect節(jié)點,還把寬高的屬性設置成對應的樣式,這個是何原因,我們也不得而知。

          到這里,節(jié)點的克隆部分就結束了,不得不說,還是有點復雜的,很多操作其實我們也沒有看懂為什么要這么做,開發(fā)一個庫就是這樣,要處理很多邊界和異常情況,這個只有遇到了才知道為什么。

          嵌入字體

          節(jié)點克隆完后接下來會處理字體:

          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字符串,然后創(chuàng)建一個style標簽添加到克隆的節(jié)點內,接下來看看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標簽引入的樣式,結果是一個類數(shù)組,數(shù)組的每一項是一個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規(guī)則,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()值,找出了所有存在的字體規(guī)則后會遍歷它們調用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發(fā)起請求,將字體文件轉換成data:URL形式,然后替換css語句中的url,核心就是使用下面這個正則匹配和替換。

          const URL_REGEX = /url\(['"]?([^'"]+?)['"]?\)/g;
          

          繼續(xù)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鏈太長,看著容易暈。

          內聯(lián)圖片

          內聯(lián)完了字體后接下來就是內聯(lián)圖片:

          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方法會判斷節(jié)點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;
              });
          }
          

          處理完節(jié)點的背景圖片后:

          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);
                          })
                      );
                  }
              });
          }
          

          會檢查節(jié)點是否是圖片節(jié)點,是的話會調用newImage方法處理,這個方法也很簡單,也是發(fā)個請求獲取圖片數(shù)據(jù),然后將它轉換成data:URL設置回圖片的src。

          如果是其他節(jié)點,那么就遞歸處理子節(jié)點。

          將svg轉換成data:URL

          圖片也處理完了接下來就可以將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方法就是判斷是否是不合法的數(shù)字。

          主要做了四件事。

          一是給節(jié)點添加命名空間,并使用XMLSerializer對象來將DOM節(jié)點序列化成字符串。

          二是轉換DOM字符串中的一些字符:

          function escapeXhtml(string) {
              return string.replace(/%/g, '%25').replace(/#/g, '%23').replace(/\n/g, '%0A');
          }
          

          第三步就是拼接svg字符串了,將序列化后的字符串使用foreignObject標簽包裹,同時會計算一下DOM節(jié)點的寬高設置到svg上。

          最后一步是拼接成data:URL的形式。

          恢復包裝元素

          在最開始的【檢查和包裝元素】步驟會替換掉節(jié)點類型不為1的節(jié)點,這一步就是用來恢復這個操作:

          function restoreWrappers(result) {
              while (restorations.length > 0) {
                  const restoration = restorations.pop();
                  restoration.parent.replaceChild(restoration.child, restoration.wrapper);
              }
              return result;
          }
          

          這一步結束后將節(jié)點轉換成svg的操作就結束了。

          將svg轉換成圖片

          現(xiàn)在我們可以回到draw方法:

          function draw(domNode, options) {
                  options = options || {};
                  return toSvg(domNode, options)
                      .then(util.makeImage)
                      .then(function (image) {
                          // ...
                      });
          }
          

          獲取到了svg的data:URL后會調用makeImage方法將它轉換成圖片,這個方法前面我們已經(jīng)看過了,這里就不重復說了。

          將圖片通過canvas導出

          繼續(xù)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方法創(chuàng)建一個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;
              }
           // 創(chuàng)建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的原理,核心就是克隆節(jié)點和節(jié)點樣式,內聯(lián)字體、背景圖片、圖片,然后通過svg的foreignObject標簽嵌入克隆后的節(jié)點,最后將svg轉換成圖片,圖片繪制到canvas上進行導出。

          可以看到源碼中大量的Promise,很多不是異步的邏輯也會通過then方法來進行管道式調用,大部分情況會讓代碼很清晰,一眼就知道大概做了什么事情,但是部分地方串聯(lián)了太長,反倒不太容易理解。

          限于篇幅,源碼中其實還要很多有意思的細節(jié)沒有介紹,比如為了修改iframe的DOCTYPE和charset,居然寫了三種方式,雖然我覺得第一種就夠了,又比如獲取節(jié)點默認樣式的方式,通過iframe創(chuàng)建同樣標簽同樣層級的元素,說實話我是從來沒見過,再比如解析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標簽我也是從來沒有見過。等等。

          所以看源碼還是挺有意思的一件事,畢竟平時寫業(yè)務代碼局限性太大了,很多東西都了解不到,強烈推薦各位去閱讀一下。

          接:https://juejin.im/book/5b936540f265da0a9624b04b

          《高性能網(wǎng)站建設指南》的作者 Steve Souders 曾在一篇博客中提到:

          我的大部分性能優(yōu)化工作都集中在 JavaScript 和 CSS 上,從早期的 Move Scripts to the Bottom 和 Put Stylesheets at the Top 規(guī)則。為了強調這些規(guī)則的重要性,我甚至說過,“JS 和 CSS 是頁面上最重要的部分”。

          幾個月后,我意識到這是錯誤的。圖片才是頁面上最重要的部分。

          我關注 JS 和 CSS 的重點也是如何能夠更快地下載圖片。圖片是用戶可以直觀看到的。他們并不會關注 JS 和 CSS。確實,JS 和 CSS 會影響圖片內容的展示,尤其是會影響圖片的展示方式(比如圖片輪播,CSS 背景圖和媒體查詢)。但是我認為 JS 和 CSS 只是展示圖片的方式。在頁面加載的過程中,應當先讓圖片和文字先展示,而不是試圖保證 JS 和 CSS 更快下載完成。

          這段話可謂字字珠璣。此外,雅虎軍規(guī)和 Google 官方的最佳實踐也都將圖片優(yōu)化列為前端性能優(yōu)化必不可少的環(huán)節(jié)——圖片優(yōu)化的優(yōu)先級可見一斑。

          就圖片這塊來說,與其說我們是在做“優(yōu)化”,不如說我們是在做“權衡”。因為我們要做的事情,就是去壓縮圖片的體積(或者一開始就選取體積較小的圖片格式)。但這個優(yōu)化操作,是以犧牲一部分成像質量為代價的。因此我們的主要任務,是盡可能地去尋求一個質量與性能之間的平衡點。

          2019 年,圖片依然很大

          這里先給大家介紹 HTTP-Archive 這個網(wǎng)站,它會定期抓取 Web 上的站點,并記錄資源的加載情況、Web API 的使用情況等頁面的詳細信息,并會對這些數(shù)據(jù)進行處理和分析以確定趨勢。通過它我們可以實時地看到世界范圍內的 Web 資源的統(tǒng)計結果。

          截止到 2018 年 8 月,過去一年總的 web 資源的平均請求體積是這樣的:

          而具體到圖片這一類的資源,平均請求體積是這樣的:

          當然,隨著我們工程師在性能方面所做的努力越來越有成效,平均來說,不管是資源總量還是圖片體積,都在往越來越輕量的方向演化。這是一種值得肯定的進步。

          但同時我們不得不承認,如圖所示的這個圖片體積,依然是太大了。圖片在所有資源中所占的比重,也足夠“觸目驚心”了。為了改變這個現(xiàn)狀,我們必須把圖片優(yōu)化提上日程。

          不同業(yè)務場景下的圖片方案選型

          時下應用較為廣泛的 Web 圖片格式有 JPEG/JPG、PNG、WebP、Base64、SVG 等,這些格式都是很有故事的,值得我們好好研究一把。此外,老生常談的雪碧圖(CSS Sprites)至今也仍在一線的前端應用中發(fā)光發(fā)熱,我們也會有所提及。

          不談業(yè)務場景的選型都是耍流氓。下面我們就結合具體的業(yè)務場景,一起來解開圖片選型的神秘面紗!

          前置知識:二進制位數(shù)與色彩的關系

          在計算機中,像素用二進制數(shù)來表示。不同的圖片格式中像素與二進制位數(shù)之間的對應關系是不同的。一個像素對應的二進制位數(shù)越多,它可以表示的顏色種類就越多,成像效果也就越細膩,文件體積相應也會越大。

          一個二進制位表示兩種顏色(0|1 對應黑|白),如果一種圖片格式對應的二進制位數(shù)有 n 個,那么它就可以呈現(xiàn) 2^n 種顏色。

          JPEG/JPG

          關鍵字:有損壓縮、體積小、加載快、不支持透明

          JPG 的優(yōu)點

          JPG 最大的特點是有損壓縮。這種高效的壓縮算法使它成為了一種非常輕巧的圖片格式。另一方面,即使被稱為“有損”壓縮,JPG的壓縮方式仍然是一種高質量的壓縮方式:當我們把圖片體積壓縮至原有體積的 50% 以下時,JPG 仍然可以保持住 60% 的品質。此外,JPG 格式以 24 位存儲單個圖,可以呈現(xiàn)多達 1600 萬種顏色,足以應對大多數(shù)場景下對色彩的要求,這一點決定了它壓縮前后的質量損耗并不容易被我們人類的肉眼所察覺——前提是你用對了業(yè)務場景。

          使用場景

          JPG 適用于呈現(xiàn)色彩豐富的圖片,在我們日常開發(fā)中,JPG 圖片經(jīng)常作為大的背景圖、輪播圖或 Banner 圖出現(xiàn)。

          兩大電商網(wǎng)站對大圖的處理,是 JPG 圖片應用場景的最佳寫照:

          打開淘寶首頁,我們可以發(fā)現(xiàn)頁面中最醒目、最龐大的圖片,一定是以 .jpg 為后綴的:

          京東首頁也不例外:

          使用 JPG 呈現(xiàn)大圖,既可以保住圖片的質量,又不會帶來令人頭疼的圖片體積,是當下比較推崇的一種方案。

          JPG 的缺陷

          有損壓縮在上文所展示的輪播圖上確實很難露出馬腳,但當它處理矢量圖形Logo 等線條感較強、顏色對比強烈的圖像時,人為壓縮導致的圖片模糊會相當明顯。

          此外,JPEG 圖像不支持透明度處理,透明圖片需要召喚 PNG 來呈現(xiàn)。

          PNG-8 與 PNG-24

          關鍵字:無損壓縮、質量高、體積大、支持透明

          PNG 的優(yōu)點

          PNG(可移植網(wǎng)絡圖形格式)是一種無損壓縮的高保真的圖片格式。8 和 24,這里都是二進制數(shù)的位數(shù)。按照我們前置知識里提到的對應關系,8 位的 PNG 最多支持 256 種顏色,而 24 位的可以呈現(xiàn)約 1600 萬種顏色。

          PNG 圖片具有比 JPG 更強的色彩表現(xiàn)力,對線條的處理更加細膩,對透明度有良好的支持。它彌補了上文我們提到的 JPG 的局限性,唯一的 BUG 就是體積太大

          PNG-8 與 PNG-24 的選擇題

          什么時候用 PNG-8,什么時候用 PNG-24,這是一個問題。

          理論上來說,當你追求最佳的顯示效果、并且不在意文件體積大小時,是推薦使用 PNG-24 的。

          但實踐當中,為了規(guī)避體積的問題,我們一般不用PNG去處理較復雜的圖像。當我們遇到適合 PNG 的場景時,也會優(yōu)先選擇更為小巧的 PNG-8

          如何確定一張圖片是該用 PNG-8 還是 PNG-24 去呈現(xiàn)呢?好的做法是把圖片先按照這兩種格式分別輸出,看 PNG-8 輸出的結果是否會帶來肉眼可見的質量損耗,并且確認這種損耗是否在我們(尤其是你的 UI 設計師)可接受的范圍內,基于對比的結果去做判斷。

          應用場景

          前面我們提到,復雜的、色彩層次豐富的圖片,用 PNG 來處理的話,成本會比較高,我們一般會交給 JPG 去存儲。

          考慮到 PNG 在處理線條和顏色對比度方面的優(yōu)勢,我們主要用它來呈現(xiàn)小的 Logo、顏色簡單且對比強烈的圖片或背景等。

          此時我們再次把目光轉向性能方面堪稱業(yè)界楷模的淘寶首頁,我們會發(fā)現(xiàn)它頁面上的 Logo,無論大小,還真的都是 PNG 格式:

          主 Logo:

          較小的 Logo:

          顏色簡單、對比度較強的透明小圖也在 PNG 格式下有著良好的表現(xiàn):

          SVG

          關鍵字:文本文件、體積小、不失真、兼容性好

          SVG(可縮放矢量圖形)是一種基于 XML 語法的圖像格式。它和本文提及的其它圖片種類有著本質的不同:SVG 對圖像的處理不是基于像素點,而是是基于對圖像的形狀描述。

          SVG 的特性

          和性能關系最密切的一點就是:SVG 與 PNG 和 JPG 相比,文件體積更小,可壓縮性更強

          當然,作為矢量圖,它最顯著的優(yōu)勢還是在于圖片可無限放大而不失真這一點上。這使得 SVG 即使是被放到視網(wǎng)膜屏幕上,也可以一如既往地展現(xiàn)出較好的成像品質——1 張 SVG 足以適配 n 種分辨率。

          此外,SVG 是文本文件。我們既可以像寫代碼一樣定義 SVG,把它寫在 HTML 里、成為 DOM 的一部分,也可以把對圖形的描述寫入以 .svg 為后綴的獨立文件(SVG 文件在使用上與普通圖片文件無異)。這使得 SVG 文件可以被非常多的工具讀取和修改,具有較強的靈活性

          SVG 的局限性主要有兩個方面,一方面是它的渲染成本比較高,這點對性能來說是很不利的。另一方面,SVG 存在著其它圖片格式所沒有的學習成本(它是可編程的)。

          SVG 的使用方式與應用場景

          SVG 是文本文件,我們既可以像寫代碼一樣定義 SVG,把它寫在 HTML 里、成為 DOM 的一部分,也可以把對圖形的描述寫入以 .svg 為后綴的獨立文件(SVG 文件在使用上與普通圖片文件無異)。

          將 SVG 寫入 HTML:

          將 SVG 寫入獨立文件后引入 HTML:

          <img src="文件名.svg" alt="">
          

          在實際開發(fā)中,我們更多用到的是后者。很多情況下設計師會給到我們 SVG 文件,就算沒有設計師,我們還有非常好用的 在線矢量圖形庫。對于矢量圖,我們無須深究過多,只需要對其核心特性有所掌握、日后在應用時做到有跡可循即可。

          Base64

          關鍵字:文本文件、依賴編碼、小圖標解決方案

          Base64 并非一種圖片格式,而是一種編碼方式。Base64 和雪碧圖一樣,是作為小圖標解決方案而存在的。在了解 Base64 之前,我們先來了解一下雪碧圖。

          前置知識:最經(jīng)典的小圖標解決方案——雪碧圖(CSS Sprites)

          雪碧圖、CSS 精靈、CSS Sprites、圖像精靈,說的都是這個東西——一種將小圖標和背景圖像合并到一張圖片上,然后利用 CSS 的背景定位來顯示其中的每一部分的技術。

          MDN 對雪碧圖的解釋已經(jīng)非常到位:

          圖像精靈(sprite,意為精靈),被運用于眾多使用大量小圖標的網(wǎng)頁應用之上。它可取圖像的一部分來使用,使得使用一個圖像文件替代多個小文件成為可能。相較于一個小圖標一個圖像文件,單獨一張圖片所需的 HTTP 請求更少,對內存和帶寬更加友好。

          我們幾乎可以在每一個有小圖標出現(xiàn)的網(wǎng)站里找到雪碧圖的影子(下圖截取自京東首頁):

          和雪碧圖一樣,Base64 圖片的出現(xiàn),也是為了減少加載網(wǎng)頁圖片時對服務器的請求次數(shù),從而提升網(wǎng)頁性能。Base64 是作為雪碧圖的補充而存在的。

          理解 Base64

          通過我們上文的演示,大家不難看出,每次加載圖片,都是需要單獨向服務器請求這個圖片對應的資源的——這也就意味著一次 HTTP 請求的開銷。

          Base64 是一種用于傳輸 8Bit 字節(jié)碼的編碼方式,通過對圖片進行 Base64 編碼,我們可以直接將編碼結果寫入 HTML 或者寫入 CSS,從而減少 HTTP 請求的次數(shù)。

          我們來一起看一個實例,現(xiàn)在我有這么一個小小的放大鏡 Logo:

          它對應的鏈接如下:

          https://user-gold-cdn.xitu.io/2018/9/15/165db7e94699824b?w=22&h=22&f=png&s=3680
          

          按照一貫的思路,我們加載圖片需要把圖片鏈接寫入 img 標簽:

          <img src="https://user-gold-cdn.xitu.io/2018/9/15/165db7e94699824b?w=22&h=22&f=png&s=3680">
          

          瀏覽器就會針對我們的圖片鏈接去發(fā)起一個資源請求。

          但是如果我們對這個圖片進行 Base64 編碼,我們會得到一個很長很長的字符串,我們可以直接用這個字符串替換掉上文中的鏈接地址。你會發(fā)現(xiàn)瀏覽器原來是可以理解這個字符串的,它自動就將這個字符串解碼為了一個圖片,而不需再去發(fā)送 HTTP 請求。

          Base64 的應用場景

          上面這個實例,其實源自我們 掘金 網(wǎng)站 Header 部分的搜索欄 Logo:

          既然 Base64 這么棒,我們何不把大圖也換成 Base64 呢?

          這是因為,Base64 編碼后,圖片大小會膨脹為原文件的 4/3(這是由 Base64 的編碼原理決定的)。如果我們把大圖也編碼到 HTML 或 CSS 文件中,后者的體積會明顯增加,即便我們減少了 HTTP 請求,也無法彌補這龐大的體積帶來的性能開銷,得不償失。

          在傳輸非常小的圖片的時候,Base64 帶來的文件體積膨脹、以及瀏覽器解析 Base64 的時間開銷,與它節(jié)省掉的 HTTP 請求開銷相比,可以忽略不計,這時候才能真正體現(xiàn)出它在性能方面的優(yōu)勢。

          因此,Base64 并非萬全之策,我們往往在一張圖片滿足以下條件時會對它應用 Base64 編碼:

          1. 圖片的實際尺寸很小(大家可以觀察一下掘金頁面的 Base64 圖,幾乎沒有超過 2kb 的)
          2. 圖片無法以雪碧圖的形式與其它小圖結合(合成雪碧圖仍是主要的減少 HTTP 請求的途徑,Base64 是雪碧圖的補充)
          3. 圖片的更新頻率非常低(不需我們重復編碼和修改文件內容,維護成本較低)

          Base64 編碼工具推薦

          這里最推薦的是利用 webpack 來進行 Base64 的編碼——webpack 的 url-loader 非常聰明,它除了具備基本的 Base64 轉碼能力,還可以結合文件大小,幫我們判斷圖片是否有必要進行 Base64 編碼。

          除此之外,市面上免費的 Base64 編解碼工具種類是非常多樣化的,有很多網(wǎng)站都提供在線編解碼的服務,大家選取自己認為順手的工具就好。

          WebP

          關鍵字:年輕的全能型選手

          WebP 是今天在座各類圖片格式中最年輕的一位,它于 2010 年被提出, 是 Google 專為 Web 開發(fā)的一種旨在加快圖片加載速度的圖片格式,它支持有損壓縮和無損壓縮。

          WebP 的優(yōu)點

          WebP 像 JPEG 一樣對細節(jié)豐富的圖片信手拈來,像 PNG 一樣支持透明,像 GIF 一樣可以顯示動態(tài)圖片——它集多種圖片文件格式的優(yōu)點于一身。

          WebP 的官方介紹對這一點有著更權威的闡述:

          與 PNG 相比,WebP 無損圖像的尺寸縮小了 26%。在等效的 SSIM 質量指數(shù)下,WebP 有損圖像比同類 JPEG 圖像小 25-34%。 無損 WebP 支持透明度(也稱為 alpha 通道),僅需 22% 的額外字節(jié)。對于有損 RGB 壓縮可接受的情況,有損 WebP 也支持透明度,與 PNG 相比,通常提供 3 倍的文件大小。

          我們開篇提到,圖片優(yōu)化是質量與性能的博弈,從這個角度看,WebP 無疑是真正的贏家。

          WebP 的局限性

          WebP 縱有千般好,但它畢竟太年輕。我們知道,任何新生事物,都逃不開兼容性的大坑。現(xiàn)在是 2018 年 9 月,WebP 的支持情況是這樣的:

          坦白地說,雖然沒有特別慘(畢竟還有親爹 Chrome 在撐腰),但也足夠讓人望而卻步了。

          此外,WebP 還會增加服務器的負擔——和編碼 JPG 文件相比,編碼同樣質量的 WebP 文件會占用更多的計算資源。

          WebP 的應用場景

          現(xiàn)在限制我們使用 WebP 的最大問題不是“這個圖片是否適合用 WebP 呈現(xiàn)”的問題,而是“瀏覽器是否允許 WebP”的問題,即我們上文談到的兼容性問題。具體來說,一旦我們選擇了 WebP,就要考慮在 Safari 等瀏覽器下它無法顯示的問題,也就是說我們需要準備 PlanB,準備降級方案。

          目前真正把 WebP 格式落地到網(wǎng)頁中的網(wǎng)站并不是很多,這其中淘寶首頁對 WebP 兼容性問題的處理方式就非常有趣。我們可以打開 Chrome 的開發(fā)者工具搜索其源碼里的 WebP 關鍵字:

          我們會發(fā)現(xiàn)檢索結果還是挺多的(單就圖示的加載結果來看,足足有?200?多條),下面大家注意一下這些 WebP 圖片的鏈接地址(以其中一個為例):

          <img src="http://img.alicdn.com/tps/i4/TB1CKSgIpXXXXccXXXX07tlTXXX-200-200.png_60x60.jpg_.webp" alt="手機app - 聚劃算" class="app-icon">
          

          .webp 前面,還跟了一個 .jpg 后綴!

          我們現(xiàn)在先大膽地猜測,這個圖片應該至少存在 jpg 和 webp 兩種格式,程序會根據(jù)瀏覽器的型號、以及該型號是否支持 WebP 這些信息來決定當前瀏覽器顯示的是 .webp 后綴還是 .jpg 后綴。帶著這個預判,我們打開并不支持 WebP 格式的 Safari 來進入同樣的頁面,再次搜索 WebP 關鍵字:

          Safari 提示我們找不到,這也是情理之中。我們定位到剛剛示例的 WebP 圖片所在的元素,查看一下它在 Safari 里的圖片鏈接:

          <img src="http://img.alicdn.com/tps/i4/TB1CKSgIpXXXXccXXXX07tlTXXX-200-200.png_60x60.jpg" alt="手機app - 聚劃算" class="app-icon">
          

          我們看到同樣的一張圖片,在 Safari 中的后綴從 .webp 變成了 .jpg!看來果然如此——站點確實是先進行了兼容性的預判,在瀏覽器環(huán)境支持 WebP 的情況下,優(yōu)先使用 WebP 格式,否則就把圖片降級為 JPG 格式(本質是對圖片的鏈接地址作簡單的字符串切割)。

          此外,還有另一個維護性更強、更加靈活的方案——把判斷工作交給后端,由服務器根據(jù) HTTP 請求頭部的 Accept 字段來決定返回什么格式的圖片。當 Accept 字段包含 image/webp 時,就返回 WebP 格式的圖片,否則返回原圖。這種做法的好處是,當瀏覽器對 WebP 格式圖片的兼容支持發(fā)生改變時,我們也不用再去更新自己的兼容判定代碼,只需要服務端像往常一樣對 Accept 字段進行檢查即可。

          由此也可以看出,我們 WebP 格式的局限性確實比較明顯,如果決定使用 WebP,兼容性處理是必不可少的。

          疊樣式表(Cascading Style Sheet,簡稱:CSS)是為網(wǎng)頁添加樣式的代碼。本節(jié)將介紹 CSS 的基礎知識,并解答類似問題:怎樣將文本設置為黑色或紅色?怎樣將內容顯示在屏幕的特定位置?怎樣用背景圖片或顏色來裝飾網(wǎng)頁?

          CSS 究竟什么來頭?

          和 HTML 類似,CSS 也不是真正的編程語言,甚至不是標記語言。它是一門樣式表語言,這也就是說人們可以用它來選擇性地為 HTML 元素添加樣式。舉例來說,要選擇一個 HTML 頁面里所有的段落元素,然后將其中的文本改成紅色,可以這樣寫 CSS:

          p {
            color: red;
          }

          不妨試一下:首先新建一個 styles 文件夾,在其中新建一個 style.css 文件,將這三行 CSS 保存在這個新文件中。

          然后再將該 CSS 文件連接至 HTML 文檔,否則 CSS 代碼不會對 HTML 文檔在瀏覽器里的顯示效果有任何影響。(如果你沒有完成前幾節(jié)的實踐,請復習處理文件 和 HTML 基礎。在筆記本里有這個方面的內容!)

          1、打開 index.html 文件,然后將下面一行粘貼到文檔頭(也就是 <head></head> 標簽之間)。

          <link href="styles/style.css" rel="stylesheet">

          2、保存 index.html 并用瀏覽器將其打開。應該看到以下頁面:

          如果段落文字變紅,那么祝賀你,你已經(jīng)成功地邁出了 CSS 學習的第一步。

          “CSS 規(guī)則集”詳解

          讓我們來仔細看一看上述CSS:

          整個結構稱為 規(guī)則集(通常簡稱“規(guī)則”),各部分釋義如下:

          • 選擇器(Selector
          • HTML 元素的名稱位于規(guī)則集開始。它選擇了一個或者多個需要添加樣式的元素(在這個例子中就是 p 元素)。要給不同元素添加樣式只需要更改選擇器就行了。
          • 聲明(Declaration
          • 一個單獨的規(guī)則,比如說 color: red; 用來指定添加樣式元素的屬性
          • 屬性(Properties
          • 改變 HTML 元素樣式的途徑。(本例中 color 就是 `` 元素的屬性。)CSS 中,由編寫人員決定修改哪個屬性以改變規(guī)則。
          • 屬性的值(Property value
          • 在屬性的右邊,冒號后面即屬性的值,它從指定屬性的眾多外觀中選擇一個值(我們除了 red 之外還有很多屬性值可以用于 color )。

          注意其他重要的語法:

          • 每個規(guī)則集(除了選擇器的部分)都應該包含在成對的大括號里({})。
          • 在每個聲明里要用冒號(:)將屬性與屬性值分隔開。
          • 在每個規(guī)則集里要用分號(;)將各個聲明分隔開。

          如果要同時修改多個屬性,只需要將它們用分號隔開,就像這樣:

          p {
            color: red;
            width: 500px;
            border: 1px solid black;
          }

          多元素選擇

          也可以選擇多種類型的元素并為它們添加一組相同的樣式。將不同的選擇器用逗號分開。例如:

          p, li, h1 {
            color: red;
          }

          不同類型的選擇器

          選擇器有許多不同的類型。上面只介紹了元素選擇器,用來選擇 HTML 文檔中給定的元素。但是選擇的操作可以更加具體。下面是一些常用的選擇器類型:

          選擇器名稱

          選擇的內容

          示例

          元素選擇器(也稱作標簽或類型選擇器)

          所有指定(該)類型的 HTML 元素

          p 選擇 <p>

          ID 選擇器

          具有特定 ID 的元素(單一 HTML 頁面中,每個 ID 只對應一個元素,一個元素只對應一個 ID)

          #my-id 選擇 <p id="my-id"><a id="my-id">

          類選擇器

          具有特定類的元素(單一頁面中,一個類可以有多個實例)

          .my-class 選擇 <p class="my-class"><a class="my-class">

          屬性選擇器

          擁有特定屬性的元素

          img[src] 選擇 <img src="myimage.png"> 而不是 <img>

          偽(Pseudo)類選擇器

          特定狀態(tài)下的特定元素(比如鼠標指針懸停)

          a:hover 僅在鼠標指針懸停在鏈接上時選擇 <a>

          選擇器的種類遠不止于此,更多信息請參閱 選擇器。

          字體和文本

          譯注:再一次說明,中文字體文件較大,不適合直接用于 Web Font。

          在探索了一些 CSS 基礎后,我們來把更多規(guī)則和信息添加至 style.css 中,從而讓示例更美觀。首先,讓字體和文本變得更漂亮。

          第一步:找到之前Google Font 輸出的地址。并以<link>元素的形式添加進index.html文檔頭(<head></head>之間的任意位置)。代碼如下:

           <link href="https://fonts.font.im/css?family=Open+Sans" rel="stylesheet" type="text/css"> 

          以上代碼為當前網(wǎng)頁下載 Open Sans 字體,從而使自定義 CSS 中可以對 HTML 元素應用這個字體。

          第二步:接下來,刪除 style.css 文件中已有的規(guī)則。雖然測試是成功的了,但是紅字看起來并不太舒服。

          第三步:將下列代碼添加到相應的位置,用你在 Google Fonts 找到的字體替代 font-family 中的占位行。( font-family 意味著你想要你的文本使用的字體。)這條規(guī)則首先為整個頁面設定了一個全局字體和字號(因為 <html> 是整個頁面的父元素,而且它所有的子元素都會繼承相同的 font-sizefont-family):

          html {
            /* px 表示 “像素(pixels)”: 基礎字號為 10 像素 */
            font-size: 10px;
            /* Google fonts 輸出的 CSS */
            font-family: 'Open Sans', sans-serif;
          }

          注:CSS 文檔中所有位于 /**/ 之間的內容都是 CSS 注釋,它會被瀏覽器在渲染代碼時忽略。你可以在這里寫下對你現(xiàn)在要做的事情有幫助的筆記。

          譯注:/*``*/ 不可嵌套,/*這樣的注釋是/*不行*/的*/。CSS 不接受 // 注釋。

          接下來為文檔體內的元素(<h1> (en-US)、<li><p>)設置字號。將標題居中顯示,并為正文設置行高和字間距,從而提高頁面的可讀性。

             h1 {
               font-size: 60px;
               text-align: center;
             }
             
             p, li {
               font-size: 16px;
               /* line-height 后而可以跟不同的參數(shù),如果是數(shù)字,就是當前字體大小乘上數(shù)字 */
               line-height: 2;
               letter-spacing: 1px;
             }

          可以隨時調整這些 px 值來獲得滿意的結果,以下是大體效果:

          一切皆盒子

          編寫 CSS 時你會發(fā)現(xiàn),你的工作好像是圍繞著一個一個盒子展開的——設置尺寸、顏色、位置,等等。頁面里大部分 HTML 元素都可以被看作若干層疊的盒子。



          并不意外,CSS 布局主要就是基于盒模型的。每個占據(jù)頁面空間的塊都有這樣的屬性:

          • padding:即內邊距,圍繞著內容(比如段落)的空間。
          • border:即邊框,緊接著內邊距的線。
          • margin:即外邊距,圍繞元素外部的空間。



          這里還使用了:

          • width :元素的寬度
          • background-color :元素內容和內邊距底下的顏色
          • color :元素內容(通常是文本)的顏色
          • text-shadow :為元素內的文本設置陰影
          • display :設置元素的顯示模式(暫略)

          開始在頁面中添加更多 CSS 吧!大膽將這些新規(guī)則都添加到頁面的底部,而不要糾結改變屬性值會帶來什么結果。

          更改頁面顏色

          html{
            background-color:#00539f;
          }

          這條規(guī)則將整個頁面的背景顏色設置為 所計劃的顏色。

          文檔體格式設置

          body{
             width:600px;
             margin:0 auto;
             background-color:#ff9500;
             padding:0 20px 20px 20px;
             border:5px solid black;
          }

          現(xiàn)在是 <body> 元素。以上條聲明,我們來逐條查看:

          • width: 600px; —— 強制頁面永遠保持 600 像素寬。
          • margin: 0 auto; —— 為 marginpadding 等屬性設置兩個值時,第一個值代表元素的上方下方(在這個例子中設置為 0),而第二個值代表左邊右邊(在這里,auto 是一個特殊的值,意思是水平方向上左右對稱)。你也可以使用一個,三個或四個值,參考 這里 。
          • background-color: #FF9500; —— 如前文所述,指定元素的背景顏色。我們給 body 用了一種略微偏紅的橘色以與深藍色的 `` 元素形成反差,你也可以嘗試其它顏色。
          • padding: 0 20px 20px 20px; —— 我們給內邊距設置了四個值來讓內容四周產(chǎn)生一點空間。這一次我們不設置上方的內邊距,設置右邊,下方,左邊的內邊距為20像素。值以上、右、下、左的順序排列。
          • border: 5px solid black; —— 直接為 body 設置 5 像素的黑色實線邊框。

          定位頁面主標題并添加樣式

          h1{
            margin: 0;
            padding:20px 0;
            color: #00539f;
            text-shadow:3px 3px 1px black
          }

          你可能發(fā)現(xiàn)頁面的頂部有一個難看的間隙,那是因為瀏覽器會在沒有任何 CSS 的情況下 給 <h1>en-US等元素設置一些默認樣式。但這并不是個好主意,因為我們希望一個沒有任何樣式的網(wǎng)頁也有基本的可讀性。為了去掉那個間隙,我們通過設置margin: 0;來覆蓋默認樣式。

          至此,我們已經(jīng)把標題的上下內邊距設置為 20 像素,并且將標題文本與 HTML 的背景顏色設為一致。

          需要注意的是,這里使用了一個 text-shadow 屬性,它可以為元素中的文本提供陰影。四個值含義如下:

          • 第一個值設置水平偏移值 —— 即陰影右移的像素數(shù)(負值左移)。
          • 第二個值設置垂直偏移值 —— 即陰影下移的像素數(shù)(負值上移)。
          • 第三個值設置陰影的模糊半徑 —— 值越大產(chǎn)生的陰影越模糊。
          • 第四個值設置陰影的基色。

          不妨嘗試不同的值看看能得出什么結果。

          圖像居中

          img{
            display:block;
            margin:0 auto;
          }

          最后,我們把圖像居中來使頁面更美觀。可以復用 body 的margin: 0 auto,但是需要一點點調整。<body>元素是塊級元素,意味著它占據(jù)了頁面的空間并且能夠賦予外邊距和其他改變間距的值。而圖片是內聯(lián)元素,不具備塊級元素的一些功能。所以為了使圖像有外邊距,我們必須使用display: block 給予其塊級行為。

          注:以上說明假定所選圖片小于頁面寬度(600 pixels)。更大的圖片會溢出 body 并占據(jù)頁面的其他位置。要解決這個問題,可以:

          1)使用 圖片編輯器 來減小圖片寬度; 2)用 CSS 限制圖片大小,即減小 <img> 元素 width 屬性的值(比如 400 px)。

          注:如果你暫時不能理解 display: block 和塊級元素與行內元素的差別也沒關系;隨著你對 CSS 學習的深入,你將明白這個問題。

          小結

          如果你按部就班完成本文的實踐,那么最終可以得到以下頁面


          相關推薦:

          前端新手看過來,手把手帶你輕松上手html的實操


          主站蜘蛛池模板: 夜夜精品视频一区二区| 无码精品人妻一区二区三区免费 | 91视频一区二区| 日韩av片无码一区二区三区不卡 | 日本韩国一区二区三区| 精品乱码一区内射人妻无码| 亚洲国产精品综合一区在线| 一区二区精品视频| 亚洲一区二区三区四区视频| 色综合视频一区二区三区| 在线精品国产一区二区| 国产成人精品视频一区| 区三区激情福利综合中文字幕在线一区亚洲视频1 | 伊人久久精品无码麻豆一区 | 无码国产伦一区二区三区视频| 国产小仙女视频一区二区三区| 久久青草精品一区二区三区| 国产婷婷一区二区三区| 日韩熟女精品一区二区三区| 国产精品美女一区二区三区| AV鲁丝一区鲁丝二区鲁丝三区| 免费无码毛片一区二区APP| 国产一区二区在线看| 熟妇人妻一区二区三区四区| 一区二区精品久久| 国产一区二区在线观看视频 | 一区二区三区日韩| 久久久无码一区二区三区| 久久国产午夜一区二区福利| 国精无码欧精品亚洲一区| 亚洲AV无码一区二区乱子伦| 一区二区在线视频| 国产福利一区二区三区在线观看| 日韩一区二区三区无码影院| 91精品一区二区三区在线观看| 极品人妻少妇一区二区三区| 国产精品视频一区二区猎奇| 亚洲AV网一区二区三区| 韩国精品福利一区二区三区| 免费国产在线精品一区| 国产精品被窝福利一区 |