圖片裁剪原理
寫在最前面(不看也不會少一個億)
最開始的一個小需求
前兩天項目中有個小需求:前端下載后臺小哥返回的二進制流文件。
起初接到這個需求時,我感覺這很簡單啊(雖然我不會,但可以百度啊,,,,)
然后就寫出了如下的代碼:
let?blob?=?new?Blob([res.data]);
let?fileName?=?`Cosen.csv`;
if?(window.navigator.msSaveOrOpenBlob)?{
??navigator.msSaveBlob(blob,?fileName);
}?else?{
??let?link?=?document.createElement("a");
??let?evt?=?document.createEvent("HTMLEvents");
??evt.initEvent("click",?false,?false);
??link.href?=?URL.createObjectURL(blob);
??link.download?=?fileName;
??link.style.display?=?"none";
??document.body.appendChild(link);
??link.click();
??window.URL.revokeObjectURL(link.href);
}
這一段代碼,我大概強行解釋一下:
首先判斷window.navigator.msSaveOrOpenBlob是為了兼容IE(誰要兼容這 xxIE!!)
然后非IE的通過URL.createObjectURL()將Blob(Blob是啥?不知道?沒關(guān)系,我下面會具體裝逼講解的)構(gòu)建為一個object URL對象、指定文件名&文件類型、創(chuàng)建a鏈接模擬點擊實現(xiàn)下載,最后通過URL.revokeObjectURL釋放創(chuàng)建的對象。
功能雖然實現(xiàn)了,但其實我是似懂非懂的~

緊接著 一個不那么簡單的需求
沒過幾天,產(chǎn)品又給我提了一個需求:圖片裁剪上傳及預(yù)覽。
雖然聽過類似的需求,但自己手寫還真的沒寫過,然后我就開始了網(wǎng)上沖浪時光(各種搜索,,,)。但這次,沒有想象中那么簡單了~~
網(wǎng)上看到的都是諸如FileReader、canvas、ArrayBuffer、FormData、Blob這些名詞。我徹底懵了,這些平時都只是聽過啊,用的也不多啊。經(jīng)過了一番學習,我發(fā)現(xiàn)這些都屬于前端二進制的知識范疇,所以在搞業(yè)務(wù)前,我準備先把涉及到的前端二進制梳理一遍,正所謂:底層基礎(chǔ)決定上層建筑嘛 ?
FileReader
HTML5定義了FileReader作為文件API的重要成員用于讀取文件,根據(jù)W3C的定義,FileReader接口提供了讀取文件的方法和包含讀取結(jié)果的事件模型。
創(chuàng)建實例
var?reader?=?new?FileReader();
方法
| 方法名 | 描述 |
|---|---|
abort | 中止讀取操作 |
readAsArrayBuffer | 異步按字節(jié)讀取文件內(nèi)容,結(jié)果用 ArrayBuffer 對象表示 |
readAsBinaryString | 異步按字節(jié)讀取文件內(nèi)容,結(jié)果為文件的二進制串 |
readAsDataURL | 異步讀取文件內(nèi)容,結(jié)果用 data:url 的字符串形式表示 |
readAsText | 異步按字符讀取文件內(nèi)容,結(jié)果用字符串形式表示 |
事件
| 事件名 | 描述 |
|---|---|
onabort | 中斷時觸發(fā) |
onerror | 出錯時觸發(fā) |
onload | 文件讀取成功完成時觸發(fā) |
onloadend | 讀取完成觸發(fā)(無論成功或失敗) |
onloadstart | 讀取開始時觸發(fā) |
onprogress | 讀取中 |
示例
下面我們嘗試把一個文件的內(nèi)容通過字符串的方式讀取出來:
"file"?id='upload'?/>
document.getElementById('upload').addEventListener('change',?function?(e)?{
????var?file?=?this.files[0];
????const?reader?=?new?FileReader();
????reader.onload?=?function?()?{
????????const?result?=?reader.result;
????????console.log(result);
????}
????reader.readAsText(file);
},?false);
ArrayBuffer/TypedArray/DataView 對象
ArrayBuffer
先來看下ArrayBuffer的功能:

