koa 實現 react-view 原理

Arcthur發表於2016-05-25

在之前我們有過一篇『React 同構實踐與思考』的專欄文章,給讀者實踐了用 React 怎麼實現同構。今天,其實講的是在實現同構過程中看到過,可能非常容易被忽視更小的一個點 —— React View。

React View

每一個 BS 架構的框架都會涉及到 View 層的展現,Koa 也不例外。我們在做 View 層的時候有兩種做法,一種是做成外掛形式,對於 View 來說就是模板引擎,另一種是做成中件間的形式。

再說到 React,常常有人說它是增強版的模板引擎。這種說法即對也不對。

從表象來看的確,React 可以替換變數,有條件判斷,有迴圈判斷,JSX 語法讓渲染過程和 HTML 沒什麼兩樣,畢竟說到底 React 就是 JavaScript,而 React 所推崇的無狀態函式,也徹徹底底把 React 變成了像是模板的樣子。

從內在來看,React 它還是 JavaScript,它可以方便地做模組化管理,有內部狀態,有自己的資料流。它可以做一部分 Controller,或者說,可以完全承擔 Controller 的工作。

但是在服務端,我們需要模板是為了作 HTML 的同步請求,因此說地簡單一些就只需要渲染成 HTML 的功能就可以了。當然,特殊的一點是,之所以讓 React 作模板就是可以讓服務端跑到客戶端的渲染邏輯,並解決單頁應用常常詬病的載入後白屏的問題。

言歸正傳,現在我們就帶著 React View 怎麼實現這個問題來解讀原始碼。

React-View 原始碼解讀

配置

配置是設計的源頭之一,一切原始碼都可以從配置入手研究。

var defaultOptions = {
  doctype: `<!DOCTYPE html>`,
  beautify: false,
  cache: process.env.NODE_ENV === `production`,
  extname: `jsx`,
  writeResp: true,
  views: path.join(__dirname, `views`),
  internals: false
};

如果我們用過像 handlebars 或是 jade View,我們看到 React View 的配置與其它 View 的配置有幾點不同。doctype、internals 這些配置都是其它模板引擎不會有的。

模板常用的配置應該是什麼呢?

  1. viewPath,在上述配置指的是 view,就是 View 的目錄在哪裡,這是每一個模板外掛或中介軟體都需要去配的。

  2. extname,字尾名是什麼,一般來說模板引擎都有自己獨有的字尾,當然不排除可以有喜好選擇的情況。比如對 React 而言,就可以寫成是 .jsx.js 兩種不同的形式。

  3. cache,我想一般模板引擎都會帶 cache 功能,因為模板的解析是需要耗費資源的,而模板本身的改動的頻度是非常低的。每當釋出的時候,我們去重新整理一次模板即可。但上述配置中的 cache 並不是指這個,我們等讀原始碼時再來看。

渲染

標準的渲染過程其實非常的簡單。對於 React 來說就是讀取目錄下的檔案,像前端載入一樣,require 那個檔案。最後利用 ReactDOMServer 中的方法來渲染。

var render = internals
  ? ReactDOMServer.renderToString
  : ReactDOMServer.renderToStaticMarkup;

...

var markup = options.doctype || ``;
try {
  var component = require(filepath);
  // Transpiled ES6 may export components as { default: Component }
  component = component.default || component;
  markup += render(React.createElement(component, locals));
} catch (err) {
  err.code = `REACT`;
  throw err;
}

if (options.beautify) {
  // NOTE: This will screw up some things where whitespace is important, and be
  // subtly different than prod.
  markup = beautifyHTML(markup);
}

var writeResp = locals.writeResp === false
    ? false
    : (locals.writeResp || options.writeResp);
      
if (writeResp) {
  this.type = `html`;
  this.body = markup;
}

return markup

這裡我們擷取最關鍵的片段,正如我們預估的渲染過程一樣。但我們看到,從流程上看有四個細節:

設定 doctype 的目的

在一般模板中我們很少看到將 doctype 放在配置中配置,但因為 React 的特殊性,讓我們不得不這麼做。原因很簡單,React render 方法返回時一定需要一個包裹的元素,比如 div,ul,甚至 html,因此,我們需要手動去加 doctype。

渲染 React 元件

