[关闭]
@FunC 2018-01-02T16:34:19.000000Z 字数 7663 阅读 1802

Node.js Desigin Patterns | CH08

Node.js


为 Web 应用编写通用 JavaScript

与浏览器共享代码

尽管 Node.js 和最流行的浏览器—Chrome 一样,都使用了 V8 引擎,然而想要让代码能够共享还是需要花费一定的功夫。例如 Node.js 中没有 DOM, 而浏览器端没有读写文件的能力。同时 Node.js 已经支持大部分 ES2015 的新特性,而浏览器方面则推进缓慢。

共享模块—UMD(通用模块定义)

浏览器端对模块的支持/实现是非常零散的,可能有下面几种情况:
* 没有模块系统,依靠全局对象访问不同模块
* 有基于 AMD(异步模块定义)的环境
* 有将 CommonJS 模块系统抽象出来的环境

而使用 UMD,我们能将代码从模块系统中抽象出来,直接在环境中使用。

创建一个 UMD 模块

观察以下代码:
umdModule.js

  1. (function(root, factory) {
  2. // 检测是否存在 AMD 环境
  3. if (typeof define === 'function' && define.amd) {
  4. define(['mustache'], factory);
  5. // 检测 CommonJS 环境
  6. } else if (typeof module === 'object' && typeof module.exports === 'object') {
  7. var mustache = require('mustache');
  8. module.exports = factory(mustache);
  9. // 使用全局对象
  10. } else {
  11. root.UmdModule = factory(root.Mustache);
  12. }
  13. // 采用依赖注入的方式
  14. } (this, function(mustache) {
  15. var template = '<h1>Hello <i>{{name}}</i></h1>';
  16. mustache.parse(template);
  17. return {
  18. sayHello: function(toWhom) {
  19. return mustache.render(template, {name: toWhom});
  20. }
  21. };
  22. }));

上述代码定义了一个模块,它依赖于模块 mustache,我们通过依赖注入的方式引入。
1. 首先,利用 typeof 操作符检测是否存在 define 函数和 define.amd标志,以判断是否存在 AMD 环境。
2. 若不存在 AMD 环境,检测是否存在 modulemodule.exports,以判断是否存在 CommonJS 模块环境。
3. 若依然没有,则使用全局对象。要求依赖的名称必须为Mustache
4. 传入 this 作为全局对象(浏览器下为 window),以及一个工厂函数,触发自执行函数。

对 UMD 模式的思考

因为 UMD 模式需要书写大量样板代码(boilerplate),导致难以在每个环境中进行测试。因此,UMD 模式实际上只适合用于包裹已经开发完整且经过测试的模块,而不是从头开发。我们应该使用 Webpack 等工具帮助我们自动化完成。
此外,AMD,CommonJS 和 浏览器的全局对象这三者不等同于全部的模块系统(例如 ES2015 的模块规范),上述的 UMD 样板代码并不能覆盖全部的模块系统。

ES2015 模块

ES2015 标准的其中一个特性,就是引入了内置的模块系统。然而至今仍未完全实现,此处仅作简单介绍。
ES2015 模块的目标是汲取 CommonJS 和 AMD 模块的优点:
* 像 CommonJS 一样支持单点导出(single exports)和循环依赖
* 像 AMD 一样支持异步加载和可配置模块加载
* 此外,得益于声明式语法,支持静态分析和优化,从而实现按需打包(bundle),缩短加载时间。

跨平台开发基础

当为不同的平台进行开发时,最常见的问题,就是如何复用公共部分,并为平台相关的部分提供特别的实现。

运行时的代码切分(Runtime code branching)

根据平台的不同,动态地去切换代码的分支是一个最简单且符合直觉的做法。例如:

  1. if (typeof window !== "undefined" && window.document) {
  2. console.log('Hey browser!');
  3. } else {
  4. console.log('Hey Node.js!');
  5. }

当然,这种做法也会有一些不便之处:
* 不同平台的代码放在同一个模块中,最终的 bundle 里无法到达(unreachable)的代码量大幅上升
* 过分使用会降低可读性
* 即使使用动态切换来加载不同的分支,仍会将所有的模块都打包到最终的 bundle 中(因为在构建时无法得知运行时的变量)

出于同样原因,使用变量来引入的模块有可能不被打包进 bundle 中:

  1. // 构建时无法得知 moduleList 的内容
  2. moduleList.forEach(function(module) {
  3. require(module);
  4. });

不过 Webpack 克服了其中的一些限制,例如它会猜测所有可能的值:

  1. // Webpack 会将 controller 目录下的所有模块打包进 bundle
  2. function getController(controllerName) {
  3. return require("./controller/" + controllerName);
  4. }

