不能說的秘密——前端也能玩的圖片隱寫術
上個月在千里碼刷題的時候,碰到了比較有意思的一道題—— 隱寫術。既然感覺有意思,又很久沒有玩過 canvas,所以今天結合這兩塊內容帶大家探索一下。
隱寫術算是一種加密技術,權威的 wiki 說法是“ 隱寫術是一門關於信息隱藏的技巧與科學,所謂信息隱藏指的是不讓除預期的接收者之外的任何人知曉信息的傳遞事件或者信息的內容。” 這看似高大上的定義,並不是近代新誕生的技術,早在 13 世紀末德國人 Trithemius 就寫出了《隱寫術》的著作,學過密碼學的同學可能知道。好了,說了這么多,隱寫術到底是什么技術,讓我們看一個例子。
下面是一張看似普通的圖片,但其中卻藏有另一個肉眼無法識別的圖像哦。
這是如果把上圖每個色彩空間和數字 3 進行邏輯與運算,再把亮度增強 85 倍,可以得到下圖。
簡單的說,上述的處理過程可以理解為對圖片像素的處理,也就是說,加密的信息散布在每個像素點上。可是,13 世紀還沒有“ 像素” 這個概念吧?!沒錯,上面這個例子只是隱寫術的一個現代技術實現,隱藏信息的手段有很多,我們日常的鈔票防偽也算是隱寫術的一種,所以標題上也限定了我們的討論范圍—— 圖片隱寫術。
(電子水印與隱寫術有一些共通點)
聚焦到載體為圖片的隱寫術,一起來從前端角度分析其技術原理。
我們知道圖片的像素信息里存儲着 RGB 的色值,R、G、B 分別為該像素的紅、綠、藍通道,每個通道的分量值范圍在 0~255,16 進制則是 00~FF。在 CSS 中經常使用其 16 進制形式,比如指定博客頭部背景色為 #A9D5F4。其中 R(紅色)的 16 進制值為 A9,換算成十進制為 169。這時候,對 R 分量的值+1,即為 170,整個像素 RGB 值為 #AAD5F4,別說你看不出差別,就連火眼金金的“ 像素眼” 設計師都察覺不出來呢。於此同時,修改 G、B 的分量值,也是我們無法察覺的。因此可以得出重要結論:RGB 分量值的小量變動,是肉眼無法分辨的,不影響對圖片的識別。
有了這個結論,那就給我們了利用空間,常用手段的就是對二進制最低位進行操作,下面就用 canvas 來演示一下。
解開圖中的秘密
這是一張我們當家美女小蘭師姐的照片,為了讓例子足夠簡單,里面的 R 通道分量被我加入了文本信息,想知道其中的信息,可以跟我用 canvas 代碼來解開。
首先在頁面加入一個 canvas 標簽,並獲取到其上下文。
接着將圖片先繪制在畫布上,然后獲取其像素數據。
打印出數據,會看到有一個非常大的數組。
這個一維數組存儲了所有的像素信息,一共有 256 * 256 * 4 = 262144 個值。其中 4 個值一組,為什么呢?在瀏覽器中解析圖片,除了 RGB 值外,每組第 4 個值為透明度值,即像素信息實際為大家熟知的 rgba 值。
這里的解密規則是對 R 通道進行處理,R 的分量最低位為 1 則該像素設為紅色,R 的分量最低位為 0 則該像素設為黑色,直接看代碼實現,完成后我們再繪制到 canvas,即可看到結果。
在 img onload 事件中調用 processData 方法,就可以看到結果啦。
得到的結果可能是這個樣子的。
在圖片中隱藏信息
講了基礎的解密過程,再來反向說說加密過程。
既然要在圖片中加入文字信息,那么首先要獲取文字的像素信息,這里我先用 canvas 在畫布上打印文字,獲取像素信息。
先保存文字的像素信息,接着加載圖片獲取其像素信息,然后對兩組像素進行處理,我在這里抽離了一個公共方法。
上述代碼做的是,接受要隱藏的數據以及隱藏的顏色通道,然后對原圖進行操作,修改圖片該通道分量的最低位,如果有文字信息,則最低位置為 1,否則為 0。從最文章開頭的結論知道,RGB 的三個通道可以分別隱藏不同信息。
在 img.onload 中調用 mergeData(textData, 'R'),處理好圖像后,只要在瀏覽器中的 canvas 上右鍵保存圖片即可。
這里的例子比較簡單,只展示了基本的最低位隱藏文本信息,像二維碼這些簡單圖形也可以這么處理。現實中隱藏畫中畫則需要更專業的圖像處理算法,這里就不再展開了。
應用價值
圖片隱寫術的應用價值很廣泛,比如程序員之間的表白(不限男女),不失為一種浪漫的方式~
上面的案例中我沒有放出師姐的原片,這意味着如果盜用上面的圖片,我是有辦法識別出來的,起到了簡單的一種簽名作用。當然你也有辦法消除掉里面的信息,而前提是你需要知道我的加密方式,可是實際應用中絕不會這么簡單哦。有個成功案例就是大眾點評通過這種方式,成功證明食神 app 對其圖片的盜用,為自己的合法權益進行了有效維護。
好的,感謝閱讀到最后,作為回報,我將福利隱藏在了師姐的圖片中,請自行發現吧~
出處:http://www.alloyteam.com/2016/03/image-steganography/
=======================================================================================
從破解某設計網站談前端水印(詳細教程)
前言
最近在寫公眾號的時候,常常會自己做首圖,並且慢慢地發現沉迷於制作首圖,感覺扁平化的設計的真好好看。慢慢地萌生了一個做一個屬於自己的首圖生成器的想法。

