摘要:事實(shí)上,協(xié)議確實(shí)是基于協(xié)議實(shí)現(xiàn)的。的可選參數(shù)用于監(jiān)聽(tīng)事件另外,它也監(jiān)聽(tīng)事件,只不過(guò)回調(diào)函數(shù)是自己實(shí)現(xiàn)的。并且會(huì)把本次連接的套接字文件描述符封裝成對(duì)象,作為事件的參數(shù)。過(guò)載保護(hù)理論上,允許的同時(shí)連接數(shù)只與進(jìn)程可以打開(kāi)的文件描述符上限有關(guān)。
作者:正龍(滬江Web前端開(kāi)發(fā)工程師)
本文為原創(chuàng)文章,轉(zhuǎn)載請(qǐng)注明作者及出處
上文“走進(jìn)Node.js啟動(dòng)過(guò)程”中我們算是成功入門(mén)了。既然Node.js的強(qiáng)項(xiàng)是處理網(wǎng)絡(luò)請(qǐng)求,那我們就來(lái)分析一個(gè)HTTP請(qǐng)求在Node.js中是怎么被處理的,以及JavaScript在這個(gè)過(guò)程中引入的開(kāi)銷到底有多大。
Node.js采用的網(wǎng)絡(luò)請(qǐng)求處理模型是IO多路復(fù)用。它與傳統(tǒng)的主從多線程并發(fā)模型是有區(qū)別的:只使用有限的線程數(shù)(1個(gè)),所以占用系統(tǒng)資源很少;操作系統(tǒng)級(jí)的異步IO支持,可以減少用戶態(tài)/內(nèi)核態(tài)切換,并且本身性能更高(因?yàn)橹苯优c網(wǎng)卡驅(qū)動(dòng)交互);JavaScript天生具有保護(hù)程序執(zhí)行現(xiàn)場(chǎng)的能力(閉包),傳統(tǒng)模型要么依賴應(yīng)用程序自己保存現(xiàn)場(chǎng),或者依賴線程切換時(shí)自動(dòng)完成。當(dāng)然,并不能說(shuō)IO多路復(fù)用就是最好的并發(fā)模型,關(guān)鍵還是看應(yīng)用場(chǎng)景。
我們來(lái)看“hello world”版Node.js網(wǎng)絡(luò)服務(wù)器:
require("http").createServer((req, res) => { res.end("hello world"); }).listen(3333);代碼思路分析 createServer([requestListener])
createServer創(chuàng)建了http.Server對(duì)象,它繼承自net.Server。事實(shí)上,HTTP協(xié)議確實(shí)是基于TCP協(xié)議實(shí)現(xiàn)的。createServer的可選參數(shù)requestListener用于監(jiān)聽(tīng)request事件;另外,它也監(jiān)聽(tīng)connection事件,只不過(guò)回調(diào)函數(shù)是http.Server自己實(shí)現(xiàn)的。然后調(diào)用listen讓http.Server對(duì)象在端口3333上監(jiān)聽(tīng)連接請(qǐng)求并最終創(chuàng)建TCP對(duì)象,由tcp_wrap.h實(shí)現(xiàn)。最后會(huì)調(diào)用TCP對(duì)象的listen方法,這才真正在指定端口開(kāi)始提供服務(wù)。我們來(lái)看看涉及到的所有JavaScript對(duì)象:
涉及到的C++類大多只是對(duì)libuv做了一層包裝并公布給JavaScript,所以不在這里特別列出。我們有必要提一下http-parser,它是用來(lái)解析http請(qǐng)求/響應(yīng)消息的,本身十分高效:沒(méi)有任何系統(tǒng)調(diào)用,沒(méi)有內(nèi)存分配操作,純C實(shí)現(xiàn)。
connection事件當(dāng)服務(wù)器接受了一個(gè)連接請(qǐng)求后,會(huì)觸發(fā)connection事件。我們可以在這個(gè)結(jié)點(diǎn)獲取到套接字文件描述符,之后就可以在這個(gè)文件描述符上做流式讀或?qū)?,也就是所謂的全雙工模式。上文提到net.Server的listen方法會(huì)創(chuàng)建TCP對(duì)象,并且提供TCP對(duì)象的onconnection事件回調(diào)方法;這里可以利用字段net.Server.maxConnections做過(guò)載保護(hù),后面會(huì)講到。并且會(huì)把clientHandle(本次連接的套接字文件描述符)封裝成net.Socket對(duì)象,作為connection事件的參數(shù)。我們來(lái)看看調(diào)用過(guò)程:
tcp_wrap.cc
void TCPWrap::Listen(const FunctionCallbackInfo& args) { int err = uv_listen(reinterpret_cast (&wrap->handle_), backlog, OnConnection); args.GetReturnValue().Set(err); }
OnConnection 在connection_wrap.cc中定義
// ...省略不重要的代碼 uv_stream_t* client_handle = reinterpret_cast(&wrap->handle_); // uv_accept can fail if the new connection has already been closed, in // which case an EAGAIN (resource temporarily unavailable) will be // returned. if (uv_accept(handle, client_handle)) return; // Successful accept. Call the onconnection callback in JavaScript land. argv[1] = client_obj; // ...省略不重要的代碼 wrap_data->MakeCallback(env->onconnection_string(), arraysize(argv), argv);
上文提到的clientHandle實(shí)際上是uv_accept的第二個(gè)參數(shù),指服務(wù)當(dāng)前連接的套接字文件描述符。net.Server的字段 _handle 會(huì)在JavaScript側(cè)存儲(chǔ)該字段。最后我們上一張流程圖:
request事件connection事件的回調(diào)函數(shù)connectionListener(lib/_http_server.js)中,首先獲取http-parser對(duì)象,設(shè)置parser.onIncoming回調(diào)(馬上會(huì)用到)。當(dāng)連接套接字有數(shù)據(jù)到達(dá)時(shí),調(diào)用http-parser.execute方法。http-parser在解析過(guò)程中會(huì)觸發(fā)如下回調(diào)函數(shù):
on_message_begin:在開(kāi)始解析HTTP消息之前,可以設(shè)置http-parser的初始狀態(tài)(注意http-parse有可能是復(fù)用的而不是重每次新創(chuàng)建)
on_url:解析請(qǐng)求的url,對(duì)響應(yīng)消息不起作用
on_status, 解析狀態(tài)碼,只對(duì)http響應(yīng)消息起作用
on_head_field, 頭字段名稱
on_head_value:頭字段對(duì)應(yīng)值
on_headers_complete:當(dāng)所有頭解析完成時(shí)
on_body:解析http消息中包含的payload
on_message_complete:解析工作結(jié)束
Node.js中Parser類是對(duì)http-parser的包裝,它會(huì)注冊(cè)上面所有的回調(diào)函數(shù)。同時(shí),暴露給JavaScript5個(gè)事件:
kOnHeaders,kOnHeadersComplete,kOnBody,kOnMessageComplete,kOnExecute。在lib/_http_common.js中監(jiān)聽(tīng)了這些事件。其中,當(dāng)需要強(qiáng)制把頭字段回傳到JavaScript時(shí)會(huì)觸發(fā)kOnHeaders;例如,頭字段個(gè)數(shù)超過(guò)32,或者解析結(jié)束時(shí)仍然有頭字段沒(méi)有回傳給JavaScript。當(dāng)調(diào)用完http_parser_execute后觸發(fā)kOnExecute。kOnHeadersComplete事件觸發(fā)時(shí),會(huì)調(diào)用parser的onIncoming回調(diào)函數(shù)。僅僅HTTP頭解析完成之后,就會(huì)觸發(fā)request事件。執(zhí)行流程如下:
說(shuō)了那么多,其實(shí)仍然離不開(kāi)最基礎(chǔ)的套接字編程步驟,對(duì)于服務(wù)器端依次是:create、bind,listen、accept和close??蛻舳藭?huì)經(jīng)歷create、bind、connect和close。想了解更多套接字編程的同學(xué)可以參考《UNIX網(wǎng)絡(luò)編程》。
HTTP場(chǎng)景分析上面提到的Node.js版hello world只涵蓋了HTTP處理最基本的情況,但是也足以說(shuō)明Node.js處理得非常簡(jiǎn)潔?,F(xiàn)在,我們來(lái)分析一些典型的HTTP場(chǎng)景。
1. keep-alive對(duì)于前端應(yīng)用,HTTP請(qǐng)求瞬間數(shù)量比較多,但每個(gè)請(qǐng)求傳輸?shù)臄?shù)據(jù)一般不大;這時(shí),用同一個(gè)TCP連接處理同一個(gè)用戶發(fā)出的HTTP請(qǐng)求可以顯著提高性能。但是keep-alive也不是萬(wàn)能的,如果用戶每次只發(fā)起一個(gè)請(qǐng)求,它反而會(huì)因?yàn)檠娱L(zhǎng)連接的生存時(shí)間,浪費(fèi)服務(wù)器資源。
針對(duì)同一個(gè)連接,Node.js會(huì)維持一個(gè)incoming隊(duì)列和一個(gè)outgoing隊(duì)列。應(yīng)用程序通過(guò)監(jiān)聽(tīng)request事件,可以訪問(wèn)ServerResponse和IncomingMessage對(duì)象,當(dāng)請(qǐng)求處理完成之后(調(diào)用response.end()),ServerResponse會(huì)響應(yīng)finish事件。如果它是本次連接上最后一個(gè)response對(duì)象,則準(zhǔn)備關(guān)閉連接;否則,繼續(xù)觸發(fā)request事件。每個(gè)連接最長(zhǎng)超時(shí)時(shí)間默認(rèn)為2分鐘,可以通過(guò)http.Server.setTimeout調(diào)整。
現(xiàn)在把我們的Node.js版hello world修改一下
var delay = [2000, 30, 500]; var i = 0; require("http").createServer((req, res) => { // 為了讓請(qǐng)求模擬更真實(shí),會(huì)調(diào)整每個(gè)請(qǐng)求的響應(yīng)時(shí)間 setTimeout(() => { res.end("hello world"); }, delay[i]); i = (i+1)%(delay.length); }).listen(3333, () => { // listen的回調(diào)函數(shù) console.log("listen at 3333"); });
客戶端代碼如下:
var http = require("http"); // 設(shè)置HTTP agent開(kāi)啟keep-alive模式 // 套接字的打開(kāi)時(shí)間維持1分鐘 var agent = new http.Agent({ keepAlive: true, keepAliveMsecs: 60000 }); // 每次請(qǐng)求結(jié)束之后,都會(huì)再發(fā)起一次請(qǐng)求 // doReq每調(diào)用一次只會(huì)觸發(fā)2次請(qǐng)求 function doReq(again, iter) { let request = http.request({ hostname: "192.168.1.10", port: 3333, agent:agent }, (res) => { console.log(`${new Date().valueOf()} ${iter} ${again} Headers: ${JSON.stringify(res.headers)}`); console.log(request.socket.localPort); // 設(shè)置解析響應(yīng)的編碼格式 res.setEncoding("utf8"); // 接收響應(yīng) res.on("data", (chunk) => { console.log(`${new Date().valueOf()} ${iter} ${again} Body: ${chunk}`); }); if (again) doReq(false, iter); }); // 發(fā)起請(qǐng)求 request.end(); } for (let i = 0; i < 3; i++) { doReq(true, i); }
套接字復(fù)用的時(shí)序如下:
2. Expect頭如果客戶端在發(fā)送POST請(qǐng)求之前,由于傳輸?shù)臄?shù)據(jù)量比較大,期望向服務(wù)器確認(rèn)請(qǐng)求是否能被處理;這種情況下,可以先發(fā)送一個(gè)包含頭Expect:100-continue的http請(qǐng)求。如果服務(wù)器能處理此請(qǐng)求,則返回響應(yīng)狀態(tài)碼100(Continue);否則,返回417(Expectation Failed)。默認(rèn)情況下,Node.js會(huì)自動(dòng)響應(yīng)狀態(tài)碼100;同時(shí),http.Server會(huì)觸發(fā)事件checkContinue和checkExpectation來(lái)方便我們做特殊處理。具體規(guī)則是:當(dāng)服務(wù)器收到頭字段Expect時(shí):如果其值為100-continue,會(huì)觸發(fā)checkContinue事件,默認(rèn)行為是返回100;如果值為其它,會(huì)觸發(fā)checkExpectation事件,默認(rèn)行為是返回417。
例如,我們通過(guò)curl發(fā)送HTTP請(qǐng)求:
curl -vs --header "Expect:100-continue" http://localhost:3333
交互過(guò)程如下
> GET / HTTP/1.1 > Host: localhost:3333 > User-Agent: curl/7.49.1 > Accept: */* > Expect:100-continue > < HTTP/1.1 100 Continue < HTTP/1.1 200 OK < Date: Mon, 03 Apr 2017 14:15:47 GMT < Connection: keep-alive < Content-Length: 11 <
我們接收到2個(gè)響應(yīng),分別是狀態(tài)碼100和200。前一個(gè)是Node.js的默認(rèn)行為,后一個(gè)是應(yīng)用程序代碼行為。
3. HTTP代理在實(shí)際開(kāi)發(fā)時(shí),用到http代理的機(jī)會(huì)還是挺多的,比如,測(cè)試說(shuō)線上出bug了,觸屏版頁(yè)面顯示有問(wèn)題;我們一般第一時(shí)間會(huì)去看api返回是否正常,這個(gè)時(shí)候在手機(jī)上設(shè)置好代理就能輕松捕獲HTTP請(qǐng)求了。老牌的代理工具有fiddler,charles。其實(shí),nodejs下也有,例如node-http-proxy,anyproxy?;舅悸肥潜O(jiān)聽(tīng)request事件,當(dāng)客戶端與代理建立HTTP連接之后,代理會(huì)向真正請(qǐng)求的服務(wù)器發(fā)起連接,然后把兩個(gè)套接字的流綁在一起。我們可以實(shí)現(xiàn)一個(gè)簡(jiǎn)單的代理服務(wù)器:
var http = require("http"); var url = require("url"); http.createServer((req, res) => { // request回調(diào)函數(shù) console.log(`proxy request: ${req.url}`); var urlObj = url.parse(req.url); var options = { hostname: urlObj.hostname, port: urlObj.port || 80, path: urlObj.path, method: req.method, headers: req.headers }; // 向目標(biāo)服務(wù)器發(fā)起請(qǐng)求 var proxyRequest = http.request(options, (proxyResponse) => { // 把目標(biāo)服務(wù)器的響應(yīng)返回給客戶端 res.writeHead(proxyResponse.statusCode, proxyResponse.headers); proxyResponse.pipe(res); }).on("error", () => { res.end(); }); // 把客戶端請(qǐng)求數(shù)據(jù)轉(zhuǎn)給中間人請(qǐng)求 req.pipe(proxyRequest); }).listen(8089, "0.0.0.0");
驗(yàn)證下是否真的起作用,curl通過(guò)代理服務(wù)器訪問(wèn)我們的“hello world”版Node.js服務(wù)器:
curl -x http://192.168.132.136:8089 http://localhost:3333/優(yōu)化策略
Node.js在實(shí)現(xiàn)HTTP服務(wù)器時(shí),除了利用高性能的http-parser,自身也做了些性能優(yōu)化。
1. http_parser對(duì)象緩存池http-parser對(duì)象處理完一個(gè)請(qǐng)求之后不會(huì)被立即釋放,而是被放入緩存池(/lib/internal/freelist),最多緩存1000個(gè)http-parser對(duì)象。
2. 預(yù)設(shè)HTTP頭總數(shù)HTTP協(xié)議規(guī)范并沒(méi)有限定可以傳輸?shù)腍TTP頭總數(shù)上限,http-parser為了避免動(dòng)態(tài)分配內(nèi)存,設(shè)定上限默認(rèn)值是32。其他web服務(wù)器實(shí)現(xiàn)也有類似設(shè)置;例如,apache能處理的HTTP請(qǐng)求頭默認(rèn)上限(LimitRequestFields)是100。如果請(qǐng)求消息中頭字段真超過(guò)了32個(gè),Node.js也能處理,它會(huì)把已經(jīng)解析的頭字段通過(guò)事件kOnHeaders保存到JavaScript這邊然后繼續(xù)解析。 如果頭字段不超過(guò)32個(gè),http-parser會(huì)直接處理完并觸發(fā)on_headers_complete一次性傳遞所有頭字段;所以我們?cè)诶肗ode.js作為web服務(wù)器時(shí),應(yīng)盡量把頭字段控制在32個(gè)之內(nèi)。
3. 過(guò)載保護(hù)理論上,Node.js允許的同時(shí)連接數(shù)只與進(jìn)程可以打開(kāi)的文件描述符上限有關(guān)。但是隨著連接數(shù)越來(lái)越多,占用的系統(tǒng)資源也越來(lái)越多,很有可能連正常的服務(wù)都無(wú)法保證,甚至可能拖垮整個(gè)系統(tǒng)。這時(shí),我們可以設(shè)置http.Server的maxConnections,如果當(dāng)前并發(fā)量大于服務(wù)器的處理能力,則服務(wù)器會(huì)自動(dòng)關(guān)閉連接。另外,也可以設(shè)置socket的超時(shí)時(shí)間為可接受的最長(zhǎng)響應(yīng)時(shí)間。
性能實(shí)測(cè)為了簡(jiǎn)單分析下Node.js引入的開(kāi)銷,現(xiàn)在基于libuv和http_parser編寫(xiě)一個(gè)純C的HTTP服務(wù)器?;舅悸肥牵谀J(rèn)事件循環(huán)隊(duì)列上監(jiān)聽(tīng)指定TCP端口;如果該端口上有請(qǐng)求到達(dá),會(huì)在隊(duì)列上插入一個(gè)一個(gè)的任務(wù);當(dāng)這些任務(wù)被消費(fèi)時(shí),會(huì)執(zhí)行connection_cb。見(jiàn)核心代碼片段:
int main() { // 初始化uv事件循環(huán) loop = uv_default_loop(); uv_tcp_t server; struct sockaddr_in addr; // 指定服務(wù)器監(jiān)聽(tīng)地址與端口 uv_ip4_addr("192.168.132.136", 3333, &addr); // 初始化TCP服務(wù)器,并與默認(rèn)事件循環(huán)綁定 uv_tcp_init(loop, &server); // 服務(wù)器端口綁定 uv_tcp_bind(&server, (const struct sockaddr*)&addr, 0); // 指定連接處理回調(diào)函數(shù)connection_cb // 256為T(mén)CP等待隊(duì)列長(zhǎng)度 int r = uv_listen((uv_stream_t*)&server, 256, connection_cb); // 開(kāi)始處理默認(rèn)時(shí)間循環(huán)上的消息 // 如果TCP報(bào)錯(cuò),事件循環(huán)也會(huì)自動(dòng)退出 return uv_run(loop, UV_RUN_DEFAULT); }
connection_cb調(diào)用uv_accept會(huì)負(fù)責(zé)與發(fā)起請(qǐng)求的客戶端實(shí)際建立套接字,并注冊(cè)流操作回調(diào)函數(shù)read_cb:
void connection_cb(uv_stream_t* server, int status) { uv_tcp_t* client = (uv_tcp_t*)malloc(sizeof(uv_tcp_t)); uv_tcp_init(loop, client); // 與客戶端建立套接字 uv_accept(server, (uv_stream_t*)client); uv_read_start((uv_stream_t*)client, alloc_buffer, read_cb); }
上文中read_cb用于讀取客戶端請(qǐng)求數(shù)據(jù),并發(fā)送響應(yīng)數(shù)據(jù):
void read_cb(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) { if (nread > 0) { memcpy(reqBuf + bufEnd, buf->base, nread); bufEnd += nread; free(buf->base); // 驗(yàn)證TCP請(qǐng)求數(shù)據(jù)是否是合法的HTTP報(bào)文 http_parser_execute(parser, &settings, reqBuf, bufEnd); uv_write_t* req = (uv_write_t*)malloc(sizeof(uv_write_t)); uv_buf_t* response = malloc(sizeof(uv_buf_t)); // 響應(yīng)HTTP報(bào)文 response->base = "HTTP/1.1 200 OK Connection:close Content-Length:11 hello world "; response->len = strlen(response->base); uv_write(req, stream, response, 1, write_cb); } else if (nread == UV_EOF) { uv_close((uv_handle_t*)stream, close_cb); } }
全部源碼請(qǐng)參見(jiàn)simple HTTP server。我們使用apache benchmark來(lái)做壓力測(cè)試:并發(fā)數(shù)為5000,總請(qǐng)求數(shù)為100000。
ab -c 5000 -n 100000 http://192.168.132.136:3333/
測(cè)試結(jié)果如下: 0.8秒(C) vs??5秒(Node.js)
我們?cè)倏纯磧?nèi)存占用,0.6MB(C) vs??51MB(Node.js)
Node.js雖然引入了一些開(kāi)銷,但是從代碼實(shí)現(xiàn)行數(shù)上確實(shí)要簡(jiǎn)潔很多。
iKcamp原創(chuàng)新書(shū)《移動(dòng)Web前端高效開(kāi)發(fā)實(shí)戰(zhàn)》已在亞馬遜、京東、當(dāng)當(dāng)開(kāi)售。
文章版權(quán)歸作者所有,未經(jīng)允許請(qǐng)勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請(qǐng)注明本文地址:http://hztianpu.com/yun/91745.html
摘要:例如,在方法中,如果需要主從進(jìn)程之間建立管道,則通過(guò)環(huán)境變量來(lái)告知從進(jìn)程應(yīng)該綁定的相關(guān)的文件描述符,這個(gè)特殊的環(huán)境變量后面會(huì)被再次涉及到。 文:正龍(滬江網(wǎng)校Web前端工程師)本文原創(chuàng),轉(zhuǎn)載請(qǐng)注明作者及出處 之前的文章走進(jìn)Node.js之HTTP實(shí)現(xiàn)分析中,大家已經(jīng)了解 Node.js 是如何處理 HTTP 請(qǐng)求的,在整個(gè)處理過(guò)程,它僅僅用到單進(jìn)程模型。那么如何讓 Web 應(yīng)用擴(kuò)展到...
摘要:具體調(diào)用鏈路如圖函數(shù)主要是解析啟動(dòng)參數(shù),并過(guò)濾選項(xiàng)傳給引擎。查閱文檔之后發(fā)現(xiàn),通過(guò)指定參數(shù)可以設(shè)置線程池大小。原來(lái)的字節(jié)碼編譯優(yōu)化還有都是通過(guò)多線程完成又繼續(xù)深入調(diào)查,發(fā)現(xiàn)環(huán)境變量會(huì)影響的線程池大小。執(zhí)行過(guò)程如下調(diào)用執(zhí)行。 作者:正龍 (滬江Web前端開(kāi)發(fā)工程師)本文原創(chuàng),轉(zhuǎn)載請(qǐng)注明作者及出處。 隨著Node.js的普及,越來(lái)越多的開(kāi)發(fā)者使用Node.js來(lái)搭建環(huán)境,也有很多公司開(kāi)始把...
摘要:具體調(diào)用鏈路如圖函數(shù)主要是解析啟動(dòng)參數(shù),并過(guò)濾選項(xiàng)傳給引擎。查閱文檔之后發(fā)現(xiàn),通過(guò)指定參數(shù)可以設(shè)置線程池大小。原來(lái)的字節(jié)碼編譯優(yōu)化還有都是通過(guò)多線程完成又繼續(xù)深入調(diào)查,發(fā)現(xiàn)環(huán)境變量會(huì)影響的線程池大小。執(zhí)行過(guò)程如下調(diào)用執(zhí)行。 作者:正龍 (滬江Web前端開(kāi)發(fā)工程師)本文原創(chuàng),轉(zhuǎn)載請(qǐng)注明作者及出處。 隨著Node.js的普及,越來(lái)越多的開(kāi)發(fā)者使用Node.js來(lái)搭建環(huán)境,也有很多公司開(kāi)始把...
摘要:前端日?qǐng)?bào)精選借助和緩存及離線開(kāi)發(fā)中和走進(jìn)之實(shí)現(xiàn)分析總是一知半解的中個(gè)常見(jiàn)的陷阱發(fā)布核心成員發(fā)布了免費(fèi)的學(xué)習(xí)視頻中文譯的函數(shù)式編程是一種反模式掘金譯更好的表單設(shè)計(jì)每一頁(yè),一件事實(shí)例研究掘金打印龍墨并不簡(jiǎn)單結(jié)合實(shí)現(xiàn)簡(jiǎn)單的加載動(dòng)畫(huà) 2017-07-12 前端日?qǐng)?bào) 精選 借助Service Worker和cacheStorage緩存及離線開(kāi)發(fā)JavaScript中toString()和valu...
摘要:前端日?qǐng)?bào)精選中的垃圾收集,圖文指南十個(gè)免費(fèi)的前端開(kāi)發(fā)工具專題之遞歸如何在鏈中共享變量基于的爬蟲(chóng)框架中文譯十六進(jìn)制顏色揭秘掘金掘金小書(shū)基本環(huán)境安裝小書(shū)教程中間件對(duì)閉包的一個(gè)巧妙使用簡(jiǎn)書(shū)源碼分析掘金組件開(kāi)發(fā)練習(xí)焦點(diǎn)圖切換前端學(xué) 2017-09-13 前端日?qǐng)?bào) 精選 V8 中的垃圾收集(GC),圖文指南十個(gè)免費(fèi)的web前端開(kāi)發(fā)工具JavaScript專題之遞歸 · Issue #49 · m...
閱讀 2620·2023-04-25 17:37
閱讀 1283·2021-11-24 10:29
閱讀 3843·2021-09-09 11:57
閱讀 792·2021-08-10 09:41
閱讀 2352·2019-08-30 15:55
閱讀 2887·2019-08-30 15:54
閱讀 2050·2019-08-30 15:53
閱讀 1040·2019-08-30 15:43