renderToStringrenderToStaticMarkup 都是 `react-dom/server` 下的方法,與 render 不同,render 方法需要指定具體渲染到 DOM 上的節點,但那兩個方法都只返回一段 HTML 字串。這一點讓 React 成為模板語言而存在。它們兩個方法的區別在於:

  • renderToString 方法渲染的時候帶有 data-reactid 屬性,意味著可以做 server render,React 在前端會認識服務端渲染的內容,不會重新渲染 DOM 節點,開始執行 componentDidMount 繼續執行後續生命週期。

  • renderToStaticMarkup 方法渲染時沒有 data-reactid,把 React 當做是純模板來使用,這個時候只渲染 body 外的框架是比較合適的。

render 方法裡,我們看到 React.createElement 方法。是因為在服務端 render 方法沒有 babel 編譯,因此寫的其實是 <component {...locals} /> 編譯後的程式碼。

美化 HTML

options.beautify 配置了我們是否要美化 HTML,預設時是關閉的。任何需要編譯的模板引擎一般都會有類似的配置。在 Reat 中,因為 render 後的程式碼是一連串的字串,返回到前臺的時候都是無法閱讀的程式碼。在有必要時,我們可以開啟這個配置。

繫結到上下文

最後一步,儘管有一個開關控制,但我們看到最後是把內容繫結到 this.body 下的。 這裡省略了整個實現過程是在 app.context.render 方法下,即是重寫了 app.context 下的 render 方法,用於渲染 React。如果說 app.context.render 方法是 function*,那麼我們的 react-view,就會變為中介軟體。

Cache

我們從一開始就看到了配置中就有 cache 配置,這個 cache 是不是我們所想呢?我們來看下原始碼:

// match function for cache clean
var match = createMatchFunction(options.views);

...

if (!options.cache) {
  cleanCache(match);
}

這裡的 cache 指的是模板快取麼。事實上不完全是,我們來看一下 cleanCache 方法就明白了:

function cleanCache(match) {
  Object.keys(require.cache).forEach(function(module) {
    if (match(require.cache[module].filename)) {
      delete require.cache[module];
    }
  });
}

因為我們讀取 React 檔案用的是 require 方法,而在 Node 中 require 方法是有快取的,Node 在每個第一次 Load Module 時就會將該 Module 快取,存入全域性的 _cache 中,在一般情況下我們當然需要這麼做。但在模板載入這個情景下就不同了。

在這裡的確我們全域性快取了 React 模板檔案,但這個檔案是編譯前的檔案。而我們需要快取的是編譯後的檔案,也就是說 markup 是我們需要快取的值。

在這裡我們想想怎麼去實現,方便起見,我們可以新增一個 lru-cache,用它的好處是 lru 封裝了很多關於 cache 時效與容量的開關。

var LRU = require("lru-cache");
var cache = LRU(this.options.cacheOptions);

...

if (options.cache && cache.get(filepath)) {
  markup = cache.get(filepath);
} else {
  var markup = options.doctype || ``;
  try {
    var component = require(filepath);
  } else {
      // Transpiled ES6 may export components as { default: Component }
      component = component.default || component;
      markup += render(React.createElement(component, locals));
    }
  } catch (err) {
    err.code = `REACT`;
    throw err;
  }

  // beautify ...
  
  if (options.cache) {
    cache.set(filepath, markup);
  }
}

當然,我們現在這種情形下都需要清除 require 的 cache。

Babel

我想很多開發者在寫 React 元件的時候用的是 ES6 Class 來寫的,而且會用到很多 ES6/ES7 的方法,不巧的是 Node 還不支援有些高階特性。因此就引到了一個話題,服務端怎麼引用 babel?

在業務有 babel-node 這類解決方案,但這畢竟是一個實驗性的 Node,我們不會拿生產環境去冒險。

在 koa/react-view 中介軟體內,有一段說明,它建議開發者在使用的時候加入 babel-register 作實時編譯。關於這個問題,當然也可以寫在中介軟體內,在載入模板前引入。隨著 Node 對 ES6 方法支援的完善,也許有一天也用不到了。

總結

其實,實現 View 非常簡單,我們也從一些維度看到了設計一個 xx-view 的一般方法。在具體實現的時候,我們可以用一些更好的方法去做,比如用類來抽象 View,用 Promise 來描述過程。

相關文章