制作呢,當然也不是拍拍腦袋就開始,在開始之前,就去研究了一下某在線設計網站(如果有人不知道的話,可以說一下,這是一個在線制作海報之類的網站 T T 像我們這種內容創作者用的比較多),畢竟人家已經做了很久了,我只是想做個方便個人使用的。畢竟以上用 PS 做着還是有一些廢時間,由於組成的元素都很簡單,做一個自動化生成的完全可以。
但是研究着研究着,就看到了某在線設計網站的水印,像這種技術支持的網站,最重要的防御措施就是水印了,水印能夠很好的保護知識產權。
慢慢地路就走偏了,開始對它的水印感興趣了。不禁發現之前只是大概知道水印的生成方法,但是從來沒有仔細研究過,本文將以以下的路線進行講解。以下所有代碼示例均在
明水印
水印(watermark)是一種容易識別、被夾於紙內,能夠透過光線穿過從而顯現出各種不同陰影的技術。
水印的類型有很多,有一些是整圖覆蓋在圖層上的水印,還有一些是在角落。


那么這個水印怎么實現呢?熟悉 PS 的朋友,都知道 PS 有個叫做圖層的概念。

網頁也是如此。我們可以通過絕對定位,來將水印覆蓋到我們的頁面之上。
最終變成了這個樣子。
等等,但是發現少了點什么。直接覆蓋上去,就好像是一個蒙層,我都知道這樣是無法觸發底下圖層的事件的,此時就要介紹一個css屬性pointer-events
。
pointer-events
CSS 屬性指定在什么情況下 (如果有) 某個特定的圖形元素可以成為鼠標事件的 target。
當它的被設置為 none
的時候,能讓元素實體虛化,雖然存在這個元素,但是該元素不會觸發鼠標事件。詳情可以查看 CSS3 pointer-events:none應用舉例及擴展 « 張鑫旭-鑫空間-鑫生活 。
這下理清了實現原理,等於成功了一半了!