建议研读官方文档,查看更多支持的案例

构建时的代码切分(Build-time code branching)

先前提到,Webpack 还支持使用插件,来扩展我们的处理过程。下面使用两个内置插件DefinePlugin UglifyJsPlugin 进行演示:
main.js

  1. if (typeof __BROWSER__ !== "undefined") {
  2. console.log('Hey browser!');
  3. } else {
  4. console.log('Hey Node.js!');
  5. }

webpack.config.js

  1. const path = require('path');
  2. const webpack = require('webpack');
  3. // 将 __BROWSER__ 替换成 “true”
  4. const definePlugin = new webpack.DefinePlugin({
  5. "__BROWSER__": "true"
  6. });
  7. const uglify = new webpack.optimize.UglifyJsPlugin({
  8. // 保留空格和缩进
  9. beautify: true,
  10. // 去掉无法到达的代码
  11. dead_code: true
  12. });
  13. module.exports = {
  14. entry: path.join(__dirname, "src", "main.js"),
  15. output: {
  16. path: path.join(__dirname, "dist"),
  17. filename: "bundle.js"
  18. },
  19. plugins: [definePlugin, uglify]
  20. };

其中,DefinePlugin 搜索源文件,并将所有的 BROWSER 替换成“true”
if (true !== “undefined”)
然后去除 dead code 之后,最后代码就变成了:

  1. console.log('Hey browser!');

其中,DefinePlugin 还有很多不同的用法。例如还可以根据环境变量的内容,当前的时间戳,上一次 git commit 的 hash 值,当前用户,当前操作系统等。

模块交换(Module swapping)

大多数时候,在构建的时候我们就知道哪些代码是需要包括进 bundle 中的。
这时,我们可以在 webpack 中的配置文件中,批量切换模块:

  1. const path = require('path');
  2. const webpack = require('webpack');
  3. // 将所有以 alertServer.js 结尾的文本替换成 ./alertBrowser.js
  4. let moduleReplacementPlugin =
  5. new webpack.NormalModuleReplacementPlugin(/alertServer.js$/, './alertBrowser.js');
  6. module.exports = {
  7. entry: path.join(__dirname, "src", "main.js"),
  8. output: {
  9. path: path.join(__dirname, "dist"),
  10. filename: "bundle.js"
  11. },
  12. plugins: [moduleReplacementPlugin]
  13. };

Webpack 的出色之处,在于它还会解析出完成的依赖树。例如如果切换的模块依赖 jQuery,它会将 jQuery 也自动打包到 bundle 中。

设计模式在跨平台开发中的应用

尽管各种设计模式都非常的有用,但更重要的是开发者如何选择最佳的方式去解决不同的问题。

创建通用的 JavaScript app

以编写一个含服务端渲染的 react SPA 为例,记录一些要点

路由复用

前端路由使用 HTML5 的 history API 时,后端需要提供相应的路由,这点可以通过复用路由实现。
先将路由的配置单独抽出来:
routesConfig.js

  1. // components
  2. const AuthorsIndex = require('./components/authorsIndex');
  3. const AuthorPage = require('./components/authorPage');
  4. const NotFound = require('./components/notFound');
  5. // config
  6. const routesConfig = [
  7. {path: '/', component: AuthorsIndex},
  8. {path: '/author/:id', component: AuthorPage},
  9. {path: '*', component: NotFound}
  10. ];

然后后端路由引入同样的配置:
server.js

  1. // ...
  2. const ReactDom = require('react-dom/server');
  3. // ...
  4. // base on Express
  5. app.get('*', (req, res) => {
  6. // 用同样的路由配置匹配路径
  7. Router.match({routes: routesConfig, location: req.url}, (error, redirectLocation, renderProps) => {
  8. if (error) {
  9. res.status(500).send(error.message)
  10. } else if (redirectLocation) {
  11. // 重定向
  12. res.redirect(302, redirectLocation.pathname + redirectLocation.search)
  13. } else if (renderProps) {
  14. // 服务端渲染相应的页面
  15. const markup = ReactDom.renderToString(<Router.RouterContext {...renderProps} />);
  16. res.render('index', {markup});
  17. } else {
  18. res.status(404).send('Not found')
  19. }
  20. });
  21. });

值得注意的是,在服务端中引入react-dom/server作为 ReactDom。浏览器收到这种方式渲染出来的页面时,就知道无需重复渲染组件,而只需要给现有的 DOM 节点附上事件监听器即可。

API 服务器

API 服务器用于根据特定的请求,返回相应的数据(一般是 JSON 格式)。

