陸陸續(xù)續(xù)用了
koa
和co
也算差不多用了大半年了憔恳,大部分的場景都是在服務(wù)端使用koa
來作為restful服務(wù)器用快鱼,使用場景比較局限败砂,這里總結(jié)一下。
異常處理其實(shí)是一個程序非常重要的部分宛蚓,我自己以前寫前端代碼的時候很不注意這個凄吏,用node來寫后臺并被焦作人很多次了之后才真正明白了它的重要性图柏。因?yàn)橛胣ode來寫后臺時,常常涉及到大量的網(wǎng)絡(luò)異步請求繁涂、用戶提交诸蚕、數(shù)據(jù)庫讀寫等操作盅抚。許多場景你即使做了充足的數(shù)據(jù)校驗(yàn)和多種情況考慮,但因?yàn)槭腔诰W(wǎng)絡(luò)和用戶的禁熏,必然存在你無法掌控的情況寄症,如果你沒有做好try catch,那么一個bug就可能讓你的node server倒下示姿。雖然借助于pm2等等工具,你可以讓他倒了再立馬爬起來,但是倒下的代價是巨大的,而且如果沒有try catch,沒有日志記錄的話,你甚至都不知道倒在哪了。所以錯誤處理對于提升程序的健壯性來說是必不可少的。",
<p class="tip">這篇文章需要koa和co以及generator的原理知識,因co和generator的處理流程有一點(diǎn)繞(捋順了其實(shí)巨好懂),如果你對co/koa/generaotor
不太熟悉,最好先看看阮一峰老師es6入門的第十五章和第十七章。當(dāng)然這篇文章也會講到co的原理過程,也可能解釋你的一些困惑炕吸。</p>
在co
里處理異常
co
是koa
(1)的核心赫模,整個koa可以說就是http.listen + koa compress + co +context,co
保證了你能在koa的中間件generator里進(jìn)行各種yield瀑罗,完成異步函數(shù)的串行化書寫斩祭,所以在koa
里處理錯誤有相當(dāng)大的比重其實(shí)是在co
里處理錯誤,我們先說說怎么在co
里處理錯誤掠拳,這里幾乎涵蓋了90%的處理場景柏肪。
我以前的兩種寫法
首先說說我以前是怎么在使用co
時處理錯誤的姐刁。
- 第一種:
co(function* () {
let abcd = yield promiseAbcd.catch(function(err){
//錯誤處理寫在這
})
})
- 第二種:
co(function* () {
let abcd
try{
abcd = yield promiseAbcd
}catch(err){
//錯誤處理寫在這
}
})
第二種寫法其實(shí)是明顯優(yōu)于第一種的,主要原因有三個:
- 第一種的錯誤處理寫在回調(diào)函數(shù)里谬俄,這就意味著你沒辦法對函數(shù)外部的代碼進(jìn)行流程控制柏靶,你無法在錯誤了之后改變外部代碼的執(zhí)行流程,你沒法
return
溃论、break
屎蜓、continue
,這是最大的問題钥勋。而能在異步處理中進(jìn)行try/catch我覺得co
/koa
的一個非常大的優(yōu)勢(對TJ真是大寫的服)炬转,要知道你對一個異步函數(shù)try/catch是并不能捕捉到他的錯誤的辆苔,如下,這是很棘手的問題扼劈。
try{
setTimeout(function(){
throw new Error()
},0)
}catch(e){
//這里根本捕捉不到錯誤的哈
}
- 第二點(diǎn)需要說的是你如果用這種方式寫的話驻啤,你需要很清楚你現(xiàn)在yield出去的是promiseAbcd.catch()方法生成的promise,而不是promiseAbcd荐吵,這有什么問題骑冗?我們知道.catch會生成一個新的promise,并且會以你在.catch綁定的回調(diào)函數(shù)中的返回值或者throw的error先煎,作為這個新的promise的成功原因或者失敗error贼涩。所以,當(dāng)promiseAbcd出錯榨婆,首先執(zhí)行你寫的這個.catch()的綁定回調(diào)磁携,然后如果你在綁定回調(diào)里面的返回值會被co接住,并扔回到generatorFunction,于是上面的這句代碼中:
let abcd = yield promiseAbcd.catch(function(err){})
變量abcd
就會是function(err){}
的返回值良风,如果你沒有注意到這個細(xì)節(jié)谊迄,沒有返回值,那么abcd
就會得到默認(rèn)返回值undefined
烟央,然后繼續(xù)往下執(zhí)行统诺,于是很有可能后續(xù)代碼就會報錯。
- 此外第一種寫法的問題也依然還是出在回調(diào)函數(shù)上疑俭,既然是函數(shù)粮呢,作用域就是新的作用域,所以要想訪問回調(diào)函數(shù)外層的變量的和
this
的話钞艇,需要使用箭頭函數(shù)啄寡,所以這種寫法就和箭頭函數(shù)這種語法糖(其實(shí)箭頭函數(shù)不是語法糖)綁定起來了,也就帶來了隱患哩照。 - 第四點(diǎn)問題在于代碼的可讀性上挺物,明顯寫在
yield
后方的.catch
讓你不會明顯的注意到,而改為try catch
的方式飘弧,在橫向長度上的減少帶來的是縱向方向上的清晰识藤,如果遇到yield promise.then(function(){......})
,本身就已經(jīng)很長了次伶,你再后面再加個catch
痴昧,這這這....
所以,第一種方式是很優(yōu)雅的冠王,但是很多同學(xué)可能會懷有疑問赶撰,為什么yield出去的異步任務(wù)竟然還能在原來的generator
上catch
住,那具體什么時候哪些情況可以catch
住,這其實(shí)是對于co
源碼和處理過程的不熟悉導(dǎo)致的扣囊。
而且如果是并行的多個任務(wù)呢乎折,比如你要并行的查多個sql,如果你希望在每個sql后面都綁定自己的錯誤處理侵歇,那么第一種方式的一個try catch
肯定不夠骂澄,比如例子如下。
let promiseArr = []
promiseArr.push(sqlExecPromise('sql A'))
promiseArr.push(sqlExecPromise('sql B'))
try{
let [resultA, resultB] = yield promiseArr
}catch(e){
//這里能捕捉到上面的錯誤嗎惕虑?
//如果上面的兩個promise都報錯呢坟冲?
//如果我想為各個promise綁定自己的錯誤處理,這種寫法也不能滿足需求吧溃蔫?
}
所以我們需要首先來理清楚co
里面的異辰√幔控制,結(jié)合co
的源碼伟叛,弄明白co
的處理過程私痹,才能知道我們到底可以怎么處理錯誤。
說一嘴 iterator和generator
首先說一點(diǎn)點(diǎn)基礎(chǔ)概念:generator
函數(shù)统刮,執(zhí)行后的返回值是這個generator
函數(shù)的iterator
(遍歷器)紊遵,然后對這個iterator
執(zhí)行.next()
方法,可以讓generator
函數(shù)往下執(zhí)行侥蒙,并且.next()
的返回值是generator
函數(shù)yield
出來的值暗膜。而對這個iterator
執(zhí)行.throw(err)
方法,這將err
“傳到”generator
函數(shù)當(dāng)中:
function * generatorFunction (){
try {
yield 'hello'
yield 'world'
} catch (err) {
console.log(err)
}
}
// 執(zhí)行g(shù)enerator函數(shù),拿到它的iterator
let iterator = generatorFunction()
// 返回一個對象,對象的value是你yield出來的值
iterator.next() // { value: \"hello\", done: false }
iterator.next() // { value: \"world\", done: false }
iterator.throw(new Error('some thing wrong'))
// 此時這個err被\"傳入\" generator函數(shù),
// 并被其generator的try catch捕捉到
// log: Error: some thing wrong
結(jié)合源碼看看co
的錯誤上報
要說的已經(jīng)在圖里面的(抱歉圖片左側(cè).catch
里面的代碼應(yīng)該寫console.log('outer error caught')
鞭衩,寫錯了)学搜,我們在co里面其實(shí)遇到的錯誤有兩種情況:
- 一種是yield的出去的這個promise,出了問題
co(function*(){ yield Promise.reject(new Error()) })
- 另外一種是generatorFunction的內(nèi)部流程代碼里出了問題:
co(function*(){ throw new Error() })
如果是第一種论衍,我們看上面那幅我制作了好半天的圖瑞佩,co給promise綁定了回調(diào),因此坯台,當(dāng)promise失敗時钉凌,會執(zhí)行藍(lán)色框里的那句代碼iterator.throw(err)
那么這個時候,err就被返回到了generator中捂人,于是let a = Promise.reject(new Error('出錯啦'))
就“變成”了throw new Error('出錯啦')
,因此這就使得yield出去的promise發(fā)生錯誤時矢沿,也依然可以在內(nèi)部的try catch捕捉到滥搭。
那如果情況是第二種,那也就是順著圖片里面的左側(cè)代碼繼續(xù)執(zhí)行捣鲸,這個throw的err會“流落”到哪去呢瑟匆?,我們需要知道iterator.thow(err)
不僅把錯誤"塞"回到了原來的generatorFunction里栽惶,還讓generatorFunction繼續(xù)執(zhí)行了愁溜,它和iterator.next(data)
的功能都是一樣的疾嗅,我們可以把他們看做一個函數(shù),這個函數(shù)在內(nèi)部調(diào)用了generatorFunction繼續(xù)執(zhí)行的函數(shù)冕象,generatorFunction繼續(xù)執(zhí)行的函數(shù)出錯代承,這個函數(shù)必然就會像外拋出錯誤,所以iterator.thow(err)
和iterator.next(data)
不僅會把err和data塞回generatorFunction渐扮,還會繼續(xù)generatorFunction的執(zhí)行论悴,并且在執(zhí)行報錯是捕捉到錯誤。
而在其實(shí)圖片里面墓律,我們可以看到co
在iterator.throw(err)
的代碼外側(cè)是用try catch包裹起來的膀估,在catch里面,使用了reject(err)
耻讽,在iterator.next(data)
的外側(cè)其實(shí)也有相似處理察纯,其實(shí)在co里,所有的iterator.next(data)
和throw外側(cè)都是用這個try catch包起來的针肥,這就保證了饼记,任何generatorFunction執(zhí)行時候的錯誤都能被catch捕捉到,并將這個錯誤reject(err)祖驱,也就是轉(zhuǎn)移到co最開始return的那個promise上握恳,因此如果是第二種情況也就是generatorFunction的內(nèi)部流程代碼里出了問題,那么這個error會報告到co函數(shù)返回的promise上捺僻。
于是co(function*(){}).catch()
的這個.catch就能起到捕捉這個錯誤的作用乡洼。所以大家應(yīng)該明白為什么可以try catch 我們yield出去的promise上發(fā)生的錯誤,也知道其中的一整套原理匕坯,更明白如果是generatorFunction內(nèi)部錯誤束昵,那么我們應(yīng)該怎么去捕捉。
好了葛峻,現(xiàn)在基本算是回答了之前兩個問題中的一個锹雏。
那,并發(fā)的情況呢术奖?
大家先試試下面這段代碼:
co(function * () {
let promiseArr = []
promiseArr.push(Promise.reject(new Error('error 1')))
promiseArr.push(Promise.reject(new Error('error 2')))
try {
yield promiseArr
} catch (err) {
console.log('inner caught')
console.log(err.message)
}
//inner caught
//error 1
})
首先我們要知道當(dāng)我們yield 出去一個填充了promise的array或者object的時候礁遵,co幫我們做了什么?我在上面那張圖里面介紹了一個小細(xì)節(jié)采记,當(dāng)co通過value下標(biāo)從iterator.next()返回的對象中取出你yield出來的東西時佣耐,這個東西可能的情況有很多,絕大部分的情況可能是promise或者是thunk唧龄,他調(diào)用了一個內(nèi)部的函數(shù)toPromise兼砖,這個函數(shù)會把你傳出來的東西轉(zhuǎn)換成promise,如果是一個數(shù)組,對數(shù)組執(zhí)行promise.all讽挟,那么會得到一個新的promise懒叛,然后在這個promise上綁定成功和失敗回調(diào),因此耽梅,在generatorFunction內(nèi)對yield進(jìn)行try catch薛窥,會捕捉到這個父promise上的異常。對于promise.all返回的這個父promise褐墅,如果所有的子promise都成功了拆檬,他才會成功,如果任意一個子promise失敗了妥凳,那么會導(dǎo)致他的失敗竟贯,而且最關(guān)鍵的是,如果一個子promise失敗了逝钥,那么這個子promise的失敗原因(error)會作為父promise的失敗原因(error)屑那,引起父promise的失敗回調(diào)執(zhí)行,而后續(xù)的子promise的失敗都不會在父promise上產(chǎn)生效果艘款,失敗回調(diào)都不會執(zhí)行(其實(shí)成功回調(diào)也不會執(zhí)行)持际,所以我們上面只能捕捉到一個error。
插一句哗咆,promise.all的這個機(jī)制很好理解蜘欲,我雖然不清楚其內(nèi)部具體實(shí)現(xiàn),但是其實(shí)類似一個普通的promise晌柬,當(dāng)你對它的reject或者resolve執(zhí)行過一次后姥份,不管你接下來再執(zhí)行多少次resolve或者reject,都不會導(dǎo)致這個promise上綁定的成功回調(diào)或失敗回調(diào)繼續(xù)執(zhí)行年碘。
上面說明的這種情況導(dǎo)致只有第一個出現(xiàn)錯誤的子promise的error會被iterator.throw(error)澈歉,從而被generatorFunction的try catch捕捉到,而后續(xù)的錯誤都不會被throw回去屿衅,也不會有任何的處理埃难。generatorFunction當(dāng)catch到第一個error就繼續(xù)往后執(zhí)行了,也不會停下來進(jìn)行等待涤久。導(dǎo)致的情況就是第一個子promise出bug以后涡尘,其他的子promise的就被遺忘在了隕落的賽博坦星球,他們不管成功或者失敗响迂,他們的data或者err我們都拿不到悟衩,而且很多時候我們甚至都無法終止他們(也就setTimeout和setInteval這種返回了句柄的可以終止),所以他們成了毫無意義的任務(wù)栓拜,一方面依然在執(zhí)行,我們沒法終止,另一方面執(zhí)行的結(jié)果和錯誤我們都根本拿不到幕与,成了占用著網(wǎng)絡(luò)資源和計算能力卻又沒任何作用的吸血蟲挑势,如果他們中存在閉包,而且這個任務(wù)又有可能一直卡住的話啦鸣,那么你可能要小心一點(diǎn)了潮饱,他可能會造成內(nèi)存泄露。
那我們應(yīng)該怎么去寫并發(fā)情況下的錯誤控制诫给,上面這種寫法的一個唯一的好處在于你可以在結(jié)果拿到之后第一時間得到錯誤信息香拉,如果你在此處就是希望all or nothing
,而且你不關(guān)心出錯的原因是什么中狂,連日志記錄都不想要凫碌,就只希望出錯了不要繼續(xù)往下執(zhí)行,或者你的并發(fā)的代碼極少出錯胃榕,那么也許上面的寫法你會采用盛险。但是其中的風(fēng)險你應(yīng)該已經(jīng)明白了。
如果你沒有那么強(qiáng)烈的快速知道錯誤發(fā)生并立即停止往下執(zhí)行的需求(這種也許可以讓你的結(jié)果返回得更快那么一點(diǎn)點(diǎn)勋又,然并卵苦掘,子promise任務(wù)并沒有被中斷),那么我覺得最好的方式還是等所有的子任務(wù)的執(zhí)行完畢楔壤,不管他是成功或者失敽追取(因?yàn)樗凑荚趫?zhí)行),這是一種無法終止已經(jīng)開啟的異步任務(wù)和promise.all的回調(diào)只能執(zhí)行一次的回退方案蹲嚣,至少保險递瑰。而且這樣的話,至少端铛,你能有辦法記錄他出錯的原因泣矛,也能針對整個并行任務(wù)的完成情況,執(zhí)行后續(xù)的處理策略禾蚕。
既然子任務(wù)的error會導(dǎo)致父promise的執(zhí)行失敗您朽,那么就不能讓子promise的error直接拋出去,所以子promise yield出去前先綁好.catch是肯定需要的换淆,而且要處理好.catch綁定的錯誤回調(diào)里的返回值哗总,不然我們雖然接住了錯誤,但是co
返回到generatorFunciton里面的卻是undefined倍试,我們不知道錯誤在哪了讯屈。
舉個例子,我們可以寫一個包裝函數(shù)县习,然后為我們的每一個promise綁定好成功回調(diào)和錯誤回調(diào)涮母,并借助閉包谆趾,讓他們成功或失敗后修改一個對象里面的對應(yīng)屬性,我們根據(jù)這個對象去判斷是否執(zhí)行成功叛本。
function protect (promiseArr) {
//這個數(shù)組用于存儲最后的結(jié)果
let resultArr = []
//為每個promise綁定成功和失敗回調(diào)
promiseArr.forEach((promise,index) => {
//在resultArr的對應(yīng)位置先插入一個對象
resultArr[index] = {
success: true,
}
promiseArr[index] = promise.then((data) => {
//如果成功,那么把結(jié)果寫回結(jié)果數(shù)組中的對象
resultArr[index].data = data
},(err) => {
//失敗就寫入失敗原因
resultArr[index].error = err
resultArr[index].success = false
})
})
// 這一步綁定.then必可不能少
return Promise.all(promiseArr).then(() => {
return resultArr
})
}
function generateRandomPromise() {
//這個函數(shù)隨機(jī)產(chǎn)生一個promise,這個promise在100毫秒內(nèi)可能成功or失敗
let randomNum = Math.random() * 100
return new Promise (function(resolve, reject) {
setTimeout( function() {
if(randomNum > 50){
resolve('data!')
}else{
reject(new Error('error'))
}
},randomNum)
})
}
co(function * () {
let promiseArr = []
for (var i = 0; i < 10; i++) {
promiseArr.push(generateRandomPromise())
}
//下面這一句不用包裹try catch
let missionResults = yield protect(promiseArr)
console.log(missionResults)
})
上面的代碼中沪蓬,我們最后拿到的這個missionResults是一個數(shù)組,里面的每一個元素包含了我們的成功和失敗信息来候,這種方式因?yàn)槊總€子promise都不會失敗跷叉,所以也就不用在yield protect(promiseArr)外層包裹try catch,你要做的就是在拿到這個missionResult之后對里面的元素判斷成功或失敗营搅,接著完成相關(guān)的處理云挟,寫日志、向用戶返回500转质、嘗試重新執(zhí)行錯誤的任務(wù)之類的园欣,這些都比你一行直接的console.log('internal error')
要好得多吧。
如果你的某一個子任務(wù)在卡住時會很長都不會reject峭拘,你覺得你可能難以接受所有的promise都必須成功或失敗時才執(zhí)行完這個父promise俊庇,那么你大可以為這個子promise包裹一層Promise.race()
,讓他和一個setTimeout并行執(zhí)行鸡挠,時間太長就當(dāng)做錯誤了去處理辉饱,讓setTimeout優(yōu)先返回(但是這依然是無法解決任務(wù)繼續(xù)在后臺執(zhí)行的問題的)〖鹫梗總之這些都是你的細(xì)節(jié)處理彭沼。但是關(guān)于并發(fā)情況下的我們該怎么寫,各種寫法有什么隱患都說完了备埃。我覺得雖然沒有找到完美的方法解決并行時候的錯誤處理姓惑,但是我覺得其實(shí)我們自己已經(jīng)能夠根據(jù)自己的使用場景找到了應(yīng)該還算是不錯的處理方法。其實(shí)如果你的并發(fā)非常容易出錯按脚,出錯情況非常多于毙,錯誤可能會長期卡住沒返回之類的,我覺得你可能需要思考和優(yōu)化的是你的并發(fā)任務(wù)本身辅搬。(另外唯沮,我正在寫關(guān)于co使用過程中的小技巧的文章,里面會包含并發(fā)數(shù)控制的內(nèi)容堪遂,寫好后會替換這個括號)介蛉。
在koa
里處理異常
在koa
里,我們通過app.use(function*(){})
溶褪,綁定了許多的中間件generatorFunction币旧,app.use就是負(fù)責(zé)把你傳入的generatorFunction放到一個中間件數(shù)組middleware
里,接下來用koa-compose對這個中間件數(shù)組處理了一下猿妈,使得koa對中間件的執(zhí)行流程成為了我們大家所熟知的洋蔥結(jié)構(gòu):
koa-compose的源碼如下吹菱,巨短巍虫,就是先拿到后一個generatorFunction的iterator,然后把這個iterator作為當(dāng)前generatorFunction的next毁葱,這樣你在當(dāng)前中間件里執(zhí)行yield next垫言,就可以執(zhí)行后續(xù)的中間件流程,后續(xù)的中間件流程執(zhí)行完之后又回到當(dāng)前的中間件倾剿。而這一整套串好的中間件最終通過co包裹起來。
function compose(middleware){
return function *(next){
if (!next) next = noop();
var i = middleware.length;
while (i--) {
next = middleware[i].call(this, next);
}
return yield *next;
}
}
function *noop(){}
所以大概會像這樣:
function out() {
function mid(){
function in() {
//....
}
}
}
也正是如此蚌成,內(nèi)層的中間件中發(fā)生的錯誤前痘,是能被外層的中間件給捕獲到的,也就是你先app.use()的中間件能捕捉到后app.use()的中間件的錯誤担忧。
所以:
var app = new Koa()
app.use(function* (next){
try{
yield next
}catch(err){
//打印錯誤
//日志記錄
}
})
app.use(function* (next){
//業(yè)務(wù)邏輯
})
同時芹缔,我們說了,這一整套串好的中間件最終通過co包裹起來(其實(shí)是co.wrap)瓶盛,因此co會返回一個promise(我在前一節(jié)說過哈)最欠,因此如果這一整套串好的中間件在執(zhí)行過程中出了什么錯沒有被catch住,那么最終會導(dǎo)致co返回的這個promise的reject惩猫,而koa在這個promise上通過.catch綁定了一個默認(rèn)的錯誤回調(diào)芝硬,錯誤回調(diào)就是設(shè)置http status為500,然后把錯誤信息this.res.end(msg)
發(fā)送出去轧房。因此出錯時拌阴,瀏覽器會收到一個說明錯誤的500報文。
同時奶镶,這個錯誤回調(diào)里執(zhí)行了this.app.emit('error', err, this)
迟赃,koa的app是繼承自event
模塊的EventEmitter
,所以可以在app上觸發(fā)error事件厂镇,而你也可以在app上面監(jiān)聽錯誤回調(diào)纤壁,完成最差情況下的錯誤處理。
koa下的錯誤處理方式還是比較齊全的捺信,另有koa-onerror
等npm包可供使用酌媒,使用了koa-onerror之后,你可以在中間件里直接this.throw(500,\"錯誤原因\")
残黑,它會自動根據(jù)request的header的accept字段馍佑,返回客戶端能accept的返回類型(比如客戶端要application/json,那么返回的body就是{error:\"錯誤原因\"}
)。npm上有較多類似的包梨水,不再贅述拭荤。
此外就是萬一還是出現(xiàn)了你沒有捕捉到的錯誤,在node里如果有未捕捉的錯誤時疫诽,會在process上觸發(fā)事件uncaughtException舅世,所以我們可以在process上監(jiān)聽此事件旦委,但是并不應(yīng)該是單純的把error log記錄好就完了,很明顯如果這個錯誤也是卡住著的雏亚,內(nèi)存是不會回收的缨硝,那么很有可能會發(fā)生內(nèi)存泄露的錯誤,對于koa這種需要長期跑著的程序罢低,這是相當(dāng)大的風(fēng)險的查辩。所以最好的方法是把這個server關(guān)掉。
你聽到這可能就有點(diǎn)崩潰了网持,“什么宜岛?我想方設(shè)法不讓服務(wù)器掛掉,結(jié)果你主動給我關(guān)了功舀?”所以為了讓你的服務(wù)還能繼續(xù)運(yùn)行萍倡,比較好的方法就是用node的cluster
模塊起一個master,然后master里面fork出幾個child_process辟汰,每個child_process就是你對應(yīng)的koa server列敲,當(dāng)child_process遇到錯誤時,那么應(yīng)該記錄日志帖汞,然后把koa給停了戴而,并通過disconnect()告訴master“我關(guān)閉了,你再fork一個新的”涨冀,再接著等連接都關(guān)閉了填硕,把自己給 process.exit()了。但是如果你想實(shí)現(xiàn)更加優(yōu)雅的退出鹿鳖,想實(shí)現(xiàn)當(dāng)前的連接都關(guān)閉之后再關(guān)閉服務(wù)器扁眯,那應(yīng)該怎么做?
我們是
二手轉(zhuǎn)轉(zhuǎn)前端(大轉(zhuǎn)轉(zhuǎn)FE)
知乎專欄:https://zhuanlan.zhihu.com/zhuanzhuan
官方微信公共號:zhuanzhuanfe
微信公眾二維碼:
關(guān)注我們翅帜,我們會定期分享一些團(tuán)隊對前端的想法與沉淀