摘要:的異步完成整個異步環節的有事件循環觀察者請求對象以及線程池。執行回調組裝好請求對象送入線程池等待執行,實際上是完成了異步的第一部分,回調通知是第二部分。異步編程是首個將異步大規模帶到應用層面的平臺。
本文首發在個人博客:http://muyunyun.cn/posts/7b9fdc87/
提到 Node.js, 我們腦海就會浮現異步、非阻塞、單線程等關鍵詞,進一步我們還會想到 buffer、模塊機制、事件循環、進程、V8、libuv 等知識點。本文起初旨在理順 Node.js 以上易混淆概念,然而一入異步深似海,本文嘗試基于 Node.js 的異步展開討論,其他的主題只能日后慢慢補上了。(附:亦可以把本文當作是樸靈老師所著的《深入淺出 Node.js》一書的小結)。
異步 I/0Node.js 正是依靠構建了一套完善的高性能異步 I/0 框架,從而打破了 JavaScript 在服務器端止步不前的局面。
異步 I/0 VS 非阻塞 I/0聽起來異步和非阻塞,同步和阻塞是相互對應的,從實際效果而言,異步和非阻塞都達到了我們并行 I/0 的目的,但是從計算機內核 I/0 而言,異步/同步和阻塞/非阻塞實際上是兩回事。
注意,操作系統內核對于 I/0 只有兩種方式:阻塞與非阻塞。
調用阻塞 I/0 的過程:
調用非阻塞 I/0 的過程:
在此先引人一個叫作輪詢的技術。輪詢不同于回調,舉個生活例子,你有事去隔壁寢室找同學,發現人不在,你怎么辦呢?方法1,每隔幾分鐘再去趟隔壁寢室,看人在不;方法2,拜托與他同寢室的人,看到他回來時叫一下你;那么前者是輪詢,后者是回調。
再回到主題,阻塞 I/0 造成 CPU 等待浪費,非阻塞 I/0 帶來的麻煩卻是需要輪詢去確認是否完全完成數據獲取。從操作系統的這個層面上看,對于應用程序而言,不管是阻塞 I/0 亦或是 非阻塞 I/0,它們都只能是一種同步,因為盡管使用了輪詢技術,應用程序仍然需要等待 I/0 完全返回。
Node 的異步 I/0完成整個異步 I/O 環節的有事件循環、觀察者、請求對象以及 I/0 線程池。
事件循環在進程啟動的時候,Node 會創建一個類似于 whlie(true) 的循環,每一次執行循環體的過程我們稱為 Tick。
每個 Tick 的過程就是查看是否有事件待處理,如果有,就取出事件及其相關的回調函數。如果存在相關的回調函數,就執行他們。然后進入下一個循環,如果不再有事件處理,就退出進程。
偽代碼如下:
while(ture) { const event = eventQueue.pop() if (event && event.handler) { event.handler.execute() // execute the callback in Javascript thread } else { sleep() // sleep some time to release the CPU do other stuff } }觀察者
每個 Tick 的過程中,如何判斷是否有事件需要處理,這里就需要引入觀察者這個概念。
每個事件循環中有一個或多個觀察者,而判斷是否有事件需要處理的過程就是向這些觀察者詢問是否有要處理的事件。
在 Node 中,事件主要來源于網絡請求、文件 I/O 等,這些事件都有對應的觀察者。
請求對象對于 Node 中的異步 I/O 而言,回調函數不由開發者來調用,在 JavaScript 發起調用到內核執行完 id 操作的過渡過程中,存在一種中間產物,它叫作請求對象。
請求對象是異步 I/O 過程中的重要中間產物,所有狀態都保存在這個對象中,包括送入線程池等待執行以及 I/O 操作完后的回調處理
以 fs.open() 為例:
fs.open = function(path, flags, mode, callback) { bingding.open( pathModule._makeLong(path), stringToFlags(flags), mode, callback ) }
fs.open 的作用就是根據指定路徑和參數去打開一個文件,從而得到一個文件描述符。
從前面的代碼中可以看到,JavaScript 層面的代碼通過調用 C++ 核心模塊進行下層的操作。
從 JavaScript 調用 Node 的核心模塊,核心模塊調用 C++ 內建模塊,內建模塊通過 libuv 進行系統調用,這是 Node 里經典的調用方式。
libuv 作為封裝層,有兩個平臺的實現,實質上是調用了 uv_fs_open 方法,在 uv_fs_open 的調用過程中,會創建一個 FSReqWrap 請求對象,從 JavaScript 層傳入的參數和當前方法都被封裝在這個請求對象中。回調函數則被設置在這個對象的 oncomplete_sym 屬性上。
req_wrap -> object_ -> Set(oncomplete_sym, callback)
對象包裝完畢后,在 Windows 下,則調用 QueueUserWorkItem() 方法將這個 FSReqWrap 對象推人線程池中等待執行。
至此,JavaScript 調用立即返回,由 JavaScript 層面發起的異步調用的第一階段就此結束(即上圖所注釋的異步 I/0 第一部分)。JavaScript 線程可以繼續執行當前任務的后續操作,當前的 I/O 操作在線程池中等待執行,不管它是否阻塞 I/O,都不會影響到 JavaScript 線程的后續操作,如此達到了異步的目的。
執行回調組裝好請求對象、送入 I/O 線程池等待執行,實際上是完成了異步 I/O 的第一部分,回調通知是第二部分。
線程池中的 I/O 操作調用完畢之后,會將獲取的結果儲存在 req -> result 屬性上,然后調用 PostQueuedCompletionStatus() 通知 IOCP,告知當前對象操作已經完成,并將線程歸還線程池。
在這個過程中,我們動用了事件循環的 I/O 觀察者,在每次 Tick 的執行過程中,它會調用 IOCP 相關的 GetQueuedCompletionStatus 方法檢查線程池中是否有執行完的請求,如果存在,會將請求對象加入到 I/O 觀察者的隊列中,然后將其當做事件處理。
I/O 觀察者回調函數的行為就是取出請求對象的 result 屬性作為參數,取出 oncomplete_sym 屬性作為方法,然后調用執行,以此達到調用 JavaScript 中傳入的回調函數的目的。
小結通過介紹完整個異步 I/0 后,有個需要重視的觀點是 JavaScript 是單線程的,Node 本身其實是多線程的,只是 I/0 線程使用的 CPU 比較少;還有個重要的觀點是,除了用戶的代碼無法并行執行外,所有的 I/0 (磁盤 I/0 和網絡 I/0) 則是可以并行起來的。
異步編程Node 是首個將異步大規模帶到應用層面的平臺。通過上文所述我們了解了 Node 如何通過事件循環實現異步 I/0,有異步 I/0 必然存在異步編程。異步編程的路經歷了太多坎坷,從回調函數、發布訂閱模式、Promise 對象,到 generator、asycn/await。趁著異步編程這個主題剛好把它們串起來理理。
異步 VS 回調對于剛接觸異步的新人,很大幾率會混淆回調 (callback) 和異步 (asynchronous) 的概念。先來看看維基的 Callback) 條目:
In computer programming, a callback is any executable code that is passed as an argument to other code
因此,回調本質上是一種設計模式,并且 jQuery (包括其他框架)的設計原則遵循了這個模式。
在 JavaScript 中,回調函數具體的定義為:函數 A 作為參數(函數引用)傳遞到另一個函數 B 中,并且這個函數 B 執行函數 A。我們就說函數 A 叫做回調函數。如果沒有名稱(函數表達式),就叫做匿名回調函數。
因此 callback 不一定用于異步,一般同步(阻塞)的場景下也經常用到回調,比如要求執行某些操作后執行回調函數。講了這么多讓我們來看下同步回調和異步回調的例子:
同步回調:
function f2() { console.log("f2 finished") } function f1(cb) { cb() console.log("f1 finished") } f1(f2) // 得到的結果是 f2 finished, f1 finished
異步回調:
function f2() { console.log("f2 finished") } function f1(cb) { setTimeout(cb, 1000) // 通過 setTimeout() 來模擬耗時操作 console.log("f1 finished") } f1(f2) // 得到的結果是 f1 finished, f2 finished
小結:回調可以進行同步也可以異步調用,但是 Node.js 提供的 API 大多都是異步回調的,比如 buffer、http、cluster 等模塊。
發布/訂閱模式事件發布/訂閱模式 (PubSub) 自身并無同步和異步調用的問題,但在 Node 的 events 模塊的調用中多半伴隨事件循環而異步觸發的,所以我們說事件發布/訂閱廣泛應用于異步編程。它的應用非常廣泛,可以在異步編程中幫助我們完成更松的解耦,甚至在 MVC、MVVC 的架構中以及設計模式中也少不了發布-訂閱模式的參與。
以 jQuery 事件監聽為例
$("#btn").on("myEvent", function(e) { // 觸發事件 console.log("I am an Event") }) $("#btn").trigger("myEvent") // 訂閱事件
可以看到,訂閱事件就是一個高階函數的應用。事件發布/訂閱模式可以實現一個事件與多個回調函數的關聯,這些回調函數又稱為事件偵聽器。下面我們來看看發布/訂閱模式的簡易實現。
var PubSub = function() { this.handlers = {} } PubSub.prototype.subscribe = function(eventType, handler) { // 注冊函數邏輯 if (!(eventType in this.handlers)) { this.handlers[eventType] = [] } this.handlers[eventType].push(handler) // 添加事件監聽器 return this // 返回上下文環境以實現鏈式調用 } PubSub.prototype.publish = function(eventType) { // 發布函數邏輯 var _args = Array.prototype.slice.call(arguments, 1) for (var i = 0, _handlers = this.handlers[eventType]; i < _handlers.length; i++) { // 遍歷事件監聽器 _handlers[i].apply(this, _args) // 調用事件監聽器 } } var event = new PubSub // 構造 PubSub 實例 event.subscribe("name", function(msg) { console.log("my name is " + msg) // my name is muyy }) event.publish("name", "muyy")
至此,一個簡易的訂閱發布模式就實現了。然而發布/訂閱模式也存在一些缺點,創建訂閱本身會消耗一定的時間與內存,也許當你訂閱一個消息之后,之后可能就不會發生。發布-訂閱模式雖然它弱化了對象與對象之間的關系,但是如果過度使用,對象與對象的必要聯系就會被深埋,會導致程序難以跟蹤與維護。
Promise/Deferred 模式想象一下,如果某個操作需要經過多個非阻塞的 IO 操作,每一個結果都是通過回調,程序有可能會看上去像這個樣子。這樣的代碼很難維護。這樣的情況更多的會發生在 server side 的情況下。代碼片段如下:
operation1(function(err, result1) { operation2(result1, function(err, result2) { operation3(result2, function(err, result3) { operation4(result3, function(err, result4) { callback(result4) // do something useful }) }) }) })
這時候,Promise 出現了,其出現的目的就是為了解決所謂的回調地獄的問題。讓我們看下使用 Promise 后的代碼片段:
promise() .then(operation1) .then(operation2) .then(operation3) .then(operation4) .then(function(value4) { // Do something with value4 }, function (error) { // Handle any error from step1 through step4 }) .done()
可以看到,使用了第二種編程模式后能極大地提高我們的編程體驗,接著就讓我們自己動手實現一個支持序列執行的 Promise。(附:為了直觀的在瀏覽器上也能感受到 Promise,為此也寫了一段瀏覽器上的 Promise 用法示例)
在此之前,我們先要了解 Promise/A 提議中對單個異步操作所作的抽象定義,定義具體如下所示:
Promise 操作只會處在 3 種狀態的一種:未完成態、完成態和失敗態。
Promise 的狀態只會出現從未完成態向完成態或失敗態轉化,不能逆反。完成態和失敗態不能相互轉化。
Promise 的狀態一旦轉化,將不能被更改。
Promise 的狀態轉化示意圖如下:
除此之外,Promise 對象的另一個關鍵就是需要具備 then() 方法,對于 then() 方法,有以下簡單的要求:
接受完成態、錯誤態的回調方法。在操作完成或出現錯誤時,將會調用對應方法。
可選地支持 progress 事件回調作為第三個方法。
then() 方法只接受 function 對象,其余對象將被忽略。
then() 方法繼續返回 Promise 對象,已實現鏈式調用。
then() 方法的定義如下:
then(fulfilledHandler, errorHandler, progressHandler)
有了這些核心知識,接著進入 Promise/Deferred 核心代碼環節:
var Promise = function() { // 構建 Promise 對象 // 隊列用于存儲執行的回調函數 this.queue = [] this.isPromise = true } Promise.prototype.then = function (fulfilledHandler, errorHandler, progressHandler) { // 構建 Progress 的 then 方法 var handler = {} if (typeof fulfilledHandler === "function") { handler.fulfilled = fulfilledHandler } if (typeof errorHandler === "function") { handler.error = errorHandler } this.queue.push(handler) return this }
如上 Promise 的代碼就完成了,但是別忘了 Promise/Deferred 中的后者 Deferred,為了完成 Promise 的整個流程,我們還需要觸發執行上述回調函數的地方,實現這些功能的對象就叫作 Deferred,即延遲對象。
Promise 和 Deferred 的整體關系如下圖所示,從中可知,Deferred 主要用于內部來維護異步模型的狀態;而 Promise 則作用于外部,通過 then() 方法暴露給外部以添加自定義邏輯。
接著來看 Deferred 代碼部分的實現:
var Deferred = function() { this.promise = new Promise() } // 完成態 Deferred.prototype.resolve = function(obj) { var promise = this.promise var handler while(handler = promise.queue.shift()) { if (handler && handler.fulfilled) { var ret = handler.fulfilled(obj) if (ret && ret.isPromise) { // 這一行以及后面3行的意思是:一旦檢測到返回了新的 Promise 對象,停止執行,然后將當前 Deferred 對象的 promise 引用改變為新的 Promise 對象,并將隊列中余下的回調轉交給它 ret.queue = promise.queue this.promise = ret return } } } } // 失敗態 Deferred.prototype.reject = function(err) { var promise = this.promise var handler while (handler = promise.queue.shift()) { if (handler && handler.error) { var ret = handler.error(err) if (ret && ret.isPromise) { ret.queue = promise.queue this.promise = ret return } } } } // 生成回調函數 Deferred.prototype.callback = function() { var that = this return function(err, file) { if(err) { return that.reject(err) } that.resolve(file) } }
接著我們以兩次文件讀取作為例子,來驗證該設計的可行性。這里假設第二個文件讀取依賴于第一個文件中的內容,相關代碼如下:
var readFile1 = function(file, encoding) { var deferred = new Deferred() fs.readFile(file, encoding, deferred.callback()) return deferred.promise } var readFile2 = function(file, encoding) { var deferred = new Deferred() fs.readFile(file, encoding, deferred.callback()) return deferred.promise } readFile1("./file1.txt", "utf8").then(function(file1) { // 這里通過 then 把兩個回調存進隊列中 return readFile2(file1, "utf8") }).then(function(file2) { console.log(file2) // I am file2. })
最后可以看到控制臺輸出 I am file2,驗證成功~,這個案例的完整代碼可以點這里查看,并建議使用 node-inspector 進行斷點觀察,(這段代碼里面有些邏輯確實很繞,通過斷點調試就能較容易理解了)。
從 Promise 鏈式調用可以清晰地看到隊列(先進先出)的知識,其有如下兩個核心步驟:
將所有的回調都存到隊列中;
Promise 完成時,逐個執行回調,一旦檢測到返回了新的 Promise 對象,停止執行,然后將當前 Deferred 對象的 promise 引用改變為新的 Promise 對象,并將隊列中余下的回調轉交給它;
至此,實現了 Promise/Deferred 的完整邏輯,Promise 的其他知識未來也會繼續探究。
Generator盡管 Promise 一定程度解決了回調地獄的問題,但是對于喜歡簡潔的程序員來說,一大堆的模板代碼 .then(data => {...}) 顯得不是很友好。所以愛折騰的開發者們在 ES6 中引人了 Generator 這種數據類型。仍然以讀取文件為例,先上一段非常簡潔的 Generator + co 的代碼:
co(function* () { const file1 = yield readFile("./file1.txt") const file2 = yield readFile("./file2.txt") console.log(file1) console.log(file2) })
可以看到比 Promise 的寫法簡潔了許多。后文會給出 co 庫的實現原理。在此之前,先歸納下什么是 Generator。可以把 Generator 理解為一個可以遍歷的狀態機,調用 next 就可以切換到下一個狀態,其最大特點就是可以交出函數的執行權(即暫停執行),讓我們看如下代碼:
function* gen(x) { yield (function() {return 1})() var y = yield x + 2 return y } // 調用方式一 var g = gen(1) g.next() // { value: 1, done: false } g.next() // { value: 3, done: false } g.next() // { value: undefined, done: true } // 調用方式二 var g = gen(1) g.next() // { value: 1, done: false } g.next() // { value: 3, done: false } g.next(10) // { value: 10, done: true }
由此我們歸納下 Generator 的基礎知識:
Generator 生成迭代器后,等待迭代器的 next() 指令啟動。
啟動迭代器后,代碼會運行到 yield 處停止。并返回一個 {value: AnyType, done: Boolean} 對象,value 是這次執行的結果,done 是迭代是否結束。并等待下一次的 next() 指令。
next() 再次啟動,若 done 的屬性不為 true,則可以繼續從上一次停止的地方繼續迭代。
一直重復 2,3 步驟,直到 done 為 true。
通過調用方式二,我們可看到 next 方法可以帶一個參數,該參數就會被當作上一個 yield 語句的返回值。
另外我們注意到,上述代碼中的第一種調用方式中的 y 值是 undefined,如果我們真想拿到 y 值,就需要通過 g.next(); g.next().value 這種方式取出。可以看出,Generator 函數將異步操作表示得很簡潔,但是流程管理卻不方便。這時候用于 Generator 函數的自動執行的 co 函數庫 登場了。為什么 co 可以自動執行 Generator 函數呢?我們知道,Generator 函數就是一個異步操作的容器。它的自動執行需要一種機制,當異步操作有了結果,能夠自動交回執行權。
兩種方法可以做到這一點:
Thunk 函數。將異步操作包裝成 Thunk 函數,在回調函數里面交回執行權。
Promise 對象。將異步操作包裝成 Promise 對象,用 then 方法交回執行權。
co 函數庫其實就是將兩種自動自動執行器(Thunk 函數和 Promise 對象),包裝成一個庫。使用 co 的前提條件是,Generator 函數的 yield 命令后面,只能是 Thunk 函數或者是 Promise 對象。下面分別用以上兩種方法對 co 進行一個簡單的實現。
基于 Thunk 函數的自動執行在 JavaScript 中,Thunk 函數就是指將多參數函數替換成單參數的形式,并且其只接受回調函數作為參數的函數。Thunk 函數的例子如下:
// 正常版本的 readFile(多參數) fs.readFile(filename, "utf8", callback) // Thunk 版本的 readFile(單參數) function readFile(filename) { return function(callback) { fs.readFile(filename, "utf8", callback); }; }
在基于 Thunk 函數和 Generator 的知識上,接著我們來看看 co 基于 Thunk 函數的實現。(附:代碼參考自co最簡版實現)
function co(generator) { return function(fn) { var gen = generator() function next(err, result) { if(err) { return fn(err) } var step = gen.next(result) if (!step.done) { step.value(next) // 這里可以把它聯想成遞歸;將異步操作包裝成 Thunk 函數,在回調函數里面交回執行權。 } else { fn(null, step.value) } } next() } }
用法如下:
co(function* () { // 把 function*() 作為參數 generator 傳入 co 函數 var file1 = yield readFile("./file1.txt") var file2 = yield readFile("./file2.txt") console.log(file1) // I"m file1 console.log(file2) // I"m file2 return "done" })(function(err, result) { // 這部分的 function 作為 co 函數內的 fn 的實參傳入 console.log(result) // done })
上述部分關鍵代碼已進行注釋,下面對 co 函數里的幾個難點進行說明:
var step = gen.next(result), 前文提到的一句話在這里就很有用處了:next方法可以帶一個參數,該參數就會被當作上一個yield語句的返回值;在上述代碼的運行中一共會經過這個地方 3 次,result 的值第一次是空值,第二次是 file1.txt 的內容 I"m file1,第三次是 file2.txt 的內容 I"m file2。根據上述關鍵語句的提醒,所以第二次的內容會作為 file1 的值(當作上一個yield語句的返回值),同理第三次的內容會作為 file2 的值。
另一處是 step.value(next), step.value 就是前面提到的 thunk 函數返回的 function(callback) {}, next 就是傳入 thunk 函數的 callback。這句代碼是條遞歸語句,是這個簡易版 co 函數能自動調用 Generator 的關鍵語句。
建議親自跑一遍代碼,多打斷點,從而更好地理解,代碼已上傳github。
基于 Promise 對象的自動執行基于 Thunk 函數的自動執行中,yield 后面需跟上 Thunk 函數,在基于 Promise 對象的自動執行中,yield 后面自然要跟 Promise 對象了,讓我們先構建一個 readFile 的
Promise 對象:
function readFile(fileName) { return new Promise(function(resolve, reject) { fs.readFile(fileName, function(error, data) { if (error) reject(error) resolve(data) }) }) }
在基于前文 Promise 對象和 Generator 的知識上,接著我們來看看 co 基于 Promise 函數的實現:
function co(generator) { var gen = generator() function next(data) { var result = gen.next(data) // 同上,經歷了 3 次,第一次是 undefined,第二次是 I"m file1,第三次是 I"m file2 if (result.done) return result.value result.value.then(function(data) { // 將異步操作包裝成 Promise 對象,用 then 方法交回執行權 next(data) }) } next() }
用法如下:
co(function* generator() { var file1 = yield readFile("./file1.txt") var file2 = yield readFile("./file2.txt") console.log(file1.toString()) // I"m file1 console.log(file2.toString()) // I"m file2 })
這一部分的代碼上傳在這里,通過觀察可以發現基于 Thunk 函數和基于 Promise 對象的自動執行方案的 co 函數設計思路幾乎一致,也因此呼應了它們共同的本質 —— 當異步操作有了結果,自動交回執行權。
async看上去 Generator 已經足夠好用了,但是使用 Generator 處理異步必須得依賴 tj/co,于是 asycn 出來了。本質上 async 函數就是 Generator 函數的語法糖,這樣說是因為 async 函數的實現,就是將 Generator 函數和自動執行器,包裝進一個函數中。偽代碼如下,(注:其中 automatic 的實現可以參考 async 函數的含義和用法中的實現)
async function fn(args){ // ... } // 等同于 function fn(args) { return automatic(function*() { // automatic 函數就是自動執行器,其的實現可以仿照 co 庫自動運行方案來實現,這里就不展開了 // ... }) }
接著仍然以上文的讀取文件為例,來比較 Generator 和 async 函數的寫法差異:
// Generator var genReadFile = co(function*() { var file1 = yield readFile("./file1.txt") var file2 = yield readFile("./file2.txt") }) // 改用 async 函數 var asyncReadFile = async function() { var file1 = await readFile("./file1.txt") var file2 = await 1 // 等同于同步操作(如果跟上原始類型的值) }
總體來說 async/await 看上去和使用 co 庫后的 generator 看上去很相似,不過相較于 Generator,可以看到 Async 函數更優秀的幾點:
內置執行器。Generator 函數的執行必須依靠執行器,而 Aysnc 函數自帶執行器,調用方式跟普通函數的調用一樣;
更好的語義。async 和 await 相較于 * 和 yield 更加語義化;
更廣的適用性。前文提到的 co 模塊約定,yield 命令后面只能是 Thunk 函數或 Promise 對象,而 async 函數的 await 命令后面則可以是 Promise 或者原始類型的值;
返回值是 Promise。async 函數返回值是 Promise 對象,比 Generator 函數返回的 Iterator 對象方便,因此可以直接使用 then() 方法進行調用;
參考資料深入淺出 Node.js
理解回調函數
JavaScript之異步編程簡述
理解co執行邏輯
co 函數庫的含義和用法
async 函數的含義和用法
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/88588.html
摘要:前端日報精選在中的元素種類及性能優化譯異步遞歸回調譯定位一個頁面阻塞問題的排查過程前端分享之的使用及單點登錄中文視頻如何用做好一個大型應用云際個實用技巧眾成翻譯年一定不要錯過的五本編程書籍年前端領域有哪些探索和實踐實現一個時光網掘金 2017-09-22 前端日報 精選 JavaScript 在 V8 中的元素種類及性能優化【譯】異步遞歸:回調、Promise、Async[譯]HTML...
摘要:以下,請求兩個,當兩個異步請求返還結果后,再請求第三個此處為調用后的結果的數組對于來說,只要參數數組有一個元素變為決定態,便返回新的。 showImg(https://segmentfault.com/img/remote/1460000015444020); Promise 札記 研究 Promise 的動機大體有以下幾點: 對其 api 的不熟悉以及對實現機制的好奇; 很多庫(比...
摘要:瀑布流布局中的圖片有一個核心特點等寬不定等高,瀑布流布局在國內網網站都有一定規模的使用,比如花瓣網等等。那么接下來就基于這個特點開始瀑布流探索之旅。 showImg(https://segmentfault.com/img/remote/1460000013059759?w=640&h=280); 瀑布流布局中的圖片有一個核心特點 —— 等寬不定等高,瀑布流布局在國內網網站都有一定規模...
摘要:瀑布流布局中的圖片有一個核心特點等寬不定等高,瀑布流布局在國內網網站都有一定規模的使用,比如花瓣網等等。那么接下來就基于這個特點開始瀑布流探索之旅。 showImg(https://segmentfault.com/img/remote/1460000013059759?w=640&h=280); 瀑布流布局中的圖片有一個核心特點 —— 等寬不定等高,瀑布流布局在國內網網站都有一定規模...
摘要:瀑布流布局中的圖片有一個核心特點等寬不定等高,瀑布流布局在國內網網站都有一定規模的使用,比如花瓣網等等。那么接下來就基于這個特點開始瀑布流探索之旅。 showImg(https://segmentfault.com/img/remote/1460000013059759?w=640&h=280); 瀑布流布局中的圖片有一個核心特點 —— 等寬不定等高,瀑布流布局在國內網網站都有一定規模...
閱讀 1894·2021-11-22 09:34
閱讀 3035·2021-09-28 09:35
閱讀 13443·2021-09-09 11:34
閱讀 3601·2019-08-29 16:25
閱讀 2831·2019-08-29 15:23
閱讀 2046·2019-08-28 17:55
閱讀 2435·2019-08-26 17:04
閱讀 3050·2019-08-26 12:21