(這是一個系列文章:預計會有三期,第一期會以同構構建先後端應用爲主,第二期會以GraphQL和MySQL爲主,第三期會以Docker配合線上部署報警爲主)javascript
做者: 趙瑋龍 (爲何老是我,由於有隊友們無限的支持!!!)html
首先聲明下寫這篇文章的初衷,本身也仍是在全棧之路探索的學徒而已。寫系列文章其一是記錄下本身在搭建整站中的一些心得體會(傳說有一種武功是學了就會忘記的,那就是寫代碼。。。),其二是但願與各位讀者交流下其中遇到的坑和設計思路,懷着向即將出現的留言區學習的心態來此~~java
正片的分界線node
同構應用自己的優缺點我不許備在這裏闡述過多,而且也一直有不少爭論的方向和論點,咱們在這裏就不展開了。固然若是你質疑同構應用的必要性,我也並不否定好比這篇文章就說得很好。那你可能會質疑爲何我還要寫這個主題,緣由是咱們的全棧之路是能讓咱們作各類咱們想作的事情而不受到技術的侷限性。若是說我好奇他們爭論的對錯,順手實現了呢?(但願你也經常抱着這樣的態度去學習,那麼你必定會走的更遠!)react
本文全部技術棧選型以下:webpack
若是你發現不少寫法都變了是時候更新技術棧了少年~git
咱們開始以前先想一下同構應用須要解決哪些問題:github
首先項目開始時咱們先想一個問題運行在 browser 端的代碼能夠完美的運行在 node 端嗎? 固然是不能的,可是咱們同構的目的不就是但願代碼的複用價值提升嗎?咱們先想一下有哪些地方是 node 端不支持的而在 browser 端必須使用的。好比全局 window 對象 node 端是 global ,還有 v10-node 端支持基本全部ES6語法都是支持的。而 browser 端由於瀏覽器兼容性問題並非這樣的,可是 module 方面 node 端卻不支持 import 靜態引用,而瀏覽器端的 webpack 已經支持基於 import 的 tree shaking 了。遇到這麼多兼容問題。。不得不先感嘆一下js執行環境的不一致啊,都統一成v8而且去掉全局變量模塊很差嗎?仍是要有很長路要走的。web
首先配置熟悉的 .babelrc (客戶端的寫法我在第一篇文章中有詳細的說過,能夠移步這裏)其實同構應用只須要讓node端兼容import以及react的jsx就ok了。固然了若是咱們以後用 Babel 天然node的代碼也不會直接運行在遠端機而是會編譯以後再運行。這個其實除去webpack 編譯打包以外還有個小問題無非是node原生模塊好比 require('path') , require('stream') 咱們不但願被打包,這個只須要設置 target:node webpack會幫咱們忽略掉這些模塊。說了這麼多,咱們只是但願咱們以前的 .babelrc 可以打包 node 代碼,因此咱們只須要在入口文件添加一個鉤子 @babel/register (這個@的寫法是 bable7 新版本的模塊寫法,個人第一篇文章中有提到)。下面我來看下咱們可能遇到的第一個坑,本地開發階段咱們須要在開發過程當中利用本身的已有node服務去編譯 webpack 文件。保證客戶端的代碼能夠順利執行。chrome
const webpack = require('webpack');
const logger = require('koa-logger');
const config = require('./webpack.config');
const webpackDevMiddleware = require('./middleware/koa-middleware-dev');
const router = new Router();
const app = new koa();
// const Production = process.env.NODE_ENV === 'production';
const compiler = webpack(config);
// logger記錄
app.use(logger());
// 替換原有的webpack-dev-middleware
app.use(webpackDevMiddleware(compiler, {
publicPath: config.output.publicPath,
}));
複製代碼
先說第一個坑,能夠從代碼中看到咱們本身實現了一個屬於本身的 webpackDevMiddleware ,緣由是由於koa自己沒有成熟的 webpack-dev-middleware 這個插件自己是基於 express 造的,因此咱們就本身實現一個也並不麻煩:
const devMiddleware = require('webpack-dev-middleware');
module.exports = (compiler, option) => {
const expressMiddleware = devMiddleware(compiler, option);
const koaMiddleware = async (ctx, next) => {
const { req } = ctx;
// 修改res的兼容方法
const runNext = await expressMiddleware(ctx.req, {
end(content) {
ctx.body = content;
},
locals: ctx.state,
setHeader(name, value) {
ctx.set(name, value);
}
}, next);
};
// 把webpack-dev-middleware的方法屬性拷貝到新的對象函數
Object.keys(expressMiddleware).forEach(p => {
koaMiddleware[p] = expressMiddleware[p];
});
return koaMiddleware
}
複製代碼
能夠看到咱們主要是要兼容 koa 的 async 函數以及裏面參數的問題, express 的中間件的是 (req, res, next) => {} 而 koa 的中間件是 (ctx, next) => {} 因此咱們須要轉換下形式而且在 express 會有部分 api 和 express 中不一致 致使咱們須要轉換下方法,具體到 webpack-dev-middleware 用到哪些方法有興趣的能夠瀏覽下它的源碼,這裏咱們就不作源碼解析了。簡單說明下只有三個方法在用。
express => koa
res.end => ctx.body 關閉http請求連接,而且設置回覆報文體
res.locals => ctx.state 設置掛載穿透namespace
res.setHeader => ctx.set header設置
複製代碼
首屏渲染咱們要面臨的問題會涉及到先後端路由同構,因此咱們就放在這裏一塊兒說。服務端首屏第一步須要對於路由進行匹配(直接上代碼):
// 採用koa-router的用法
app.use(router.routes())
.use(router.allowedMethods());
appRouter(router);
// 而後設置appRouter函數
module.exports = function(app, options={}) {
// 頁面router設置
app.get(`${staticPrefix}/*`, async (ctx, next) => {
// ...內容
}
// api路由
app.get(`${apiPrefix}/user/info`, async(ctx, next) => {
// ...內容
}
}
// 咱們發現爲了和服務的請求api區分開咱們會在路由的前綴作一下區分固然名字如你所願
複製代碼
既然咱們匹配了 頁面/* 路由,做爲單頁面應用咱們還須要有一個依賴的 layout 模版,先想一下模版須要哪些須要替換信息:
好根據這幾點咱們看一下咱們的 layout 模版應該是大概長什麼樣:
const Production = process.env.NODE_ENV === 'production';
module.exports = function renderFullPage(html, initialState) {
html.scriptUrl = Production ? '' : '/bundle.js';
return ` <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta http-equiv="x-ua-compatible" content="IE=edge, chrome=1"> <meta httpEquiv='Cache-Control' content='no-siteapp' /> <meta name='renderer' content='webkit' /> <meta name='keywords' content='demo' /> <meta name="format-detection" content="telephone=no" /> <meta name='description' content='demo' /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no"> <title>${html.title}</title> </head> <body> <div id="root">${html.body}</div> <script type="application/javascript"> window.__INITIAL_STATE__ = ${JSON.stringify(initialState)} </script> <script src=${html.scriptUrl}></script> </body> </> `
}
// 其中 scriptUrl 會根據後期上線設置的全局變量來改變。咱們開發環境只是把 webpack-dev-middleware 幫咱們打包好放在內存中的bundle.js文件放入html,生產環境的js文件咱們後放到後期系列去說
複製代碼
在發送的過程當中除去 scriptUrl 和 initialState 之外呢,咱們須要一個可替換的 title ,以及 body 可替換的 title 咱們採用 react-helmet 具體的使用方法咱們就很少的贅述了。有興趣的能夠看這裏。
在說如何塞入 body 以前咱們會先去說一下整個渲染過程的流程圖:
+-------------+ +--------------+
| | api, js | |
| +---------------------> |
| SERVER | | CLIENT |
| | | |
| <---------------------+ | +---+---------+ api, js +-------^------+ | | | | | +----------------+ | render | | | | | | HTML | | +------------> +---------+ +----------------+ 複製代碼
咱們看到圖中實際上是第一次會吐出一個涵蓋全部首屏所須要展現內容的完整html裏面的js代碼請求就是咱們以前塞進模版的 scriptUrl ,後續若是還有用戶行爲的操做都會經過js中的請求 api 和服務端交互。這些都和正常的客戶端邏輯沒有區別了。那麼關鍵點在於服務端須要渲染完整的html。 咱們從這裏開始:
// 頁面route match
export const staticPrefix = '/page';
// routes定義
export const routes = [
{
path: `${staticPrefix}/user`,
component: User,
exact: true,
},
{
path: `${staticPrefix}/home`,
component: Home,
exact: true,
},
];
// route裏的component篩選以及拿到相應component裏相應的須要首屏展現依賴的fetchData
const promises = routes.map(
route => {
const match = matchPath(ctx.path, route);
if (match) {
let serverFetch = route.component.loadData
return serverFetch(store.dispatch)
}
}
)
// 注意這時候須要在確認咱們的數據拿到以後才能去正確的渲染咱們的首屏頁面
const serverStream = await Promise.all(promises)
.then(
() => {
return ReactDOMServer.renderToNodeStream(
<Provider store={store}> <StaticRouter location={ctx.url} context={context} > <App/> </StaticRouter> </Provider>
);
}
);
// 這裏的關鍵點咱們會在後面詳細闡述,咱們採用了react 16新的api renderToNodeStream
// 正如這個api的名稱同樣,咱們能夠獲得的不是一個字符串了,而是一個流
// console.log(serverStream.readable); 能夠發現這是一個可讀流
await streamToPromise(serverStream).then(
(data) => {
options.body = data.toString();
if (context.status === 301 && context.url) {
ctx.status = 301;
ctx.redirect(context.url);
return ;
}
if (context.status === 404) {
ctx.status = 404;
ctx.body = renderFullPage(options, store.getState());
return ;
}
ctx.status = 200;
ctx.set({
'Content-Type': 'text/html; charset=utf-8'
});
ctx.body = renderFullPage(options, store.getState());
})
// console.log(serverStream instanceof Stream); 一樣你能夠檢測這個serverStream的數據類型
複製代碼
咱們着重講一下這個流的問題,還有 node 裏面的異步回調的問題。 首先熟悉 node 的同窗確定對流不是很陌生了。這裏咱們只是概念性的說一下。若是想很是詳細的瞭解流,建議仍是去官網和別的專門說流的一些帖子好比國內的 cnode 論壇等。
流是數據的集合 —— 就像數組或字符串同樣。區別在於流中的數據可能不會馬上就所有可用,而且你無需一次性地把這些數據所有放入內存。這使得流在操做大量數據或是數據從外部來源逐段發送過來的時候變得很是有用。
咱們看到這個概念的時候會發現若是發送的首屏的 html 很大的話,採用流的方式反而會減輕服務端的壓力。 既然 react 給咱們封裝了這個 api ,咱們天然能夠發揮它的長處。 咱們來大概掃一眼可讀流和可寫流在 node 中有哪些 api 可用(這裏咱們先不去談可讀可寫流)
可寫流~ events: data ,finish , error, close, pipe/unpipe
可寫流~ functions: write(), end(), cork(), uncork()
可讀流~ events: data, end, error, close, readable,
可讀流~ functions: pipe(), unpipe(), read(), unshift(), resume(), setEncoding()
這裏我能用到的是可讀流,上面代碼中的兩個 console.log() 也是幫咱們肯定了react的流類型。 既然是可讀流咱們須要發送到客戶端能夠利用監聽事件監聽流的發送和中止或者利用 pipe 直接導入到咱們的可寫流 res.write 上發送或者是 end() ,這裏就是 pipe 方法的魔法,它pipe上游必須是一個可讀流,下游是一個可寫流,固然雙向流也是能夠的。那麼思考上面的代碼:
const serverStream = await Promise.all(promises)
.then(
// ...內容
);
// 依然能夠發送咱們的可讀流,可是之因此我沒有這麼寫緣由仍是在於我但願動態的拼寫html,而且在代碼組織上把html模版單獨提出一個文件
res.write('<!DOCTYPE html><html><head><title>My Page</title></head><body>')
res.write('<div id='root'>')
serverStream.pipe(res, { end: false });
serverStream.on('end', () => {
res.write("</div></body></html>");
res.end();
})
// 這麼作會利用流的逐步發送功能達到數據傳輸效率的提高。可是我我的以爲代碼的耦合性比這一些性能優化要來的更加劇要,這個也要根據你的我的需求來定製你喜歡和須要的模式
複製代碼
還有個疑問你可能比較在乎咱們分析下上面代碼:
await streamToPromise(serverStream).then(
// ...內容
)
// 你可能以爲有點奇怪爲何我不用監聽事件呢?而要把這個流包裝在streamToPromise裏,我是怎麼拿到流的變化的呢?
複製代碼
這個詳細的能夠查看streamToPromise源碼其實源碼並不難。咱們的目的是要讓 stream 變成 promise 格式,變幻的過程中主要是監聽讀寫流的不一樣事件利用 buffer 數據格式,在各類相應的狀態去作 promise 化,爲何須要這樣作呢?緣由還在於咱們使用的koa。
咱們都知道 async 函數的原理,若是你想了解更多koa的原理我仍是建議看源碼。咱們這裏要說明下總體緣由,咱們的回調函數會被 koa-router 放到 koa 的中間件use裏,那麼在koa中間件執行順序中是和 async 的執行順序同樣除非你調用 next() 方法,那麼若是你放在stream事件監聽的回調函數裏異步執行,其實這個 router 會由於你沒有設置 res.end() 和 ctx.body 而執行koa 默認的代碼返回404 NotFound因此咱們必須在 await 裏執行咱們的有效返回代碼!在咱們有效返回咱們的模版以後他會涵蓋了咱們的有效模版代碼:
除去這些咱們還會在服務端作相應的 redirect 和 4** 錯誤頁面的一個定位轉發咱們響應準備好的頁面:
// redirect include from to status(3**)
const RedirectWithStatus = ({ from, to, status }) => (
<Route
render={
({ staticContext }) => {
if (staticContext) {
staticContext.status = status;
}
return <Redirect from={from} to={to} />
}
}
/>
);
// 傳遞status給服務端
const Status = ({ code, children }) => (
<Route
render={
({ staticContext }) => {
if (staticContext) {
staticContext.status = code;
}
return children
}
}
/>
);
// 404 page
const NotFound = () => (
<Status code={404}>
<div>
<h1>Sorry, we can't find page!</h1>
</div>
</Status>
);
const App = () => (
<Switch>
{
routes.map((route, index) => (
<Route {...route} key={index} />
))
}
<RedirectWithStatus
from='/page/fuck'
to='/page/user'
status={301}
exact
/>
<Route component={NotFound} />
</Switch>
);
複製代碼
咱們看到其實這些都是在react-router中作的兼容,那咱們怎麼在服務端拿到好比說相應的 status,好比 4** ,3** 這些狀態值,咱們須要在 server 端監控到這些重定向或者沒法找到頁面的狀態。這裏面 react-router 4 給我提供了 context 這個變量,注意它只在 server 端有, 因此在共用一套代碼的時候 須要兼容 if (staticContext) 的寫法保證代碼不會報錯, 而且這個 context 是你本身能夠定義任何你想傳輸的屬性,而且在 server 端也拿獲得:
// 例如這樣的判斷
if (context.status === 301 && context.url) {}
複製代碼
終於該輪到咱們說數據同步的問題了,其實數據同步也很是簡單。咱們這裏利用 redux 來作,其實無論用什麼首先咱們會把剛纔服務端首屏渲染的數據在不經過 api 的方式放鬆給客戶端,那麼毫無疑問只有一個方法:
// 放在頁面html中帶過去,讓客戶端從window對象上拿
<script type="application/javascript">
window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}
</script>
複製代碼
至於 redux 數據的生成其實跟客戶端同樣,若是你感興趣能夠參考我前一篇文章
那麼通過以上的種種坑事後,那麼恭喜你已經有一個同構應用的雛形了。做爲系列文章的開篇每每仍是須要賣一個關子,完整的全棧項目 demo 會在系列完成以後給出 github 地址,敬請期待!
以上所說的全部項目中的體感,見解僅僅表明我的見解,若是你有不一樣的意見和本身更加獨到的看法,期待在下面看到你的留言。仍是那句話,但願你們在共同踩坑的同時共勉前行。也但願這裏的拙見對你可能有所幫助或者啓發!