# 使用 bootstrap

源码 (opens new window)

# MVC

我们已经可以用 koa 处理不同的 URL,还可以用 Nunjucks 渲染模板。现在,是时候把这两者结合起来了!

当用户通过浏览器请求一个 URL 时,koa 将调用某个异步函数处理该 URL。在这个异步函数内部,我们用一行代码:

ctx.render("home.html", { name: "Michael" });
1

通过 Nunjucks 把数据用指定的模板渲染成 HTML,然后输出给浏览器,用户就可以看到渲染后的页面了:

这就是传说中的 MVC:Model-View-Controller,中文名“模型-视图-控制器”。

异步函数是 C:Controller,Controller 负责业务逻辑,比如检查用户名是否存在,取出用户信息等等;

包含变量的模板就是 V:View,View 负责显示逻辑,通过简单地替换一些变量,View 最终输出的就是用户看到的 HTML。

MVC 中的 Model 在哪?Model 是用来传给 View 的,这样 View 在替换变量的时候,就可以从 Model 中取出相应的数据。

上面的例子中,Model 就是一个 JavaScript 对象:

{
  name: "Michael";
}
1
2
3

# 整理目录

下面,我们根据原来的工程创建工程 view-koa,把 koa2、Nunjucks 整合起来,然后,把原来直接输出字符串的方式,改为ctx.render(view, model)的方式。

工程 view-koa 结构如下:

view-koa/
|
+- .vscode/
|  |
|  +- launch.json <-- VSCode 配置文件
|
+- controllers/ <-- Controller
|
+- views/ <-- html模板文件
|
+- static/ <-- 静态资源文件
|
+- controller.js <-- 扫描注册Controller
|
+- app.js <-- 使用koa的js
|
+- package.json <-- 项目描述文件
|
+- node_modules/ <-- npm安装的所有依赖包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 安装依赖

在 package.json 中,我们将要用到的依赖包有:

{
    "koa": "2.13.1",
    "koa-bodyparser": "4.3.0",
    "koa-router": "10.0.0",
    "nunjucks": "3.2.2",
    "mime": "1.3.4",
    "mz": "2.7.0"
}
1
2
3
4
5
6
7
8

先用npm install安装依赖包。

然后,我们准备编写以下两个 Controller:

处理首页 GET / 我们定义一个 async 函数处理首页 URL/:

async (ctx, next) => {
  ctx.render("index.html", {
    title: "Welcome",
  });
};
1
2
3
4
5

注意到 koa 并没有在 ctx 对象上提供 render 方法,这里我们假设应该这么使用,这样,我们在编写 Controller 的时候,最后一步调用 ctx.render(view, model)就完成了页面输出

# 处理登录请求 POST /signin

我们再定义一个 async 函数处理登录请求/signin:

