我見到有不少朋友在 SegmentFault 上面問一些不太好回答的問題,「JavaScript/Node 學好了能作什麼?」,「前端架構師天天都作些什麼?」等等。這些問題並不是不能回答,可是第1、問題自己太過泛泛,很難回答的既針對又具體;第2、面對這樣的問題一時也想不出從何處着手來回答。我本身以爲若是能配合一個實例來講明一下會比泛泛而談更有價值,因此這篇文章等待了很久,就爲了等一個合適的例子。javascript
恰逢最近一個項目應用了 React+Redux 的同構化應用程序架構,在實施這個項目的過程當中也使用了 express+webpack 的組合,在整個過程當中特別是 SSR(服務端渲染)這一起解決了很多的技術問題,我以爲值得總結一下經驗。項目臨近結束,我打算把裏面的核心部分抽取出來重構一下作個開源項目,因而這裏面就有了一些值得拿出來分享的例子,而且分享的目的不僅是問題的解決方法自己,更重要的是一些額外的東西,那些能多多少少回答上述問題的東西。好比思路分析、原理闡釋、源代碼閱讀、編寫/調試技巧等等。html
若是接下來我能有比較充裕的時間,那就會不止這一篇,個人目標對象是那些初學者和處於進階門檻的人,但願能對大家的胃口。接下來是第一個例子:爲 Koa 框架封裝 webpack-dev-middleware 中間件。前端
我並不是經驗豐富的全棧型工程師,UI 編程方面還能夠,服務端則只會些皮毛。前面提到的項目是我第一次用 express 編寫真實上線的後端服務,而且也只是作了 SSR 而已。對於 express 我沒什麼特別的感受——既沒以爲很差,也沒以爲出色,所以在重構的時候我打算試試歷來沒有玩過的 Koa。java
不少人都會問「學一個新的框架/工具最好的方法/途徑是什麼?」,我歷來不回答這類的問題,由於我認爲這是一個見仁見智的問題,並且我還認爲只會遵從於別人的規劃是學不到東西的,所謂「因材施教」就是這個意思。固然你能夠說「我是爲了借鑑你們的經驗」,然而如果爲此的話其實能夠問得更巧妙或是具體一些。webpack
在 Koa 這個具體的例子上個人方法其實很簡單,就是把一個用 express 寫過的項目用 Koa 重構一遍。不過對此個人要求很高,這些要求是方方面面的,其中有一個和本文有關,即:使用到的全部第三方的庫都要讀懂其原理,若不費事的話就本身造一遍。學習的方法千千萬,不過裏面總有些通用的法則,個人法則就是求穩不求快。事實上這個法則在後面幫了個人大忙,由於熟悉了幾個典型的 Koa 中間件後,在處理 webpack-dev-middleware 的封裝時就以爲簡單不少。git
對於 webpack-dev-middleware,最直觀簡單的理解就是一個運行於內存中的文件系統。你定義了 webpack.config,webpack 就能據此梳理出全部模塊的關係脈絡,而 webpack-dev-middleware 就在此基礎上造成一個微型的文件映射系統,每當應用程序請求一個文件——好比說你定義的某個 entry
,它匹配到了就把內存中緩存的對應結果做爲文件內容返回給你,反之則進入到下一個中間件。程序員
由於是內存型的文件系統,因此 rebuilding 的速度很是快,所以特別適合於開發階段用做靜態資源服務器;又由於 webpack 能夠把任何一種資源都看成是模塊來處理,所以它徹底能夠取代其餘的 HTTP 服務器。事實上,大多數 webpack 用戶用過的 webpack-dev-server 就是一個 express+webpack-dev-middleware 的實現。兩者的區別僅在於 webpack-dev-server 是封裝好的,除了 webpack.config 和命令行參數以外,你很難去作定製型開發,因此它是適合純前端項目的輔助工具。而 webpack-dev-middleware 是中間件,你能夠編寫本身的後端服務而後把它整合進來,相對而言就自由得多了。咱們作的是一個先後同構的應用,所以 webpack-dev-server 就不予考慮了。github
問題在於 webpack-dev-middleware 是 express 標準的中間件,並不能直接用於 Koa。web
一個標準的 express 中間件是這樣的:express
expressApp.use((req, res, next) => { if (nextNeeded) { // do what you want // until you need down-stream middleware(s) next(); } else { // anything else, e.g. sending response } });
而一個標準的 Koa(v2.x)中間件是這樣的:
server.use((context, next) => Promise.resolve(() => doSomething() .then(() => {/* before next middleware */}) .then(() => next()) .then(() => {/* ... and more */}) .then(() => {/* after down-stream middleware(s) */}) ));
爲何上面要用
Promise.resolve
包一層?……交給你本身探索了。
或者是它的姊妹版,基於 async
的函數形式:
koaApp.use(async (context, next) => { const beforeNextMiddleware = await doSomething(); try { await next(); } catch (error) { context.body = { message: error.message }; context.status = error.status || 500; } return andMore().then(() => evenAfterDownStreams()); });
雖然以上只是一些最基本的概念,真實的中間件還有不少編寫方法與技巧,不過咱們已經看到兩者最顯著的不兼容之處,即它們的參數簽名。若是直接把 webpack-dev-middleware 用於 Koa,顯然因爲 res
不是一個函數是沒有辦法調用的,所以 Koa 會告訴你:next is not a function
。
看來要想把 webpack-dev-middleware 用在 Koa 裏,須要封裝一層中間件來協調兩種不一樣的參數簽名。如上所示,我使用的是 Koa v2,在此以前 Koa 的中間件是基於 ES2015 Generator 來編寫的,Github 上能夠找到適合 Generator 的 webpack-dev-middleware,可是找不到適合 Promise/Async 的現成中間件,因此咱們來本身造輪子吧。
基本上,定義一個返回 Promise 的函數或是一個 Async 函數均可以直接拿來用做 Koa 中間件。不過大多數中間件都會須要 options
,因此慣例上都會用高階函數包一層來傳參:
export default (compiler, options) => async (context, next) => { await next(); }
webpack-dev-middleware 須要兩個參數:
compiler
:能夠經過 webpack(webpackConfig)
獲得
options
:補充 webpack-dev-middleware 須要的特定選項,其中 publicPath
是必須的,而且其值應該等於 webpackConfig.output.publicPath
所以咱們能夠幫用戶檢查 options
是否有效,若不傳 options
就用 compiler
裏的默認構造一份,有的話就沿用。嚴格一點的話你還能夠檢查 publicPath
是否正確,不然拋出異常中斷處理也能夠——這個我就不寫了。另外我還添加了一點點我的偏好的 options
進去,這個是可選的,能夠徹底交給用戶來傳參。
import webpackDevMiddleware from 'webpack-dev-middleware'; // personal taste, totally optional const stats = {chunkModules: false, colors: 'debug' != process.env.NODE_ENV}; export default (compiler, options = {}) => { // this is how we get the original webpack dev middleware, also totally // optional if you willing to let user pass in everything. const {publicPath} = compiler.options.output; const defaults = options.publicPath ? options : {publicPath, stats}; const middleware = webpackDevMiddleware(compiler, Object.assign({}, defaults, options)); // CAUTION: explicitly return middleware here because we don't want to // initialize webpackDevMiddleware instance through every request. return async (context, next) => { await next(); }; }
我補充了比較詳細的註釋來解釋 what & how,初學者應該仔細讀一下里面的經驗之談,順便就當練習英文讀寫了;其實讀源碼的時候常常能獲得這些 tips。另外別忘了看看命令行的輸出,此時若無誤 webpack 自己應該已經正常工做了。
如今咱們手頭上擁有了 express 版本的中間件了,接下來就是考慮如何讓其既能適合 Koa 對於中間件定義的要求,又能作好本身的本職工做。咱們先來看看兩方的參數如何匹配:
express 的 req
:等價於 Koa 的 context.req
express 的 res
:等價於 Koa 的 context.res
express 的 next()
:形式上等價於 Koa 的 next()
可是二者的內涵不一樣,Koa 的 next()
須要返回 Promise 對象(Async 函數是基於 Promise 的語法糖),但 express 的 next()
只是單純的回調函數
好吧,咱們先來試試很天真的作法:
// ...same as above, just pass to async function return async (context, next) => { middleware(context.req, context.res, next); await next(); };
直接調用會讓咱們看到這樣的錯誤:Error: next() called multiple times
,從錯誤的蛛絲馬跡來推斷問題的緣由是每一位初學者的必修課,接下來咱們重溫一遍這個過程看看在沒什麼經驗的狀況下如何處理這個狀況(我不會講很深的東西,由於我也不懂……)。
若是你試圖截斷拋出的異常而後向回追蹤調用棧會發現看不出什麼蛛絲馬跡,頂多就是找到拋出 Error: next() called multiple times
的那一行代碼,然而對解決這個問題並無什麼幫助。思考一下 next()
爲何會屢次(不正確的)調用?此時我並無完全想清楚,可是我獲得了兩條思路:
回顧咱們的代碼,next()
會在 middleware
裏面調用,還會在 middleware
執行以後由 await next()
,注意這兩個 next
的調用場景是不一樣的,並且在這時咱們並不能肯定 middleware
裏面到底有沒有調用 next()
或者能不能正確處理 next()
,這些事情前面分析過。因此去步進一下 middleware
裏的過程應該是比較明顯的線索;
按照中間件的邏輯,它們以棧的方式從上到下(按聲明順序,也就是 app.use(middleware)
)的順序執行,須要繼續的就調用 next()
,已經處理完成的話就能夠直接返回響應,因此後面的 await next()
應該是有條件調用的,由於在我寫這個中間件的時候它以後就只剩下 SSR 的中間件了(調試時,把確認正常且不相干的中間件註釋掉會是一個很好的辦法)。若是它不停的 await next()
,而 SSR 又不能所有處理爲響應的話,出現前面的錯誤也不奇怪。
第二點其實很值得深刻說明,不過因爲第一點能夠當即動手試試,因此咱們先動手看看可否帶領咱們理清第二點的細節。
斷點打到這裏:https://github.com/webpack/webpack-dev-m...,準備步進看看怎麼走的(webpack-dev-middleware 的源碼我就不貼了,Github 上有現成的,後續指示的斷點位置都來自於 Github)。
對於陌生的代碼在閱讀/調試的時候不要急着鑽細節,應該由表及裏,先宏觀再微觀的逐層深刻。像這個例子,斷點打好以後我壓根沒看過程,只是不斷的步進而後留意兩個重點:
有沒有大塊代碼在步進時被跳過。一般這是條件分支/異常處理等情形出現的特徵,留意一下這些地方,在後續調試的時候會是節省時間而且幫助你宏觀理解代碼結構的好幫手;
跳出的位置,一旦跳出了就回退一步(調用棧)看看是什麼狀況。正常的方法調用?(留意一下函數名字大體判斷一下幹什麼去了)仍是拋出異常?(留意一下錯誤對象看看什麼緣由)。
直到步進至前文錯誤出現的地方爲止,我觀察到 webpackDevMiddleware
幾乎就沒作什麼事兒(其實這是很顯然的,我刻意寫了前面那個天真的代碼就爲了經過調試引導到這裏……),從第二行開始就 next()
出去了,此後直到異常重現都再沒有它參與其中。好!咱們仔細看看前兩行,爲何啥都不幹就 next()
?
var filename = getFilenameFromUrl(req.url); if (filename === false) return next();
很顯然,若是從請求的 url 裏匹配不到文件,那麼就跳出 webpackDevMiddleware 剩下的處理邏輯直接進入到下一個中間件了。問題來了:
爲何沒有 next()
到下一個中間件(SSR)而是拋出異常了呢?
什麼狀況下能匹配到文件而不是跳出呢?
第一個問題有興趣能夠步進 next()
一路走下去看看什麼狀況,這裏先告訴你們結論:前面說了 Koa v2.x 的中間件是基於 Promise 的,而此處是直接執行了一個空函數而沒有返回 promise 對象,因此無法順利過渡到後面的中間件。這個問題是不只限於本文的例子的,只要你在 Koa v2.x 下開發早晚都會意識到這一點。咱們在這裏不作繼續的深刻是由於個人環境是使用 babel-transform-async-to-module-method
將 async 函數轉爲 bluebird Promise 實現的,而每一個項目使用的 Promise 實現都未必同樣,不具有通用性,所以這個問題點到爲止。
至於第二個問題這裏要插播一些背景:我在寫這個中間件的時候,用於測試的頁面構成以下:
一個 SSR 渲染的首頁 index.html
,只是最基本的 HTML5 模版,裏面渲染了一個 React 組件作測試
一個 client.js
,前面的組件在這裏調用的,用於接手客戶端渲染
一個 vendor.js
, 包含了一些模塊,好比 React、fetch、bluebird 之類經常使用的工具庫
index.html
天然是第一個請求了,它的裏面引了後兩個腳本,這倆腳本是使用 webpack 打包的,所以它們是應該在 webpack-dev-middleware 裏被匹配到的,而 index.html
則應該經過調用 next()
交由 SSR 渲染。
此時此刻,index.html
第一個到達 webpack-dev-middleware 並走到了調用 next()
這裏,又由於前面提到的非 promise 的 next()
問題致使了異常的拋出,因而下面就……木有了。
行文爲了流暢,以上的闡述天然省卻了一些分析源碼和調試的過程,不過不用擔憂,所謂「眼過千遍不如手過一遍」,要學會給本身找合適的機會親身嘗試一下。不用多,相似的事情作個兩三次就會找到感受,用不了多久就能把貌似一團亂麻的問題梳理的清清楚楚。這裏順便提個小故事,《實用主義程序員》提到的橡膠小黃鴨調試法,建議你們去讀一讀,我的以爲它很是有效。其精髓很簡單:分析問題的時候要一句一句,一點一點的說出來,說給小黃鴨、奧特曼、恐龍特級克塞號……都無所謂,哪怕只是自言自語,可是必定要說出來,要出聲!信不信一試便知。
如今,咱們嘗試一步一步讓 webpack-dev-middleware 和 Koa 和諧共處吧。
首先,若是咱們讓 next()
返回 Promise 的話會如何?
// ...same as above, just pass to async function return async (context, next) => { middleware(context.req, context.res, () => Promise.resolve()) await next(); };
咱們把傳入的 next
替換爲一個返回 promise 的匿名函數再試試看?啊哈~ SSR 成功渲染!
若是你沒有後續的 SSR 中間件也無妨,隨便返回點
Hello World
的簡單中間件也是同樣的:app.use(function(context, next) { context.body = "Hello World" })
放在後面就行。
可是 client.js
和 vendor.js
的請求返回的都是 404,這又是爲啥嘞?像這樣的問題下意識的都會覺得 webpack-dev-middleware 是否是有問題?不過請等,像這種知名的開源項目出現這麼「明顯」問題的機率是很低的,不確信的話能夠掃一下 issues 說不定也能找到答案。不過既然都已經讀了一些源碼了,索性咱繼續日後走看看到底如何吧。
當請求走到兩個腳本文件的時候,前面的 filename
檢查就會跳過 next()
調用了。Line 177-189 之間是一些選項檢查和緩存處理,不用細究。
要注意 Line 191 開始的邏輯,L192 的 processRequest()
是對匹配成功的請求的主要邏輯,因爲首次請求要等待 building 完成才能返回完整的內容,因此 L191 又一個 ready()
作延遲處理(源代碼有註釋),所以斷點要提早打到函數內部,不然眼一閉一睜——沒了~
在這以後我陷入了一段長時間的困惑,由於根據調試的結果,咱們有了表明文件內容的 content
,也執行了必要的 res.setHeader()
,最後儘管 res.send()
方法不存在,但 webpack-dev-middleware 也 fallback 到了 res.end(content)
。按理來講應該是成功走完了纔對,爲何會是 404 呢?
其實早前我曾經提到過,await next()
這句應該是有條件調用的,具體來講:若是 res.end(content)
正確執行了,那麼咱們就應該終止下一個中間件的繼續調用。但目前咱們在 middleware()
執行事後不管如何都會繼續 await next()
,因而個人 SSR 又接手了這些請求。鑑於 SSR 的設計是不去處理腳本等外鏈靜態資源請求的,因此返回 404 也就不難理解了。(涉及 SSR 的部分之後有時間再分享)
討厭的是 webpack-dev-middleware 處理到最後並不會返回什麼,因此咱們拿不到可靠的條件來跳過 await next()
這一句。不過想一想咱們前面處理 next()
的方式吧,咱們不是讓它順利返回了 promise 對象嗎?那麼是否是也可讓 middleware()
也返回些什麼東西呢?咱們須要以下的結構:
// ...same as above, just pass to async function return async (context, next) => { const hasNext = await middleware(context.req, context.res, () => Promise.resolve(true)); if (hasNext === true) { await next(); } };
注意:這一點改動並不能解決前面 404 的問題,由於它只能保證在 next()
被調用時讓咱們經過 await promise 拿到 true
而已。可是這一改動暗示着,若是咱們能讓 middleware()
在不走 next()
的時候最終返回 Promise.resolve(false)
,那麼就能夠跳過不須要的 await next()
了。也就是說,咱們須要包裝 res.send()
和 res.setHeader()
方法讓它們代理 webpack-dev-middleware 裏的同名方法,同時讓 res.send()
返回 Promise.resolve(false)
:
export default (compiler, options = {}) => { // omit options processing... return async (context, next) => { const hasNext = await applyMiddleware(middleware, context.req, { send: content => context.body = content, setHeader: function() {context.set.apply(context, arguments)} }); hasNext && await next(); }; }; function applyMiddleware(middleware, req, res) { const _send = res.send; return new Promise((resolve, reject) => { try { res.send = function() {_send.apply(res, arguments) && resolve(false)}; middleware(req, res, resolve.bind(null, true)); } catch (error) { reject(error); } }); }
這一段變化較大同時又有點燒腦,且容我一一道來:
首先咱們須要單獨寫一個 applyMiddleware
函數用於返回可包含兩種情形的 promise(並且更容易處理異常),且因爲 promise 分離出去處理了,也就不須要單獨封裝 next()
了。
context.res
不能原封不動的傳進去了,不然又走回了 webpack-dev-middleware 的老路數,所以咱們「僞造」了一個恰好夠用的對象,僅實現了 send()
和 setHeader()
這倆方法。前文說過,這是 webpack-dev-middleware 惟二調用的兩個屬於 res
對象的成員。這兩個方法做用不變,可是內部使用的是 Koa 的對等 API,也就是說,當 webpack-dev-middleware 調用它們的時候,咱們將會代理給 Koa 來進行等價處理。
狀態符 hasNext
或真或假,將會由 applyMiddleware
返回,因而 middleware
的調用轉入其中執行。
再看 applyMiddleware
裏面。首先咱們拷貝了一份代理的 res.send
,這是由於該方法須要的 content
參數咱們是沒法直接獲取到的,須要由 webpack-dev-middleware 調用時幫咱們傳進來,而後咱們重寫它並在其中 apply
調用。注意,Koa 等價的 send()
實際上是針對 context.body
的直接賦值,所以 apply(null)
是沒有問題的,可是 setHeader
須要 apply(context)
,不然 this
的指向會出問題。
在這以後,咱們才真正調用 middleware()
,此時 res
已是通過處理的代理對象了,但 webpack-dev-middleware 再次走到 setHeader
和 send
那裏的時候,這倆方法已經「叛變」成了 Koa 的等價處理邏輯,因而真正發揮做用的是 context.body=
和 context.set
,最後 context.body=
實際上是一個 setter,它最終返回的是實際的 content
,咱們知道它是一個真值,因此直接短路至 resolve(false)
。
Tips:
resolve.bind(null, true)
等價於function() { resolve(true) }
——若是你還不清楚這一點的話,它的做用就是幫助next()
返回結果爲真的 promise
再次返回 await applyMiddleware(...)
那裏,這一次 hasNext
會在須要後續中間件介入時爲真,前面 404 的問題迎刃而解。
但願這篇文章絮絮不休的風格多少能帶給初學者一些幫助或引導,Github 上尚未完成咱們這個例子的開源項目,而我也不打算去作這件事情。我想把這個機會留給讀完本文而且尚未在 npm 上發佈過模塊軟件包的新手朋友們,若是你有這個熱情和精力來維護它那就幹吧,在這裏我就權當開源了。等你發佈後若是有問題我也樂意給予力所能及的幫助,衷心但願各位能在編程的道路上越走越遠。