明水印的生成
明水印的生成方式主要可以歸為兩類,一種是 純 html 元素(純div),另一種則為背景圖(canvas/svg)。
下面我分別來介紹一下,兩種方式。
div實現
我們首先來講比較簡單的 div 生成的方式。就按照我們剛才說的。
// 文本內容
<div class="app">
<h1>秋風</h1>
<p>hello</p>
</div>
復制代碼
首先我們來生成一個水印塊,就是上面的 一個個秋風的筆記
。這里主要有一點就是設置一個透明度(為了讓水印看起來不是那么明顯,從而不遮擋我們的主要頁面),另一個就是一個旋轉,如果是正的水平會顯得不是那么好看,最后一點就是使用 userSelect
屬性,讓此時的文字無法被選中。
userSelect
function cssHelper(el, prototype) {
for (let i in prototype) {
el.style[i] = prototype[i]
}
}
const item = document.createElement('div')
item.innerHTML = '秋風的筆記'
cssHelper(item, {
position: 'absolute',
top: `50px`,
left: `50px`,
fontSize: `16px`,
color: '#000',
lineHeight: 1.5,
opacity: 0.1,
transform: `rotate(-15deg)`,
transformOrigin: '0 0',
userSelect: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
})
復制代碼
有了一個水印片,我們就可以通過計算屏幕的寬高,以及水印的大小來計算我們需要生成的水印個數。
const waterHeight = 100;
const waterWidth = 180;
const { clientWidth, clientHeight } = document.documentElement || document.body;
const column = Math.ceil(clientWidth / waterWidth);
const rows = Math.ceil(clientHeight / waterHeight);
for (let i = 0; i < column * rows; i++) {
const wrap = document.createElement('div');
cssHelper(wrap, Object.create({
position: 'relative',
width: `${waterWidth}px`,
height: `${waterHeight}px`,
flex: `0 0 ${waterWidth}px`,
overflow: 'hidden',
}));
wrap.appendChild(createItem());
waterWrapper.appendChild(wrap)
}
document.body.appendChild(waterWrapper)
復制代碼
這樣子我們就完美地實現了上面我們給出的思路的樣子啦。
背景圖實現
canvas
canvas
的實現很簡單,主要是利用canvas
繪制一個水印,然后將它轉化為 base64 的圖片,通過canvas.toDataURL()
來拿到文件流的 url ,關於文件流相關轉化可以參考我之前寫的文章一文帶你層層解鎖「文件下載」的奧秘, 然后將獲取的 url 填充在一個元素的背景中,然后我們設置背景圖片的屬性為重復。
.watermark {
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
pointer-events: none;
background-repeat: repeat;
}
復制代碼
function createWaterMark() {
const angle = -20;
const txt = '秋風的筆記'
const canvas = document.createElement('canvas');
canvas.width = 180;
canvas.height = 100;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, 180, 100);
ctx.fillStyle = '#000';
ctx.globalAlpha = 0.1;
ctx.font = `16px serif`
ctx.rotate(Math.PI / 180 * angle);
ctx.fillText(txt, 0, 50);
return canvas.toDataURL();
}
const watermakr = document.createElement('div');
watermakr.className = 'watermark';
watermakr.style.backgroundImage = `url(${createWaterMark()})`
document.body.appendChild(watermakr);
復制代碼
svg
svg 和 canvas 類似,主要還是生成背景圖片。
function createWaterMark() {
const svgStr =
`<svg xmlns="http://www.w3.org/2000/svg" width="180px" height="100px"> <text x="0px" y="30px" dy="16px" text-anchor="start" stroke="#000" stroke-opacity="0.1" fill="none" transform="rotate(-20)" font-weight="100" font-size="16" > 秋風的筆記 </text> </svg>`;
return `data:image/svg+xml;base64,${window.btoa(unescape(encodeURIComponent(svgStr)))}`;
}
const watermakr = document.createElement('div');
watermakr.className = 'watermark';
watermakr.style.backgroundImage = `url(${createWaterMark()})`
document.body.appendChild(watermakr);
復制代碼
明水印的破解一
以上就很快實現了水印的幾種方案。但是對於有心之人來說,肯定會想着破解,以上破解也很簡單。
打開了 Chrome Devtools
找到對應的元素,直接按 delete
即可刪除。
明水印的防御
這樣子的水印對於大概知道控制台操作的小白就可以輕松破解,那么有什么辦法能防御住這樣的操作呢?
答案是肯定的,js 有一個方法叫做 MutationObserver
,能夠監控元素的改動。
MutationObserver 對現代瀏覽的兼容性還是不錯的,MutationObserver是元素觀察器,字面上就可以理解這是用來觀察Node(節點)變化的。MutationObserver是在DOM4規范中定義的,它的前身是MutationEvent事件,最低支持版本為 ie9 ,目前已經被棄用。
在這里我們主要觀察的有三點
- 水印元素本身是否被移除
- 水印元素屬性是否被篡改(display: none ...)
- 水印元素的子元素是否被移除和篡改 (element生成的方式 )
來通過 MDN 查看該方法的使用示例。
const targetNode = document.getElementById('some-id');
// 觀察器的配置(需要觀察什么變動)
const config = { attributes: true, childList: true, subtree: true };
// 當觀察到變動時執行的回調函數
const callback = function(mutationsList, observer) {
// Use traditional 'for loops' for IE 11
for(let mutation of mutationsList) {
if (mutation.type === 'childList') {
console.log('A child node has been added or removed.');
}
else if (mutation.type === 'attributes') {
console.log('The ' + mutation.attributeName + ' attribute was modified.');
}
}
};
// 創建一個觀察器實例並傳入回調函數
const observer = new MutationObserver(callback);
// 以上述配置開始觀察目標節點
observer.observe(targetNode, config);
復制代碼
而MutationObserver
主要是監聽子元素的改動,因此我們的監聽對象為 document.body
, 一旦監聽到我們的水印元素被刪除,或者屬性修改,我們就重新生成一個。通過以上示例,加上我們的思路,很快我們就寫一個監聽刪除元素的示例。(監聽屬性修改也是類似就不一一展示了)
// 觀察器的配置(需要觀察什么變動)
const config = { attributes: true, childList: true, subtree: true };
// 當觀察到變動時執行的回調函數
const callback = function (mutationsList, observer) {
// Use traditional 'for loops' for IE 11
for (let mutation of mutationsList) {
mutation.removedNodes.forEach(function (item) {
if (item === watermakr) {
document.body.appendChild(watermakr);
}
});
}
};
// 監聽元素
const targetNode = document.body;
// 創建一個觀察器實例並傳入回調函數
const observer = new MutationObserver(callback);
// 以上述配置開始觀察目標節點
observer.observe(targetNode, config);
復制代碼
我們打開控制台來檢驗一下。
這回完美了,能夠完美抵御一些開發小白了。
那么這樣就萬無一失了嗎?顯然,道高一尺魔高一丈,畢竟前端的一切都是不安全的。
明水印的破解二
在一個高級前端工程師面前,一切都是紙老虎。接下來我就隨便介紹三種破解的方式。
第一種
打開 Chrome Devtools
,點擊設置 - Debugger - Disabled JavaScript .