先來介紹ArrayBuffer ,是因為 FileReader 有個 readAsArrayBuffer()的方法,如果被讀的文件是二進制數(shù)據(jù),那用這個方法去讀應(yīng)該是最合適的,讀出來的數(shù)據(jù),就是一個 Arraybuffer 對象,來看下定義:
ArrayBuffer對象用來表示通用的、固定長度的原始二進制數(shù)據(jù)緩沖區(qū).ArrayBuffer不能直接操作,而是要通過類型數(shù)組對象或DataView對象來操作,它們會將緩沖區(qū)中的數(shù)據(jù)表示為特定的格式,并通過這些格式來讀寫緩沖區(qū)的內(nèi)容.
ArrayBuffer也是一個構(gòu)造函數(shù),可以分配一段可以存放數(shù)據(jù)的連續(xù)內(nèi)存區(qū)域。
const?buffer?=?new?ArrayBuffer(8);
//?ArrayBuffer?對象有實例屬性?byteLength?,表示當前實例占用的內(nèi)存字節(jié)長度(單位字節(jié))
console.log(buffer.byteLength);
由于無法對 Arraybuffer 直接進行操作,所以我們需要借助其他對象來操作. 所有就有了 TypedArray(類型數(shù)組對象)和 DataView對象。
DataView 對象
上面代碼生成了一段 8 字節(jié)的內(nèi)存區(qū)域,每個字節(jié)的值默認都是 0。
為了讀寫這段內(nèi)容,需要為它指定視圖。DataView視圖的創(chuàng)建,需要提供ArrayBuffer對象實例作為參數(shù)。
DataView視圖是一個可以從二進制ArrayBuffer對象中讀寫多種數(shù)值類型的底層接口。
setint8()從DataView起始位置以byte為計數(shù)的指定偏移量(byteOffset)處存儲一個8-bit數(shù)(一個字節(jié))getint8()從DataView起始位置以byte為計數(shù)的指定偏移量(byteOffset)處獲取一個8-bit數(shù)(一個字節(jié))
調(diào)用
new?DataView(buffer,?[,?byteOffset?[,?byteLength]])
示例
let?buffer?=?new?ArrayBuffer(2);
console.log(buffer.byteLength);?//?2
let?dataView?=?new?DataView(buffer);
dataView.setInt(0,?1);
dataView.setInt(1,?2);
console.log(dataView.getInt8(0));?//?1
console.log(dataView.getInt8(1));?//?2
console.log(dataView.getInt16(0));?//?258

TypedArray
另一種TypedArray視圖,與DataView視圖的一個區(qū)別是,它不是一個構(gòu)造函數(shù),而是一組構(gòu)造函數(shù),代表不同的數(shù)據(jù)格式。
TypedArray對象描述了一個底層的二進制數(shù)據(jù)緩存區(qū)(binary data buffer)的一個類數(shù)組視圖(view)。
但它本身不可以被實例化,甚至無法訪問,你可以把它理解為接口,它有很多的實現(xiàn)。
實現(xiàn)方法
| 類型 | 單個元素值的范圍 | 大小(bytes) | 描述 |
|---|---|---|---|
| Int8Array | -128 to 127 | 1 | 8 位二進制有符號整數(shù) |
| Uint8Array | 0 to 255 | 1 | 8 位無符號整數(shù) |
| Int16Array | -32768 to 32767 | 2 | 16 位二進制有符號整數(shù) |
| Uint16Array | 0 to 65535 | 2 | 16 位無符號整數(shù) |
示例
const?buffer?=?new?ArrayBuffer(8);
console.log(buffer.byteLength);?//?8
const?int8Array?=?new?Int8Array(buffer);
console.log(int8Array.length);?//?8
const?int16Array?=?new?Int16Array(buffer);
console.log(int16Array.length);?//?4
Blob
Blob是用來支持文件操作的。簡單的說:在JS中,有兩個構(gòu)造函數(shù) File 和 Blob, 而File繼承了所有Blob的屬性。
所以在我們看來,File對象可以看作一種特殊的Blob對象。
上面說了,File對象是一種特殊的Blob對象,那么它自然就可以直接調(diào)用Blob對象的方法。讓我們看一看Blob具體有哪些方法,以及能夠用它們實現(xiàn)哪些功能:

是的,我們這里更加傾向于實戰(zhàn)中的應(yīng)用~

關(guān)于Blob的更具體介紹可以參考Blob[1]
atob 和 btoa
base64 相信大家都不會陌生吧(不知道的看這里[2]),最常用的操作可能就是圖片轉(zhuǎn) base64 了吧?
在之前要在字符串跟base64之間互轉(zhuǎn),我們可能需要去網(wǎng)上拷一個別人的方法,而且大部分情況下,你沒有時間去驗證這個方法是不是真的可靠,有沒有bug。
從IE10+瀏覽器開始,所有瀏覽器就原生提供了Base64編碼解碼方法。
Base64 解碼
var?decodedData?=?window.atob(encodedData);
Base64 編碼
var?encodedData?=?window.btoa(stringToEncode);
Canvas中的ImageData對象
關(guān)于Canvas,這里我就不做過多介紹了,具體可參考canvas 文檔[3]
今天主要說一下Canvas中的ImageData對象(也是為下面的那個圖片裁剪的項目做一些基礎(chǔ)知識的鋪墊~)