API 服务器应该完全和后端服务器分离,以便于独立扩容。最简单的方法是在另一个端口新建一个服务器(如后端用3000,API 服务器用3001)
但这样就会造成跨域问题,不过解决方案也很简单,只需要后端服务器接受所有请求,并且将 API 的请求代理到 API 服务器即可,如下图所示:

其中代理的部分可以使用http-proxy模块轻松实现。

通用的 API 请求客户端

因为 API 要在两个不同的环境中使用,请求的路径也不同。
* 在浏览器端,使用 AJAX请求相对路径,如/api
* 服务端直接使用内置的 http库或者封装好的request模块请求 http://localhost:3001

在不同的环境请求相同的 API,需要编写不同的代码。为了减轻这方面的负担,axios库将其封装成一致的接口,让请求 API 的代码也能在两端复用。

axios还需要经过一点小小的封装才能直接复用:

  1. const Axios = require('axios');
  2. // 后端请求的是绝对路径,这里可以写成可传入参数配置的形式
  3. const baseURL = typeof window !== 'undefined' ? '/api' : 'http://localhost:3001';
  4. const xhrClient = Axios.create({baseURL});
  5. module.exports = xhrClient;

非常简单但又非常有用的封装

异步组件

React 中想使用异步组件的话,需要引入 react-router 的一个插件async-props
然后给原本的组件加上一个静态方法:

  1. const React = require('react');
  2. const Link = require('react-router').Link;
  3. const xhrClient = require('../xhrClient');
  4. class AuthorsIndex extends React.Component {
  5. // context 指当时的 router,cb 用于通知 router 组件已经准备好了
  6. static loadProps(context, cb) {
  7. xhrClient.get('authors')
  8. .then(response => {
  9. const authors = response.data;
  10. cb(null, {authors});
  11. })
  12. .catch(error => cb(error))
  13. ;
  14. }
  15. render() {
  16. // ...
  17. }
  18. }
  19. module.exports = AuthorsIndex;

客户端只需对 render 函数稍作修改:

  1. const React = require('react');
  2. // 引入 AsyncProps 组件
  3. const AsyncProps = require('async-props').default;
  4. const ReactRouter = require('react-router');
  5. const Router = ReactRouter.Router;
  6. const browserHistory = ReactRouter.browserHistory;
  7. const routesConfig = require('./routesConfig');
  8. class Routes extends React.Component {
  9. render() {
  10. return <Router
  11. history={browserHistory}
  12. routes={routesConfig}
  13. // 修改 render 函数
  14. render={(props) => <AsyncProps {...props}/>}
  15. />;
  16. }
  17. }
  18. module.exports = Routes;

服务端渲染部分:

  1. // ...
  2. // 代理
  3. const httpProxy = require('http-proxy');
  4. // 异步组件支持
  5. const AsyncProps = require('async-props').default;
  6. const loadPropsOnServer = require('async-props').loadPropsOnServer;
  7. // 创建 API 服务器的代理
  8. const proxy = httpProxy.createProxyServer({
  9. target: 'http://localhost:3001'
  10. });
  11. app.use('/api', (req, res) => {
  12. proxy.web(req, res);
  13. });
  14. app.get('*', (req, res) => {
  15. Router.match({routes: routesConfig, location: req.url}, (error, redirectLocation, renderProps) => {
  16. if (error) {
  17. res.status(500).send(error.message)
  18. } else if (redirectLocation) {
  19. res.redirect(302, redirectLocation.pathname + redirectLocation.search)
  20. } else if (renderProps) {
  21. // 因为需要等待异步数据到位,不能直接调用.renderToString()
  22. loadPropsOnServer(renderProps, {}, (err, asyncProps, scriptTag) => {
  23. const markup = ReactDom.renderToString(<AsyncProps {...renderProps} {...asyncProps} />);
  24. // scriptTag 中的是保存起来的异步数据,让前端不用重复请求
  25. res.render('index', {markup, scriptTag});
  26. });
  27. } else {
  28. res.status(404).send('Not found')
  29. }
  30. });
  31. });
  32. // ...

值得注意的是 scriptTag, 里面包含的一些需要放在 HTML 中的 JavaScript 代码,主要是包含了一些服务端渲染时用到的异步数据,避免前端重复请求(类似于 Vue 中的 INITIAL_STATE
scriptTag 在 ejs 模版中的放置:

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8"/>
  5. <title>React Example - Authors archive</title>
  6. </head>
  7. <body>
  8. <div id="main"><%- markup %></div>
  9. <script src="/dist/bundle.js"></script>
  10. <!-- scriptTag 的内容形如 window.asyncData = { key = value } -->
  11. <%- scriptTag %>
  12. </body>
  13. </html>

至此基本包含了编写通用 JavaScript 应用需要注意的点。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注