然后再打開頁面,delete
我們的水印元素。
第二種
復制一個 body 元素,然后將原來 body 元素的刪除。
第三種
打開一個代理工具,例如 charles
,將生成水印相關的代碼刪除。
破解實踐
接下來我們實戰一下,通過預先分析,我們看到某設計網站的內容是以 div 的方式實現的,所以可以利用這種方案。
打開控制台,Ctrl + F
搜索 watermark
相關字眼。(這一步是作為一個程序員的直覺,基本上你要找什么,搜索對應的英文就可以 ~)
很快我們就找到了水印圖。發現直接刪除,沒有辦法刪除水印元素,根據我們剛才學習的,肯定是利用了MutationObserver
方法。我們使用我們的第一個破解方法,將 JavaScript 禁用,再將元素刪除。
水印已經消失了。
但是這樣真的就萬事大吉了嗎?

不知道你有沒有聽過一種東西,看不見摸不着,但是它卻真實存在,他的名字叫做暗水印,我們將時間倒流到 16 年間的月餅門事件,因為有員工將內網站點截圖了,但是很快被定位出是誰截圖了。
雖然你將一些可見的水印去除了,但是還會存在一些不可見的保護版權的水印。(這就是防止一些壞人拿去作另外的用途)
暗水印
暗水印是一種肉眼不可見的水印方式,可以保持圖片美觀的同時,保護你的資源版權。
暗水印的生成方式有很多,常見的為通過修改RGB 分量值的小量變動、DWT、DCT 和 FFT 等等方法。
通過介紹前端實現 RGB 分量值的小量變動 來揭秘其中的奧秘,主要參考 不能說的秘密——前端也能玩的圖片隱寫術 | AlloyTeam。
我們都知道圖片都是有一個個像素點構成的,每個像素點都是由 RGB 三種元素構成。當我們把其中的一個分量修改,人的肉眼是很難看出其中的變化,甚至是像素眼的設計師也很難分辨出。
你能看出其中的差別嗎?根據這個原理,我們就來實踐吧。(女孩子可以掌握方法后可以拿以下圖片進行試驗測試)
首先拿到以上圖片,我們先來講解解碼方式,解碼其實很簡單,我們需要創建一個規律,再通過我們的規律去解碼。現在假設的規律為,我們將所有像素的 R 通道的值為奇數的時候我們創建的通道密碼,舉個簡單的例子。
例如我們把以上當做是一個圖形,加入他要和一個中文的 "一" 放進圖像,例如我們將 "一" 放入第二行。按照我們的算法,我們的圖像會變成這個樣子。
解碼的時候,我們拿到所有的奇數像素將它渲染出來,例如這里的 '5779' 是不是正好是一個 "一",下面就轉化為實踐。
解碼過程
首先創建一個 canvas
標簽。
<canvas id="canvas" width="256" height="256"></canvas>
復制代碼
var ctx = document.getElementById('canvas').getContext('2d');
var img = new Image();
var originalData;
img.onload = function () {
// canvas像素信息
ctx.drawImage(img, 0, 0);
originalData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
console.log()
processData(ctx, originalData)
};
img.src = 'qiufeng-super.png';
復制代碼
我們打印出這個數組,會有一個非常大的數組,一共有 256 * 256 * 4 = 262144 個值。因為每個像素除了 RGB 外還有一個 alpha 通道,也就是我們常用的透明度。
上面也說了,我們的 R 通道為奇數的時候 ,就我們的解密密碼。因此我們只需要所有的像素點的 R 通道為奇數的時候,將它填填充,不為奇數的時候就不填充,很快我們就能得到我們的隱藏圖像。
var processData = function (ctx, originalData) {
var data = originalData.data;
for (var i = 0; i < data.length; i++) {
if (i % 4 == 0) {
// R分量
if (data[i] % 2 == 0) {
data[i] = 0;
} else {
data[i] = 255;
}
} else if (i % 4 == 3) {
// alpha通道不做處理
continue;
} else {
// 關閉其他分量,不關閉也不影響答案
data[i] = 0;
}
}
// 將結果繪制到畫布
ctx.putImageData(originalData, 0, 0);
}
processData(ctx, originalData)
復制代碼
解密完會出現類似於以下這個樣子。