ImageData對象中存儲著canvas對象真實的像素數(shù)據(jù),它包含以下幾個只讀屬性:
width:圖片寬度,單位是像素height:圖片高度,單位是像素data:Uint8ClampedArray類型的一維數(shù)組,包含著RGBA格式的整型數(shù)據(jù),范圍在 0 至 255 之間(包括 255)。
創(chuàng)建一個ImageData對象
使用createImageData() 方法去創(chuàng)建一個新的,空白的ImageData對象。
var?myImageData?=?ctx.createImageData(width,?height);
上面代碼創(chuàng)建了一個新的具體特定尺寸的ImageData對象。所有像素被預(yù)設(shè)為透明黑。
得到場景像素數(shù)據(jù)
為了獲得一個包含畫布場景像素數(shù)據(jù)的ImageData對象,你可以用getImageData()方法:
var?myImageData?=?ctx.getImageData(left,?top,?width,?height);
在場景中寫入像素數(shù)據(jù)
你可以用putImageData()方法去對場景進行像素數(shù)據(jù)的寫入。
ctx.putImageData(myImageData,?dx,?dy);
toDataURL 將canvas轉(zhuǎn)為 data URI格式
有如下元素:
<canvas?id="canvas"?width="5"?height="5">canvas>
可以用下面的方式獲取一個data-URL
var?canvas?=?document.getElementById("canvas");
var?dataURL?=?canvas.toDataURL();
console.log(dataURL);
//?"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNby
//?blAAAADElEQVQImWNgoBMAAABpAAFEI8ARAAAAAElFTkSuQmCC"
到這里,二進制相關(guān)的基礎(chǔ)知識我已經(jīng)鋪墊完了。下面讓我們回到文章開頭提到的那個產(chǎn)品的“沒那么簡單”的新需求:圖片裁剪上傳及預(yù)覽。
其實,像圖片裁剪上傳這種社區(qū)已經(jīng)有非常成熟的解決方案了,如vue-cropper[4]。這里,我選擇手寫一個簡易的圖片裁剪的目的是因為這其中用到了上文提及的大量的二進制知識,可以很好的將理論與實踐結(jié)合。
話不多說,開 Giao!!

需求開發(fā) Giao Giao!
先來看下最終的效果:

這里貼下完成后的代碼地址[5]
另外,我用一張圖梳理了以上提到的前端二進制模塊的關(guān)系,這對于下面需求的開發(fā)會有很大的幫助:

