走在JS上的全棧之路(一)

Functional_Labs發表於2018-05-10

(這是一個系列文章:預計會有三期,第一期會以同構構建前後端應用為主,第二期會以GraphQL和MySQL為主,第三期會以Docker配合線上部署報警為主)

作者: 趙瑋龍 (為什麼總是我,因為有隊友們無限的支援!!!)

首先宣告下寫這篇文章的初衷,自己也還是在全棧之路探索的學徒而已。寫系列文章其一是記錄下自己在搭建整站中的一些心得體會(傳說有一種武功是學了就會忘記的,那就是寫程式碼。。。),其二是希望與各位讀者交流下其中遇到的坑和設計思路,懷著向即將出現的留言區學習的心態來此~~


正片的分界線

同構應用本身的優缺點我不準備在這裡闡述過多,並且也一直有很多爭論的方向和論點,我們在這裡就不展開了。當然如果你質疑同構應用的必要性,我也並不否認比如這篇文章就說得很好。那你可能會質疑為什麼我還要寫這個主題,原因是我們的全棧之路是能讓我們做各種我們想做的事情而不受到技術的侷限性。如果說我好奇他們爭論的對錯,順手實現了呢?(希望你也常常抱著這樣的態度去學習,那麼你一定會走的更遠!)

本文所有技術棧選型如下:

  • node = 10.0.0
  • react >= 16.3.0
  • react-router >= 4.2.0
  • webpack >= 4.6.0
  • isomorphic-fetch >= 2.2.0
  • koa >= 2.5.0
  • koa-router >= 7.4.0
  • react-redux >= 5.0.0
  • redux >= 4.0.0

如果你發現很多寫法都變了是時候更新技術棧了少年~

我們開始之前先想一下同構應用需要解決哪些問題:

  • 程式碼相容性(js宿主環境不一致node, browser)
  • 首屏渲染
  • 首屏渲染後資料同步問題
  • 前後端頁面路由同步

程式碼相容性問題

首先專案開始時我們先想一個問題執行在 browser 端的程式碼可以完美的執行在 node 端嗎? 當然是不能的,但是我們同構的目的不就是希望程式碼的複用價值提高嗎?我們先想一下有哪些地方是 node 端不支援的而在 browser 端必須使用的。比如全域性 window 物件 node 端是 global ,還有 v10-node 端支援基本所有ES6語法都是支援的。而 browser 端因為瀏覽器相容性問題並不是這樣的,但是 module 方面 node 端卻不支援 import 靜態引用,而瀏覽器端的 webpack 已經支援基於 import 的 tree shaking 了。遇到這麼多相容問題。。不得不先感嘆一下js執行環境的不一致啊,都統一成v8並且去掉全域性變數模組不好嗎?還是要有很長路要走的。

首先配置熟悉的 .babelrc (客戶端的寫法我在第一篇文章中有詳細的說過,可以移步這裡)其實同構應用只需要讓node端相容import以及react的jsx就ok了。當然瞭如果我們之後用 Babel 自然node的程式碼也不會直接執行在遠端機而是會編譯之後再執行。這個其實除去webpack 編譯打包之外還有個小問題無非是node原生模組比如 require('path') , require('stream') 我們不希望被打包,這個只需要設定 target:node webpack會幫我們忽略掉這些模組。說了這麼多,我們只是希望我們之前的 .babelrc 能夠打包 node 程式碼,所以我們只需要在入口檔案新增一個鉤子 @babel/register (這個@的寫法是 bable7 新版本的模組寫法,我的第一篇文章中有提到)。下面我來看下我們可能遇到的第一個坑,本地開發階段我們需要在開發過程中利用自己的已有node服務去編譯 webpack 檔案。保證客戶端的程式碼可以順利執行。

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 模版,先想一下模版需要哪些需要替換資訊:

  • 每個頁面的title不同
  • react操作的root節點(替換body)
  • 可替換script標籤內的 window物件下的__INITIAL_STATE__(這個我們會放到後面資料同步去詳細說)
  • 可替換的js檔案(用於客戶端程式碼執行,生產環境和線上環境的js會不一樣。主要依據線上可執行程式碼的打包,webpack的工作,我們到後期系列-釋出環節的時候會提到這個問題!)

好根據這幾點我們看一下我們的 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 裡執行我們的有效返回程式碼!在我們有效返回我們的模版之後他會涵蓋了我們的有效模版程式碼:

html內容

除去這些我們還會在服務端做相應的 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 地址,敬請期待!

以上所說的所有專案中的體感,看法僅僅代表個人看法,如果你有不同的意見和自己更加獨到的見解,期待在下面看到你的留言。還是那句話,希望大家在共同踩坑的同時共勉前行。也希望這裡的拙見對你可能有所幫助或者啟發!

相關文章