那我們如何加密的,那就相反的方式就可以啦。(這里都用了 不能說的秘密——前端也能玩的圖片隱寫術 中的例子,= = 我也能寫出一個例子,但是覺得沒必要,別人已經寫得很好了,我們只是講述這個方法,需要代碼來舉例而已)
編碼過程
加密呢,首先我們需要獲取加密的圖像信息。
var textData;
var ctx = document.getElementById('canvas').getContext('2d');
ctx.font = '30px Microsoft Yahei';
ctx.fillText('秋風的筆記', 60, 130);
textData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height).data;
復制代碼
然后提取加密信息在待加密的圖片上進行處理。
var mergeData = function (ctx, newData, color, originalData) {
var oData = originalData.data;
var bit, offset; // offset的作用是找到alpha通道值,這里需要大家自己動動腦筋
switch (color) {
case 'R':
bit = 0;
offset = 3;
break;
case 'G':
bit = 1;
offset = 2;
break;
case 'B':
bit = 2;
offset = 1;
break;
}
for (var i = 0; i < oData.length; i++) {
if (i % 4 == bit) {
// 只處理目標通道
if (newData[i + offset] === 0 && (oData[i] % 2 === 1)) {
// 沒有信息的像素,該通道最低位置0,但不要越界
if (oData[i] === 255) {
oData[i]--;
} else {
oData[i]++;
}
} else if (newData[i + offset] !== 0 && (oData[i] % 2 === 0)) {
// // 有信息的像素,該通道最低位置1,可以想想上面的斑點效果是怎么實現的
oData[i]++;
}
}
}
ctx.putImageData(originalData, 0, 0);
}
復制代碼
主要的思路還是我一開始所講的,在有像素信息的點,將 R 偶數的通道+1。在沒有像素點的地方將 R 通道轉化成偶數,最后在 img.onload
調用 processData(ctx, originalData)
。
img.onload = function () {
// 獲取指定區域的canvas像素信息
ctx.drawImage(img, 0, 0);
originalData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
console.log(originalData)
processData(ctx, originalData)
};
復制代碼
以上方法就是一種比較簡單的加密方式。以上代碼都放到了倉庫 watermark/demo/canvas-dark-watermark.html
路徑下,方法都封裝好了~。
但是實際過程需要更專業的加密方式,例如利用傅里葉變化公式,來進行頻域制定數字盲水印,這里就不詳細展開講了,以后研究完再詳細講~
破解實踐
聽完上述的介紹,那么某設計網站是不是很有可能使用了暗水印呢?
當然啦,通過我對某設計網站的分析,我分析了以下幾種情況,我們一一來進行測試。
我們先通過免費下載的圖片來進行分析。打開 www.xxx.com/design?id=1…
通過實驗(實驗主要是去分析他各個場景下觸發的請求),發現在下載免費圖片的時候,發現它都會去向阿里雲發送一個 POST 請求,這熟悉的請求域名以及熟悉的數據封裝方式,這不就是 阿里雲 OSS 客戶端上傳方式嘛。這就好辦了,我們去查詢一下阿里雲是否有生成暗水印的相關方式,從而來看看某設計網站是否含有暗水印。很快我們就從官方文檔搜索到了相關的文檔,且對於低 QPS 是免費的。(這就是最好理解的連帶效應,例如我們覺得耐克阿迪啥賣運動類服飾,你買了他的鞋子,可能還會想買他的衣服)
const { RPCClient } = require("@alicloud/pop-core");
var client = new RPCClient({
endpoint: "http://imm.cn-shenzhen.aliyuncs.com",
accessKeyId: 'xxx',
accessKeySecret: 'xxx',
apiVersion: "2017-09-06",
});
(async () => {
try {
var params = {
Project: "test-project",
ImageUri: "oss://watermark-shenzheng/source/20201009-182331-fd5a.png",
TargetUri: "oss://watermark-shenzheng/dist/20201009-182331-fd5a-out.jpg",
Model: "DWT"
};
var result = await client.request("DecodeBlindWatermark", params);
console.log(result);
} catch (err) {
console.log(err);
}
})()
復制代碼
我們寫了一個demo進行了測試。由於阿里雲含有多種暗水印加密方式,為啥我使用了 DWT
呢?因為其他幾種都需要原圖,而我們剛才的測試,他上傳只會上傳一個文件到 OSS ,因此大致上排除了需要原圖的方案。
但是我們的結果卻沒有發現任何加密的跡象。
為什么我們會去猜想阿里雲的圖片暗水印的方式?因為從上傳的角度來考慮,我們上傳的圖片 key 的地址即是我們下載的圖片,也就是現在存在兩種情況,一就是通過阿里雲的盲水印方案,另一種就是上傳前進行了水印的植入。現在看來不是阿里雲水印的方案,那么只是能是上傳前就有了水印。
這個過程就有兩種情況,一是生成的過程中加入的水印,前端加入的水印。二是物料圖含有水印。
對於第一種情況,我們可以通過 dom-to-image
這個庫,在前端直接進行下載,或者使用截圖的方式。目前通過直接下載和通過站點內生成,發現元素略有不同。
第一個為我通過 dom-to-image
的方式下載,第二種為站點內下載,明顯大了一些。(有點懷疑他在圖片生成中可能做了什么手腳)
但是感覺前端加密的方式比較容易破解,最壞的情況想到了對素材進行了加密,但是這樣的話就無從破解了(但是查閱了一些資料,由於某設計稿網站站點素材大多是透明背景的,這種加密效果可能會弱一些,以后牛逼了再來補充)。目前這一塊暫時還不清楚,探究止步於此了。
攻擊實驗
那如果一張圖經過暗水印加密,他的抵抗攻擊性又是如何呢?
這是一張通過阿里雲 DWT
暗水印進行的加密,解密后的樣子為"秋風"字樣,我們分別來測試一下。
加一些元素
結果: 識別效果不錯
截圖
結果: 識別效果不錯
大小變化
結果:識別效果不錯
加蒙層
結果: 直接就拉胯了。
可見,暗水印的抵抗攻擊性還是蠻強的,是一種比較好的抵御攻擊的方式~
最后
以上僅僅為技術交流~ 大家不要在實際的場景盲目使用,使用正規的途徑 ~ 或者期待一下我接下來想搞的這個個人免費首圖生成器~ 喜歡文章的小伙伴可以點個贊哦 ~ 歡迎關注公眾號 秋風的筆記 ,學習前端不迷路。
參考
imm.console.aliyun.com/cn-shenzhen…