koa 实现 react-view 原理

在之前我们有过一篇『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 来描述过程。

阅读 3k

推荐阅读
pure render
用户专栏

一群志同道合的小伙伴,分享关于 React, Flux 在实践中的经验与想法为 segmentfault 的小伙伴可以看到文...

270 人关注
19 篇文章
专栏主页