整個需求分以下四步:
1、獲取文件并讀取文件。
2、獲取裁剪坐標。
3、裁剪圖片。
4、讀取裁剪后的圖片預(yù)覽并上傳。
獲取文件并讀取文件
首先來看下上面第一步提到的獲取文件。對應(yīng)就是給input綁定的handleChange事件:
handleChange?=?(event)?=>?{
??let?file?=?event.target.files[0];
??let?fileReader?=?new?FileReader();
??fileReader.onload?=?(event)?=>?{
????this.setState({
??????file,
??????dataURL:?event.target.result,
????});
????this.imageRef.current.onload?=?()?=>?this.drawImage();
??};
??fileReader.readAsDataURL(file);
};
HTML5 支持從 input[type=file] 元素中直接獲取文件信息,也可以讀取文件內(nèi)容。
這里就需要用到了 FileReader ,這個類是專門用來讀取本地文件的。純文本或者二進制都可以讀取,但是本地文件必須是經(jīng)過用戶允許才能讀取,也就是說用戶要在input[type=file]中選擇了這個文件,你才能讀取到它。
通過 FileReader 我們可以將圖片文件轉(zhuǎn)化成 DataURL,就是以 data:image/png;base64開頭的一種URL,然后可以直接放在 image.src 里,這樣本地圖片就顯示出來了。
獲取裁剪坐標
這里主要是mousedown、mousemove、mouseup事件的結(jié)合使用。
mousedown
鼠標按下事件。這里要記錄下鼠標按下時的開始坐標,即startX與startY,同時要將標志位startDrag設(shè)為true,標識鼠標開始移動。
handleMouseDown?=?(event)?=>?{
??this.setState({
????startX:?event.clientX,
????startY:?event.clientY,
????startDrag:?true,
??});
};
mousemove
鼠標移動事件。判斷startDrag為true(即鼠標開始移動),然后記錄對應(yīng)移動的距離。
handleMouseMove?=?(event)?=>?{
??if?(this.state.startDrag)?{
????this.drawImage(
??????event.clientX?-?this.state.startX?+?this.state.lastX,
??????event.clientY?-?this.state.startY?+?this.state.lastY
????);
??}
};
mouseup
鼠標彈起事件。這里要記錄下最終鼠標的落點坐標,對應(yīng)就是lastX與lastY。
handleMouseUp?=?(event)?=>?{
??this.setState({
????lastX:?event.clientX?-?this.state.startX?+?this.state.lastX,
????lastY:?event.clientY?-?this.state.startY?+?this.state.lastY,
????startDrag:?false,
??});
};
裁剪圖片
這個時候我們就需要用到canvas了,canvas和圖片一樣,所以新建canvas時就要確定其高寬。
將圖片放置入canvas時需要調(diào)用drawImage:
drawImage(image,?sx,?sy,?sWidth,?sHeight,?dx,?dy,?dWidth,?dHeight)
具體API使用參考MDN上的drawImage[6]
drawImage?=?(left?=?this.state.lastX,?top?=?this.state.lastY)?=>?{
??let?image?=?this.imageRef.current;
??let?canvas?=?this.canvasRef.current;
??let?ctx?=?canvas.getContext("2d");
??ctx.clearRect(0,?0,?canvas.width,?canvas.height);
??let?imageWidth?=?image.width;
??let?imageHeight?=?image.height;
??if?(imageWidth?>?imageHeight)?{
????let?scale?=?canvas.width?/?canvas.height;
????imageWidth?=?canvas.width?*?this.state.times;
????imageHeight?=?imageHeight?*?scale?*?this.state.times;
??}?else?{
????let?scale?=?canvas.height?/?canvas.width;
????imageHeight?=?canvas.height?*?this.state.times;
????imageWidth?=?imageWidth?*?scale?*?this.state.times;
??}
??ctx.drawImage(
????image,
????(canvas.width?-?imageWidth)?/?2?+?left,
????(canvas.height?-?imageHeight)?/?2?+?top,
????imageWidth,
????imageHeight
??);
};
其中這里面我們還加入了scale,這個變量是用來實現(xiàn)圖片放大、縮小效果的。
而且會判斷圖片的寬、高的大小關(guān)系,從而實現(xiàn)圖片在canvas中對應(yīng)的適配。
讀取裁剪后的圖片并上傳
這時我們要獲取canvas中圖片的信息,用toDataURL就可以轉(zhuǎn)換成上面用到的DataURL。
confirm?=?()?=>?{
??let?canvas?=?this.canvasRef.current;
??let?ctx?=?canvas.getContext("2d");
??const?imageData?=?ctx.getImageData(100,?100,?100,?100);
??let?avatarCanvas?=?document.createElement("canvas");
??avatarCanvas.width?=?100;
??avatarCanvas.height?=?100;
??let?avatarCtx?=?avatarCanvas.getContext("2d");
??avatarCtx.putImageData(imageData,?0,?0);
??let?avatarDataUrl?=?avatarCanvas.toDataURL();
??this.setState({?avatarDataUrl?});
??this.avatarRef.current.src?=?avatarDataUrl;
};
然后取出其中base64信息,再用window.atob轉(zhuǎn)換成由二進制字符串。但window.atob轉(zhuǎn)換后的結(jié)果仍然是字符串,直接給Blob還是會出錯。所以又要用Uint8Array轉(zhuǎn)換一下。
這時候裁剪后的文件就儲存在blob里了,我們可以把它當作是普通文件一樣,加入到FormData里,并上傳至服務(wù)器了。
upload?=?(event)?=>?{
??//?console.log("文件url",?this.state.avatarDataUrl);
??let?bytes?=?atob(this.state.avatarDataUrl.split(",")[1]);
??console.log("bytes",?bytes);
??let?arrayBuffer?=?new?ArrayBuffer(bytes.length);
??let?uInt8Array?=?new?Uint8Array();
??for?(let?i?=?0;?i?????uInt8Array[i]?=?bytes.charCodeAt[i];
??}
??let?blob?=?new?Blob([arrayBuffer],?{?type:?"image/png"?});
??let?xhr?=?new?XMLHttpRequest();
??let?formData?=?new?FormData();
??formData.append("avatar",?blob);
??xhr.open("POST",?"/upload",?true);
??xhr.send(formData);
};

參考
https://es6.ruanyifeng.com/#docs/arraybufferhttps://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial/Pixel_manipulation_with_canvas