async (ctx, next) => {
  var email = ctx.request.body.email || "",
    password = ctx.request.body.password || "";
  if (email === "admin@example.com" && password === "123456") {
    // 登录成功:
    ctx.render("signin-ok.html", {
      title: "Sign In OK",
      name: "Mr Node",
    });
  } else {
    // 登录失败:
    ctx.render("signin-failed.html", {
      title: "Sign In Failed",
    });
  }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

由于登录请求是一个 POST,我们就用ctx.request.body.<name>拿到 POST 请求的数据,并给一个默认值。

登录成功时我们用signin-ok.html渲染,登录失败时我们用signin-failed.html渲染,所以,我们一共需要以下 3 个 View:

  • index.html
  • signin-ok.html
  • signin-failed.html

# 编写 View

在编写 View 的时候,我们实际上是在编写 HTML 页。为了让页面看起来美观大方,使用一个现成的 CSS 框架是非常有必要的。我们用 Bootstrap 这个 CSS 框架。从首页下载 zip 包后解压,我们把所有静态资源文件放到/static 目录下:

view-koa/
|
+- static/
   |
   +- css/ <- 存放bootstrap.css等
   |
   +- fonts/ <- 存放字体文件
   |
   +- js/ <- 存放bootstrap.js等
1
2
3
4
5
6
7
8
9

这样我们在编写 HTML 的时候,可以直接用 Bootstrap 的 CSS,像这样:

<link rel="stylesheet" href="/static/css/bootstrap.css">
1

现在,在使用 MVC 之前,第一个问题来了,如何处理静态文件?

我们把所有静态资源文件全部放入/static 目录,目的就是能统一处理静态文件。在 koa 中,我们需要编写一个 middleware,处理以/static/开头的 URL。

# 编写 middleware

我们来编写一个处理静态文件的 middleware。编写 middleware 实际上一点也不复杂。我们先创建一个 static-files.js 的文件,编写一个能处理静态文件的 middleware:

koa 有现成 npm 包 koa-static (opens new window)

const path = require("path");
const mime = require("mime");
const fs = require("mz/fs");

// url: 类似 '/static/'
// dir: 类似 __dirname + '/static'
function staticFiles(url, dir) {
  return async (ctx, next) => {
    let rpath = ctx.request.path;
    // 判断是否以指定的url开头:
    if (rpath.startsWith(url)) {
      // 获取文件完整路径:
      let fp = path.join(dir, rpath.substring(url.length));
      // 判断文件是否存在:
      if (await fs.exists(fp)) {
        // 查找文件的mime:
        ctx.response.type = mime.lookup(rpath);
        // 读取文件内容并赋值给response.body:
        ctx.response.body = await fs.readFile(fp);
      } else {
        // 文件不存在:
        ctx.response.status = 404;
      }
    } else {
      // 不是指定前缀的URL,继续处理下一个middleware:
      await next();
    }
  };
}

module.exports = staticFiles;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

staticFiles 是一个普通函数,它接收两个参数:URL 前缀和一个目录,然后返回一个 async 函数。这个 async 函数会判断当前的 URL 是否以指定前缀开头,如果是,就把 URL 的路径视为文件,并发送文件内容。如果不是,这个 async 函数就不做任何事情,而是简单地调用 await next()让下一个 middleware 去处理请求。

我们使用了一个 mz 的包,并通过 require('mz/fs');导入。mz 提供的 API 和 Node.js 的 fs 模块完全相同,但 fs 模块使用回调,而 mz 封装了 fs 对应的函数,并改为 Promise。这样,我们就可以非常简单的用 await 调用 mz 的函数,而不需要任何回调。

所有的第三方包都可以通过 npm 官网搜索并查看其文档:

https://www.npmjs.com/ (opens new window)

最后,这个 middleware 使用起来也很简单,在 app.js 里加一行代码:

let staticFiles = require("./static-files");
app.use(staticFiles("/static/", __dirname + "/static"));
1
2

注意:也可以去 npm 搜索能用于 koa2 的处理静态文件的包并直接使用。

# 集成 Nunjucks

集成 Nunjucks 实际上也是编写一个 middleware,这个 middleware 的作用是给 ctx 对象绑定一个 render(view, model)的方法,这样,后面的 Controller 就可以调用这个方法来渲染模板了。

我们创建一个templating.js来实现这个 middleware:

koa 也用现成模板工具koa-swig (opens new window)

const nunjucks = require("nunjucks");

function createEnv(path, opts) {
  var autoescape = opts.autoescape === undefined ? true : opts.autoescape,
    noCache = opts.noCache || false,
    watch = opts.watch || false,
    throwOnUndefined = opts.throwOnUndefined || false,
    env = new nunjucks.Environment(
      new nunjucks.FileSystemLoader(path || "views", {
        noCache: noCache,
        watch: watch,
      }),
      {
        autoescape: autoescape,
        throwOnUndefined: throwOnUndefined,
      },
    );
  if (opts.filters) {
    for (var f in opts.filters) {
      env.addFilter(f, opts.filters[f]);
    }
  }
  return env;
}

function templating(path, opts) {
  // 创建Nunjucks的env对象:
  var env = createEnv(path, opts);
  return async (ctx, next) => {
    // 给ctx绑定render函数:
    ctx.render = function(view, model) {
      // 把render后的内容赋值给response.body:
      ctx.response.body = env.render(
        view,
        Object.assign({}, ctx.state || {}, model || {}),
      );
      // 设置Content-Type:
      ctx.response.type = "text/html";
    };
    // 继续处理请求:
    await next();
  };
}

module.exports = templating;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

注意到 createEnv()函数和前面使用 Nunjucks 时编写的函数是一模一样的。我们主要关心 tempating()函数,它会返回一个 middleware,在这个 middleware 中,我们只给 ctx“安装”了一个 render()函数,其他什么事情也没干,就继续调用下一个 middleware。

使用的时候,我们在 app.js 添加如下代码:

const isProduction = process.env.NODE_ENV === "production";

app.use(
  templating("views", {
    noCache: !isProduction,
    watch: !isProduction,
  }),
);
1
2
3
4
5
6
7
8

这里我们定义了一个常量 isProduction,它判断当前环境是否是 production 环境。如果是,就使用缓存,如果不是,就关闭缓存。在开发环境下,关闭缓存后,我们修改 View,可以直接刷新浏览器看到效果,否则,每次修改都必须重启 Node 程序,会极大地降低开发效率。

Node.js 在全局变量 process 中定义了一个环境变量 env.NODE_ENV,为什么要使用该环境变量?因为我们在开发的时候,环境变量应该设置为'development',而部署到服务器时,环境变量应该设置为'production'。在编写代码的时候,要根据当前环境作不同的判断。

注意:生产环境上必须配置环境变量 NODE_ENV = 'production',而开发环境不需要配置,实际上 NODE_ENV 可能是 undefined,所以判断的时候,不要用 NODE_ENV === 'development'。

类似的,我们在使用上面编写的处理静态文件的 middleware 时,也可以根据环境变量判断:

if (!isProduction) {
  let staticFiles = require("./static-files");
  app.use(staticFiles("/static/", __dirname + "/static"));
}
1
2
3
4

这是因为在生产环境下,静态文件是由部署在最前面的反向代理服务器(如 Nginx)处理的,Node 程序不需要处理静态文件。而在开发环境下,我们希望 koa 能顺带处理静态文件,否则,就必须手动配置一个反向代理服务器,这样会导致开发环境非常复杂。

# 编写 View

在编写 View 的时候,非常有必要先编写一个 base.html 作为骨架,其他模板都继承自 base.html,这样,才能大大减少重复工作。

编写 HTML 不在本教程的讨论范围之内。这里我们参考 Bootstrap 的官网简单编写了 base.html。

# 运行

一切顺利的话,这个 view-koa 工程应该可以顺利运行。运行前,我们再检查一下 app.js 里的 middleware 的顺序:

第一个 middleware 是记录 URL 以及页面执行时间:

app.use(async (ctx, next) => {
  console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);
  var start = new Date().getTime(),
    execTime;
  await next();
  execTime = new Date().getTime() - start;
  ctx.response.set("X-Response-Time", `${execTime}ms`);
});
1
2
3
4
5
6
7
8

第二个 middleware 处理静态文件:

if (!isProduction) {
  let staticFiles = require("./static-files");
  app.use(staticFiles("/static/", __dirname + "/static"));
}
1
2
3
4

第三个 middleware 解析 POST 请求:

app.use(bodyParser());
1

第四个 middleware 负责给 ctx 加上 render()来使用 Nunjucks:

app.use(
  templating("view", {
    noCache: !isProduction,
    watch: !isProduction,
  }),
);
1
2
3
4
5
6

最后一个 middleware 处理 URL 路由:

app.use(controller());
1

现在,在 VS Code 中运行代码,不出意外的话,在浏览器输入 localhost:3000/,可以看到首页内容:

直接在首页登录,如果输入正确的 Email 和 Password,进入登录成功的页面:

怎么判断正确的 Email 和 Password?目前我们在 signin.js 中是这么判断的:

if (email === 'admin@example.com' && password === '123456') {
    ...
}
1
2
3

当然,真实的网站会根据用户输入的 Email 和 Password 去数据库查询并判断登录是否成功,不过这需要涉及到 Node.js 环境如何操作数据库,我们后面再讨论。