整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          VUE前端編程:用Html2Canvas實現html頁面轉圖片

          條APP上有個截圖分享功能,就是把文章轉成一張圖片,然后分享到各渠道中去,如微信、QQ等,非常實用,因此,打算就這項功能自己封裝為一個組件ImageGenerator,后期專門進行圖片生成,html是其中的一個源。


          頭條截圖分享的實際效果圖


          目前前端使用較多的html轉圖片的工具是Html2Canvas,考慮技術儲備和問題討論的充裕性,決定封裝一下這個工具。步驟如下:


          安裝HTML2Canvas

          按照官方要求,做安裝操作:

          npm

          npm install html2canvas


          我當時下載的是1.4.1的版本。



          這個工具有自身的一些限制,使用時要注意:

          [1] 并非真正的截圖軟件,而是根據DOM繪制出來的,其繪制能力,完全依賴于工具對DOM和對應屬性的支持和理解;

          [2] 因為使用了Canvas支持,生成圖片的區域不能再有Canvas應用,否則會干擾工具的生成,不能保證生成預期,因此,如果使用了Canvas圖表的應用這個工具不推薦使用


          封裝ImageGenerator

          這個很簡單,這里就是封裝一個組件,用于后期引入html之外的源生成圖片,同時也做一下圖片的統一顯示,從而和系統整體的設計進行配合。大致的實現思路如下:



          上圖,我們引入了工具本身,并設置的結果的顯示區。生成的結果將以節點的方式注入 #image-box 中。


          上圖,封裝了一個方法,用途是利用Html2Canvas工具獲得圖片,這里我們引入了一個組件的數據imageData用以存儲和干預生成結果。在這里,我把ImageGenerator封裝為全局組件。


          應用場景

          我們在文章的尾部加入一個share功能,點擊彈出分享設置的彈窗,實際效果如下圖所示:




          以上技術實現比較簡單,這里就不進行贅述了。上圖中,我們設置了一個生成圖像按鈕,點擊該按鈕則可以觸發我們組件中的對應操作。關鍵思路包括:

          【1】這里設置了一個封裝組件shareHandler,封裝了前導模塊和imageGenerator,這兩個模塊的顯示通過一個開關進行控制,該開關則通過圖像生成成功事件進行賦值,這樣的話,我們可以實現圖片生成后,不再顯示前導模塊,而是顯示圖片結果,即ImageGenerator。


          【2】這里有一個比較關鍵的操作是shareHandler通過觸發事件將轉換器發射到文章轉換現場,為什么用事件,還是那句話,事件對于解耦和消除組件依賴是最自然的實現。注意,這里我把imageGenerator通過引用的方式作為參數傳出了,這樣的好處是事件將轉換器代入了轉換現場,并可以攜帶回現場轉換結果。



          【3】在文章查看器,solutionViewer中,自然會訂閱事件、事件處理和取消訂閱。注意這里的事件處理,實際上是調用了轉換器中我們封裝的函數,參數則是現場取得的,這里的機制很簡單,定義要轉換div的id,作為參數傳入函數。



          那么,點擊圖像后,我們可以看到效果圖:



          點擊右鍵另存圖像,我們可以獲得一張png格式的圖片,至于后續對下載和到粘貼板的支持,大家可以自行研究和實現。



          注意事項

          實現過程中有幾個注意事項:

          【1】Canvas返回時,其長寬都是按照實際大小生成的,而我們的例子中,則要根據右抽屜式的彈窗做width=100%,height=auto的處理,這個要如何實現,就是要通過我們在imageGenerator中引入的imageData。


          【2】我們的文章顯示中,引入了文件管理的微服務,因此,文章中圖片的鏈接都是跨域的,所以,必須打開html2Canvas的跨域選項,在封裝的組件里,我是通過一共一個defaultOptions來實現這一點的。



          這個選項可以在轉換場景提供,也可提供一系列的默認值,最常用的除跨域外,還有是否允許log輸出等開關,大家感興趣可以自行查閱html2Canvas的官網。


          內容比較簡單,大家如果有這個應用場景,可以參考實現一下,有問題歡迎大家隨時交流。謝謝大家的支持。

          者開源了一個Web思維導圖,在做導出為圖片的功能時走了挺多彎路,所以通過本文來記錄一下。

          思維導圖的節點和連線都是通過 svg渲染的,作為一個純 js 庫,我們不考慮通過后端來實現,所以只能思考如何通過純前端的方式來實現將svg或html轉換為圖片。

          使用img標簽結合canvas導出

          我們都知道 img 標簽可以顯示 svg,然后 canvas 又可以渲染 img,那么是不是只要將svg渲染到img標簽里,再通過canvas導出為圖片就可以呢,答案是肯定的。

          const svgToPng = async (svgStr) => {
              // 轉換成blob數據
              let blob = new Blob([svgStr], {
                type: 'image/svg+xml'
              })
              // 轉換成data:url數據
              let svgUrl = await blobToUrl(blob)
              // 繪制到canvas上
              let imgData = await drawToCanvas(svgUrl)
              // 下載
              downloadFile(imgData, '圖片.png')
          }

          svgStr是要導出的svg字符串,比如:

          然后通過Blob構造函數創建一個類型為image/svg+xml的blob數據,接下來將blob數據轉換成data:URL:

          const blobToUrl = (blob) => {
              return new Promise((resolve, reject) => {
                  let reader = new FileReader()
                  reader.onload = evt => {
                      resolve(evt.target.result)
                  }
                  reader.onerror = err => {
                      reject(err)
                  }
                  reader.readAsDataURL(blob)
              })
          }

          其實就是base64格式的字符串。

          接下來就可以通過img來加載,并渲染到canvas里進行導出:

          const drawToCanvas = (svgUrl) => {
              return new Promise((resolve, reject) => {
                const img = new Image()
                // 跨域圖片需要添加這個屬性,否則畫布被污染了無法導出圖片
                img.setAttribute('crossOrigin', 'anonymous')
                img.onload = async () => {
                  try {
                    let canvas = document.createElement('canvas')
                    canvas.width = img.width
                    canvas.height = img.height
                    let ctx = canvas.getContext('2d')
                    ctx.drawImage(img, 0, 0, img.width, img.height)
                    resolve(canvas.toDataURL())
                  } catch (error) {
                    reject(error)
                  }
                }
                img.onerror = e => {
                  reject(e)
                }
                img.src = svgUrl
              })
          }

          canvas.toDataURL()方法返回的也是一個base64格式的data:URL字符串:

          最后就可以通過a標簽來下載:

          const downloadFile = (file, fileName) => {
            let a = document.createElement('a')
            a.href = file
            a.download = fileName
            a.click()
          }

          實現很簡單,效果也不錯,不過這樣就沒問題了嗎,接下來我們插入兩張圖片試試。

          處理存在圖片的情況

          第一張圖片是使用base64的data:URL方式插入的,第二張圖片是使用普通url插入的:

          導出結果如下:

          可以看到,第一張圖片沒有問題,第二張圖片裂開了,可能你覺得同源策略的問題,但實際上換成同源的圖片,同樣也是裂開的,解決方法很簡單,遍歷svg節點樹,將圖片都轉換成data:URL的形式即可:

          // 操作svg使用了@svgdotjs/svg.js庫
          const transfromImg = (svgNode) => {
              let imageList = svgNode.find('image')
              let task = imageList.map(async item => {
                // 獲取圖片url
                let imgUlr = item.attr('href') || item.attr('xlink:href')
                // 已經是data:URL形式不用轉換
                if (/^data:/.test(imgUlr)) {
                  return
                }
                // 轉換并替換圖片url
                let imgData = await drawToCanvas(imgUlr)
                item.attr('href', imgData)
              })
              await Promise.all(task)
              return svgNode.svg()// 返回svg html字符串
          }

          這里使用了前面的drawToCanvas方法來將圖片轉換成data:URL,這樣導出就正常了:

          到這里,將純 svg 轉換為圖片就基本沒啥問題了。

          處理存在foreignObject標簽的情況

          svg提供了一個foreignObject標簽,可以插入html節點,實際上,筆者就是使用它來實現節點的富文本編輯效果的:

          接下來使用前面的方式來導出,結果如下:

          明明顯示沒有問題,導出時foreignObject內容卻發生了偏移,這是為啥呢,其實是因為默認樣式的問題,頁面全局清除了margin和padding,以及將box-sizing設置成了border-box:

          那么當svg存在于文檔樹中時是沒有問題的,但是導出時使用的是svg字符串,是脫離于文檔的,所以沒有這個樣式覆蓋,那么顯示自然會出現問題,知道了原因,解決方法有兩種,一是遍歷所有嵌入的html節點,手動添加內聯樣式,注意一定要給所有的html節點都添加,只給svg、foreignObject或最外層的html節點添加都是不行的;第二種是直接在foreignObject標簽里添加一個style標簽,通過style標簽來加上樣式,并且只要給其中一個foreignObject標簽添加就可以了,兩種方式看你喜歡哪種,筆者使用的是第二種:

          const transformForeignObject = (svgNode) => {
              let foreignObjectList = svgNode.find('foreignObject')
              if (foreignObjectList.length > 0) {
                  foreignObjectList[0].add(SVG(`<style>
                  * {
                      margin: 0;
                      padding: 0;
                      box-sizing: border-box;
                  }
                  </style>`))
              }
              return svgNode.svg()
          }

          導出結果如下:

          可以看到,一切正常。

          關于兼容性的問題,筆者測試了最新的chrome、firefox、opera、safari、360急速瀏覽器,運行都是正常的。

          踩坑記錄

          前面介紹的是筆者目前采用的方案,看著實現其實非常簡單,但是過程漫長且坎坷,接下來,開始我的表演。

          foreignObject標簽內容在firefox瀏覽器上無法顯示

          對于svg的操作筆者使用的是svg.js庫,創建富文本節點的核心代碼大致如下:

          import { SVG, ForeignObject } from '@svgdotjs/svg.js'
          
          let html = `<div>節點文本</div>`
          let foreignObject = new ForeignObject()
          foreignObject.add(SVG(html))
          g.add(foreignObject)

          SVG方法是用來將一段html字符串轉換為dom節點的。

          在chrome瀏覽器和opera瀏覽器上渲染非常正常,但是在firefox瀏覽器上foreignObject標簽的內容完全渲染不出來:

          檢查元素也看不出有任何問題,并且神奇的是只要在控制臺元素里編輯一下嵌入的html內容,它就可以顯示了,百度搜索了一圈,也沒找到解決方法,然后因為firefox瀏覽器占有率并不高,于是這個問題就擱淺了。

          使用img結合canvas導出圖片里foreignObject標簽內容為空

          chrome瀏覽器雖然渲染是正常的:

          但是使用前面的方式導出時foreignObject標簽內容卻是跟在firefox瀏覽器里顯示一樣是空的:

          firefox能忍這個不能忍,于是嘗試使用一些將html轉換為圖片的庫。

          使用html2canvas、dom-to-image等庫

          使用html2canvas:

          import html2canvas from 'html2canvas'
          
          const useHtml2canvas = async (svgNode) => {
              let el = document.createElement('div')
              el.style.position = 'absolute'
              el.style.left = '-9999999px'
              el.appendChild(svgNode)
              document.body.appendChild(el)// html2canvas轉換需要被轉換的節點在文檔中
              let canvas = await html2canvas(el, {
                  backgroundColor: null
              })
              mdocument.body.removeChild(el)
              return canvas.toDataURL()
          }

          html2canvas可以成功導出,但是存在一個問題,就是foreignObject標簽里的文本樣式會丟失:

          這應該是html2canvas的一個bug,不過看它這issues數量和提交記錄:

          指望html2canvas改是不現實的,于是又嘗試使用dom-to-image:

          import domtoimage from 'dom-to-image'
          
          const dataUrl = domtoimage.toPng(el)

          發現dom-to-image更不行,導出完全是空白的:

          并且它上一次更新時間已經是五六年前,所以沒辦法,只能回頭使用html2canvas。

          后來有人建議使用dom-to-image-more,粗略看了一下,它是在dom-to-image庫的基礎上修改的,嘗試了一下,發現確實可以,于是就改為使用這個庫,然后又有人反饋在一些瀏覽器上導出節點內容是空的,包括firefox、360,甚至chrome之前的版本都不行,筆者只能感嘆,太難了,然后又有人建議使用上一個大版本,可以解決在firefox上的導出問題,但是筆者試了一下,在其他一些瀏覽器上依舊存在問題,于是又在考慮要不要換回html2canvas,雖然它存在一定問題,但至少不是完全空的。

          解決foreignObject標簽內容在firefox瀏覽器上無法顯示的問題

          用的人多了,這個問題又有人提了出來,于是筆者又嘗試看看能不能解決,之前一直認為是firefox瀏覽器的問題,畢竟在chrome和opera上都是正常的,這一次就想會不會是svgjs庫的問題,于是就去搜它的issue,沒想到,還真的搜出來了issue,大意就是因為通過SVG方法轉換的dom節點是在svg的命名空間下,也就是使用document.createElementNS方法創建的,導致部分瀏覽器渲染不出來,歸根結底,這還是不同瀏覽器對于規范的不同實現導致的:

          你說chrome很強吧,確實,但是無形中它阻止了問題的暴露。

          知道了原因,那么修改也很簡單了,只要將SVG方法第二個參數設為true即可,或者自己來創建節點也可以:

          foreignObject.add(document.createElemnt('div'))

          果然,在firefox瀏覽器上正常渲染了。

          解決img結合canvas導出圖片為空的問題

          解決了在firefox瀏覽器上foreignObject標簽為空的問題后,自然會懷疑之前使用img結合canvas導出圖片時foreignObject標簽為空會不會也是因為這個問題,同時了解了一下dom-to-image庫的實現原理,發現它也是通過將dom節點添加到svg的foreignObject標簽中實現將html轉換成圖片的,那么就很搞笑了,我本身要轉換的內容就是一個嵌入了foreignObject標簽的svg,使用dom-to-image轉換,它會再次把傳給它的svg添加到一個foreignObject標簽中,這不是套娃嗎,既然dom-to-image-more能通過foreignObject標簽成功導出,那么不用它必然也可以,到這里基本確信之前不行就是因為命名空間的問題。

          果然,在去掉了dom-to-image-more庫后,重新使用之前的方式成功導出了,并且在firefox、chrome、opera、360等瀏覽器中都不存在問題,兼容性反而比dom-to-image-more庫好。

          總結

          雖然筆者的實現很簡單,但是dom-to-image-more這個庫實際上有一千多行代碼,那么它到底多做了些什么呢,點個關注,我們下一篇文章再見。

          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繪制圖片
                      // ...
                });
          }
          

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

          將節點轉換成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) {// 深度克隆節點
                      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后面會看到。

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

          處理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方法進行克隆,參數傳了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

          最后就是處理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

          圖片也處理完了接下來就可以將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的操作就結束了。

          將svg轉換成圖片

          現在我們可以回到draw方法:

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

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

          將圖片通過canvas導出

          繼續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標簽我也是從來沒有見過。等等。

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


          主站蜘蛛池模板: 亚洲一区在线观看视频| 国产精品一区在线播放| 奇米精品一区二区三区在线观看| 日韩精品一区二区三区四区| 波多野结衣中文一区| 亚洲综合一区二区三区四区五区 | 免费看无码自慰一区二区| 精品国产日产一区二区三区 | 日韩视频一区二区| 麻豆一区二区免费播放网站| 精品乱人伦一区二区三区| 亚洲国产精品一区二区九九| 精品久久久中文字幕一区| 亚洲一区免费观看| 国产午夜精品一区二区三区不卡| 日韩久久精品一区二区三区| 久热国产精品视频一区二区三区 | 国产精品一区在线播放| 国产精品高清一区二区人妖 | 国产中文字幕一区| 国产精品成人一区二区三区| 日本精品高清一区二区2021| 在线视频一区二区日韩国产| 久久久久人妻一区二区三区vr| 日韩人妻一区二区三区蜜桃视频| 亚洲无人区一区二区三区| 国产在线精品一区二区三区直播 | 色窝窝无码一区二区三区色欲| 国产亚洲福利精品一区| 亚洲国产日韩一区高清在线| 亚洲日本一区二区三区| 国产日本一区二区三区| 亚洲熟妇AV一区二区三区浪潮| 中文字幕AV一区二区三区| 午夜影院一区二区| 波多野结衣一区二区三区高清在线 | 精品少妇一区二区三区在线 | 亚洲一区二区三区首页| 国产福利91精品一区二区三区| 四虎成人精品一区二区免费网站| 福利片福利一区二区三区|