Express+MongoDB搭建图片分享社区一

299 阅读8分钟

初始化项目结构

首先创建目录,并初始化(如果你想放在GitHub上的话,先在GitHub上建一个仓库然后clone下来): 初始化的时候可以一路enter下来,如果你不在意的话。

mkdir Instagrammy && cd Instagrammy

npm init

安装express

npm i express

最终生成的package.json文件如下:

{
  "name": "instagrammy",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.1"
  }
}

然后在项目根目录新建入口文件server.js

const express = require('express');

app = express();

app.set('port', process.env.PORT || 3000);

app.get('/', function(req, res) {
    res.send('Hello World!');
})

app.listen(app.get('port'), function() {
    console.log(`Server is running on http://localhost:${app.get('port')}`)
})

运行node server.js,然后在浏览器中访问http://localhost:3000,就可以在页面上看到服务器返回的Hello World!

这里为了接下去运行项目方便,我们在package.json中添加启动命令:

"scripts": {
    "start": "node server.js",
    "test": "echo \"Error: no test specified\" && exit 1"
}

配置中间件

Express 本身是一个非常简洁的 web 框架,但是通过中间件这一设计模式,能够实现非常丰富的功能。一个 Express 中间件本质上是一个函数:

function someMiddleware(req, res, next) {}

req 参数是一个 express.Request 对象,封装了用户请求;res 参数则是一个 express.Response 对象,封装了即将返回给用户的响应;next 则是在执行完所有逻辑后用于触发下一个中间件的函数。

添加中间件的代码则非常简单:

app.use(middlewareA);
app.use(middlewareB);
app.use(middlewareC);

中间件 A、B、C 将会在处理每次请求时按顺序执行(这也意味着中间件的顺序是非常重要的)。接下来我们将添加以下基础中间件(也是几乎所有应用都会用到的中间件):

  • morgan:用于记录日志的中间件,对于开发调试和生产监控都很有用;
  • bodyParser:用于解析客户端请求的中间件,包括 HTML 表单和 JSON 请求;
  • methodOverride:为老的浏览器提供 REST 请求的兼容性支持;
  • cookieParser:用于收发 cookie;
  • errorHandler:用于在发生错误时打印调用栈,仅在开发时使用;
  • handlebars:用于渲染用户界面的模板引擎,会在后面细讲。

我们通过 npm 安装这些中间件:

npm install express-handlebars body-parser cookie-parser morgan method-override errorhandler

在项目根目录创建server目录,在server目录下创建configure.js文件用于配置所有的中间件,文件内容如下:

const path = require('path');
const exphbs = require('express-handlebars');
const express = require('express');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const morgan = require('morgan');
const methodOverride = require('method-override');
const errorHandler = require('errorhandler');

module.exports = function(app) {
    app.use(morgan('dev'));
    app.use(bodyParser.urlencoded({ extended: true }));
    app.use(bodyParser.json());
    app.use(methodOverride());
    app.use(cookieParser('secret-value'));
    app.use('/public/', express.static(path.join(__dirname, '../public')));

    if (app.get('env') === 'development') {
        app.use(errorHandler())
    }

    return app;
}

express.static是express自带的静态资源中间件,用于向客户端发送图片、css等静态文件。最后,我们通过env变量来判断是否处于开发环境,如果是的话就添加errorHandler以便于调试代码。

在server.js中调用刚才配置中间件的代码:

const express = require('express');
const configure = require('./server/configure');

app = express();
app = configure(app);

app.set('port', process.env.PORT || 3000);

app.get('/', function(req, res) {
    res.send('Hello World!');
})

app.listen(app.get('port'), function() {
    console.log(`Server is running on http://localhost:${app.get('port')}`)
})

搭建路由和控制器

在上面的步骤中我们配置好了基础的中间件,但是只有主页(/)可以访问,下面我们要实现以下路由:

  • GET /:网站主页。
  • GET /images/image_id:展示单张图片。
  • POST /images:上传图片。
  • POST /images/image_id/like:点赞图片。
  • POST /images/image_id/comment:评论图片。

我们采用前端最熟悉的MVC模式来搭建下面的内容。

首先创建controllers目录,在该目录下新建home.js文件定义index控制器,代码如下:

module.exports = {
  index: function(req, res) {
    res.send('The home:index controller');
  },
};

每个控制器实际上都是一个 Express 中间件(只不过不需要 next 函数,因为是最后一个中间件)。这里我们暂时用 res.send 发一条文字来代表这个 controller 已经实现。

再在 controllers 目录下创建 image.js,实现与图片处理相关的控制器:

module.exports = {
  index: function(req, res) {
    res.send('The image:index controller ' + req.params.image_id);
  },
  create: function(req, res) {
    res.send('The image:create POST controller');
  },
  like: function(req, res) {
    res.send('The image:like POST controller');
  },
  comment: function(req, res) {
    res.send('The image:comment POST controller');
  },
};

然后在 server 目录下创建路由模块 routes.js,建立从 URL 到控制器之间的映射:

const express = require('express');

const router = express.Router();
const home = require('../controllers/home');
const image = require('../controllers/image');

module.exports = function(app) {
  router.get('/', home.index);
  router.get('/images/:image_id', image.index);
  router.post('/images', image.create);
  router.post('/images/:image_id/like', image.like);
  router.post('/images/:image_id/comment', image.comment);
  app.use(router);
};

这里我们用到了 Express 自带的路由类 Router,可以很方便地定义路由,并且 Router 本身也是一个中间件,可以直接通过 app.use 进行配置。

接着在 server/configure.js 模块中调用路由模块。

const path = require('path');
const exphbs = require('express-handlebars');
const express = require('express');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const morgan = require('morgan');
const methodOverride = require('method-override');
const errorHandler = require('errorhandler');

const routes = require('./routes');

module.exports = function(app) {
  app.use(morgan('dev'));
  app.use(bodyParser.urlencoded({ extended: true }));
  app.use(bodyParser.json());
  app.use(methodOverride());
  app.use(cookieParser('secret-value'));
  app.use('/public/', express.static(path.join(__dirname, '../public')));

  if (app.get('env') === 'development') {
    app.use(errorHandler());
  }

  routes(app);
  return app;
};

最后我们去掉 server.js 中原来的首页路由。

const express = require('express');
const configure = require('./server/configure');

app = express();
app = configure(app);
app.set('port', process.env.PORT || 3000);

app.listen(app.get('port'), function() {
  console.log(`Server is running on http://localhost:${app.get('port')}`);
});

现在我们运行服务器

npm start

打开浏览器访问localhost:3000可以看到页面展示的The home:index controller。访问localhost:3000/images/test123,则是The image:index controller test123。

配置handlebars模板引擎

这一步我们来实现界面展示,首页的效果如下图所示: 首页效果图

图片详情页的效果如下图: 图片详情页

尽管如今前后端分离已经是大势所趋,但是通过模板引擎在服务器端渲染页面也是有用武之地的,特别是快速地开发一些简单的应用。在模板引擎中,HandlebarsPug 当属其中的佼佼者。由于 Handlebars 和普通的 HTML 文档几乎完全一致,容易上手,因此这篇教程中我们选用 Handlebars,并且选用 Bootstrap 样式。

与普通的 HTML 文档相比,模板最大的特点即在于提供了数据的接入。例如 handlebars,可以在双花括号 {{}} 之间填写任何数据,当服务器渲染页面时只需传入相应的数据即可渲染成对应的内容。除此之外,handlebars 还支持条件语法、循环语法和模板嵌套等高级功能,下面将详细描述。

我们创建一个 views 目录,用于存放所有的模板代码。views 目录的结构如下所示: view目录

其中 image.handlebars 和 index.handlebars 是页面模板,layouts/main.handlebars 则是整个网站的布局模板(所有页面共享),partials 目录则用于存放页面之间共享的组件模板,例如评论、数据等等。

首先完成布局模板 layouts/main.handlebars:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Instagrammy</title>
    <link rel="stylesheet" href="http://netdna.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
</head>
<body>
    <div class="page-header">
        <div class="container">
            <div class="col-md-6">
                <h1><a href="/">Instagrammy</a></h1>
            </div>
        </div>
    </div>

    <div class="container">
        <div class="row">
            <div class="col-sm-8">{{{body}}}</div>
        </div>
    </div>
</body>
</html>

main.handlebars 本身是一个完整的 HTML 文档,包括 head 和 body 部分。在 head 部分,我们定义了网站的一些元数据,还加入了 Bootstrap 的 CDN 链接;在 body 部分,包含两个容器:网站头部(header)和每个页面的自定义内容(即 {{{body}}} )。

接下来编写主页内容index.handlebars,页面内容先用标题占位:

<h1>Index Page</h1>

图片详情页面内容:

<h1>Image Page</h1>

index.handlebars 和 image.handlebars 的内容将替换布局模板中的 {{{body}}} 部分。在用户访问某个页面时,页面内容 = 布局模板 + 页面模板。

模板写好之后,我们修改控制器controller/home.js相应的代码,通过 res.render 函数渲染模板:

module.exports = {
  index: function(req, res) {
    res.render('index');
  },
};

render 函数接受一个字符串参数,即页面模板的名称。例如 index.handlebars 的名称即为 index。

同样地,我们修改 image 控制器controllers/image.js的代码:

module.exports = {
  index: function(req, res) {
    res.render('image');
  },
  create: function(req, res) {
    res.send('The image:create POST controller');
  },
  like: function(req, res) {
    res.send('The image:like POST controller');
  },
  comment: function(req, res) {
    res.send('The image:comment POST controller');
  },
};

最后在server/configure.js中配置handlebars中间件:

const path = require('path');
const exphbs = require('express-handlebars');
const express = require('express');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const morgan = require('morgan');
const methodOverride = require('method-override');
const errorHandler = require('errorhandler');

const routes = require('./routes');

module.exports = function(app) {
  app.engine('handlebars', exphbs());
  app.set('view engine', 'handlebars');

  app.use(morgan('dev'));
  app.use(bodyParser.urlencoded({ extended: true }));
  app.use(bodyParser.json());
  app.use(methodOverride());
  app.use(cookieParser('secret-value'));
  app.use('/public/', express.static(path.join(__dirname, '../public')));

  if (app.get('env') === 'development') {
    app.use(errorHandler());
  }

  routes(app);
  return app;
};

到这里我们这一步就完成了,现在npm start运行服务器,访问主页localhost:3000和图片详情页面localhost:3000/images/test就可以看到对应的页面了,虽然没有数据.

完善页面代码

首先在index.handlebars中添加上传图片的表单和展示最新图片的容器

{{!-- views/index.handlebars --}}

<div class="panel panel-primary">
  <div class="panel-heading">
    <h3 class="panel-title">
      上传图片
    </h3>
  </div>
  <form action="/images" method="post" enctype="multipart/form-data">
    <div class="panel-body form-horizontal">
      <div class="form-group col-md-12">
        <label for="file" class="col-sm-2 control-label">浏览:</label>
        <div class="col-md-10">
          <input type="file" class="form-control" name="file" id="file">
        </div>
      </div>
      <div class="form-group col-md-12">
        <label for="title" class="col-md-2 control-label">标题:</label>
        <div class="col-md-10">
          <input type="text" class="form-control" name="title">
        </div>
      </div>
      <div class="form-group col-md-12">
        <label for="description" class="col-md-2 control-label">描述:</label>
        <div class="col-md-10">
          <textarea name="description" rows="2" class="form-control"></textarea>
        </div>
      </div>
      <div class="form-group col-md-12">
        <div class="col-md-12 text-right">
          <button type="submit" id="login-btn" class="btn btn-success">
            <i class="fa fa-cloud-upload"> 上传图片</i>
          </button>
        </div>
      </div>
    </div>
  </form>
</div>

<div class="panel panel-default">
  <div class="panel-heading">
    <h3 class="panel-title">
      最新图片
    </h3>
  </div>
  <div class="panel-body">
    {{#each images}}
      <div class="col-md-4 text-center" style="padding-bottom: 1em;">
        <a href="/images/{{uniqueId}}">
          <img src="/public/upload/{{filename}}" alt="{{title}}"
            style="width: 175px; height: 175px;" class="img-thumbnail">
        </a>
      </div>
    {{/each}}
  </div>
</div>

在展示最新图片时,我们用到了 handlebars 提供的循环语法(第 46 行到 53 行)。对于传入模板的数据对象 images 进行遍历,每个循环中可以访问单个 image 的全部属性,例如 uniqueId 等等。

接着完善 image.handlebars 的内容,包括展示图片的详细内容、发表评论的表单和展示所有评论的容器。

{{!-- views/image.handlebars --}}
<div class="panel panel-primary">
  <div class="panel-heading">
    <h2 class="panel-title">{{image.title}}</h2>
  </div>
  <div class="panel-body">
    <p>{{image.description}}</p>
    <div class="col-md-12 text-center">
      <img src="/public/upload/{{image.filename}}" alt="{{image.title}}"
        class="thumbnail">
    </div>
  </div>
  <div class="panel-footer">
    <div class="row">
      <div class="col-md-8">
        <button class="btn btn-success" id="btn-like" data-id="{{image.uniqueId}}">
          <i class="fa fa-heart"> 点赞</i>
        </button>
        <strong class="likes-count">{{image.likes}}</strong> &nbsp; - &nbsp;
        <i class="fa fa-eye"></i>
        <strong>{{image.views}}</strong>
        &nbsp; - &nbsp; 发表于: <em class="text-muted">{{image.timestamp}}</em>
      </div>
      <div class="col-md-4 text-right">
        <button class="btn btn-danger" id="btn-delete" data-id="{{image.uniqueId}}">
          <i class="fa fa-times"></i>
        </button>
      </div>
    </div>
  </div>
</div>

<div class="panel panel-default">
  <div class="panel-heading">
    <div class="row">
      <div class="col-md-8">
        <strong class="panel-title">评论</strong>
      </div>
      <div class="col-md-4 text-right">
        <button class="btn btn-default btn-sm" id="btn-comment" data-id="{{image.uniqueId}}">
          <i class="fa fa-comments-o"> 发表评论...</i>
        </button>
      </div>
    </div>
  </div>
  <div class="panel-body">
    <blockquote id="post-comment">
      <div class="row">
        <form action="/images/{{image.uniqueId}}/comment" method="post">
          <div class="form-group col-sm-12">
            <label for="name" class="col-sm-2 control-label">昵称:</label>
            <div class="col-sm-10"><input type="text" class="form-control" name="name"></div>
          </div>
          <div class="form-group col-sm-12">
            <label for="email" class="col-sm-2 control-label">Email:</label>
            <div class="col-sm-10"><input type="text" class="form-control" name="email"></div>
          </div>
          <div class="form-group col-sm-12">
            <label for="comment" class="col-sm-2 control-label">评论:</label>
            <div class="col-sm-10">
              <textarea name="comment" class="form-control" rows="2"></textarea>
            </div>
          </div>
          <div class="form-group col-sm-12">
            <div class="col-sm-12 text-right">
              <button class="btn btn-success" id="comment-btn" type="button">
                <i class="fa fa-comment"></i> 发表
              </button>
            </div>
          </div>
        </form>
      </div>
    </blockquote>
    <ul class="media-list">
      {{#each comments}}
      <li class="media">
        <a href="#" class="pull-left">
          <img src="http://www.gravatar.com/avatar/{{gravatar}}?d=monsterid&s=45"
            class="media-object img-circle">
        </a>
        <div class="media-body">
          {{comment}}
          <br/>
          <strong class="media-heading">{{name}}</strong>
          <small class="text-muted">{{timestamp}}</small>
        </div>
      </li>
      {{/each}}
    </ul>
  </div>
</div>

在展示所有评论的代码中,我们同样用到了 handlebars 的循环语法,非常方便。

然后,我们将分别实现网站右边栏中的统计数据、最受欢迎图片和最新评论组件。

首先是统计数据组件模板:

{{!-- views/partials/stats.handlebars --}}
<div class="panel panel-default">
  <div class="panel-heading">
    <h3 class="panel-title">统计数据</h3>
  </div>
  <div class="panel-body">
    <div class="row">
      <div class="col-md-4 text-left">图片:</div>
      <div class="col-md-8 text-right">{{sidebar.stats.images}}</div>
    </div>
    <div class="row">
      <div class="col-md-4 text-left">评论:</div>
      <div class="col-md-8 text-right">{{sidebar.stats.comments}}</div>
    </div>
    <div class="row">
      <div class="col-md-4 text-left">浏览:</div>
      <div class="col-md-8 text-right">{{sidebar.stats.views}}</div>
    </div>
    <div class="row">
      <div class="col-md-4 text-left">点赞:</div>
      <div class="col-md-8 text-right">{{sidebar.stats.likes}}</div>
    </div>
  </div>
</div>

最受欢迎图片组件(popular.handlebars):

{{!-- views/partials/popular.handlebars --}}
<div class="panel panel-default">
  <div class="panel-heading">
    <h3 class="panel-title">最受欢迎</h3>
  </div>
  <div class="panel-body">
    {{#each sidebar.popular}}
      <div class="col-md-4 text-center" style="padding-bottom: .5em;">
        <a href="/images/{{uniqueId}}">
          <img src="/public/upload/{{filename}}" style="width: 75px; height: 75px;"
            class="img-thumbnail">
        </a>
      </div>
    {{/each}}
  </div>
</div>

最新评论组件(comments.handlebars):

{{!-- views/partials/comments.handlebars --}}
<div class="panel panel-default">
  <div class="panel-heading">
    <h3 class="panel-title">最新评论</h3>
  </div>
  <div class="panel-body">
    <ul class="media-list">
      {{#each sidebar.comments}}
      <li class="media">
        <a href="/images/{{image.uniqueId}}" class="pull-left">
          <img src="/public/upload/{{image.filename}}" class="media-object"
            height="45" width="45">
        </a>
        <div class="media-body">
          {{comment}}<br/>
          <strong class="media-heading">{{name}}</strong>
          <small class="text-muted">{{timestamp}}</small>
        </div>
      </li>
      {{/each}}
    </ul>
  </div>
</div>

最后,我们在布局模板 layouts/main.handlebars 中加入所有组件模板,加入模板的语法为 {{> component this}}。除此之外,由于我们用到了一些小图标,所以加上 font-awesome 的链接。

{{!-- views/layouts/main.handlebars --}}

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Instagrammy</title>
  <link href="http://netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css" rel="stylesheet">
  <link href="http://netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.min.css" rel="stylesheet">
</head>

<body>
  <div class="page-header">
    <div class="container">
      <div class="col-md-6">
        <h1><a href="/">Instagrammy</a></h1>
      </div>
    </div>
  </div>

  <div class="container">
    <div class="row">
      <div class="col-sm-8">{{{body}}}</div>
      <div class="col-sm-4">
        {{> stats this}}
        {{> popular this}}
        {{> comments this}}
      </div>
    </div>
  </div>
</body>

</html>

将数据传入模板视图

如果没有数据传入,那么模板相应的数据部分将全都是空白。在这一步中,我们将用一些假数据来演示如何从控制器将数据传入模板视图。

首先在 home 控制器中构造一个 viewModel 对象,并在 render 函数中作为第二参数传入。可以看到 viewModel 对象与模板中的数据接口是完全一致的。

{{!-- controllers/home.js --}}

module.exports = {
  index: function(req, res) {
    const viewModel = {
      images: [
        {
          uniqueId: 1,
          title: '示例图片1',
          description: '',
          filename: 'sample1.jpg',
          views: 0,
          likes: 0,
          timestamp: Date.now(),
        },
        {
          uniqueId: 2,
          title: '示例图片2',
          description: '',
          filename: 'sample2.jpg',
          views: 0,
          likes: 0,
          timestamp: Date.now(),
        },
        {
          uniqueId: 3,
          title: '示例图片3',
          description: '',
          filename: 'sample3.jpg',
          views: 0,
          likes: 0,
          timestamp: Date.now(),
        },
      ],
    };
    res.render('index', viewModel);
  },
};
{{!-- controllers/image.js --}}

module.exports = {
  index: function(req, res) {
    const viewModel = {
      image: {
        uniqueId: 1,
        title: '示例图片1',
        description: '这是张测试图片',
        filename: 'sample1.jpg',
        views: 0,
        likes: 0,
        timestamp: Date.now(),
      },
      comments: [
        {
          image_id: 1,
          email: 'test@testing.com',
          name: 'Test Tester',
          comment: 'Test 1',
          timestamp: Date.now(),
        },
        {
          image_id: 1,
          email: 'test@testing.com',
          name: 'Test Tester',
          comment: 'Test 2',
          timestamp: Date.now(),
        },
      ],
    };
    res.render('image', viewModel);
  },
  create: function(req, res) {
    res.send('The image:create POST controller');
  },
  like: function(req, res) {
    res.send('The image:like POST controller');
  },
  comment: function(req, res) {
    res.send('The image:comment POST controller');
  },
};

在传入数据时,我们可以自定义一些 helper 函数在模板中使用。例如 timestamp 时间戳,Date.now() 返回的是一串数字,显然用户体验很不友好,因此我们需要将其转换为方便用户阅读的时间,例如 “几秒前”“两小时前”。这里我们选用 JavaScript 最流行的处理时间的库 moment.js,并通过 npm 安装:

npm i moment

然后在 server/configure.js 中配置 handlebars 的 helper 函数 timeago:

{{!-- server/configure.js --}}

const path = require('path');
const exphbs = require('express-handlebars');
const express = require('express');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const morgan = require('morgan');
const methodOverride = require('method-override');
const errorHandler = require('errorhandler');
const moment = require('moment');

const routes = require('./routes');

module.exports = function(app) {
  // 定义 moment 全局语言
  moment.locale('zh-cn');

  app.engine(
    'handlebars',
    exphbs.create({
      helpers: {
        timeago: function(timestamp) {
          return moment(timestamp).startOf('minute').fromNow();
        },
      },
    }).engine,
  );
  app.set('view engine', 'handlebars');

  app.use(morgan('dev'));
  app.use(bodyParser.urlencoded({ extended: true }));
  app.use(bodyParser.json());
  app.use(methodOverride());
  app.use(cookieParser('secret-value'));
  app.use('/public/', express.static(path.join(__dirname, '../public')));

  if (app.get('env') === 'development') {
    app.use(errorHandler());
  }

  routes(app);
  return app;
};

接着在相应用到时间戳的地方加入 timeago 函数:

views/image.handlebars timeago1

timeago2

views/partials/comments.handlebars timeago3

到这里我们实现了项目的视图和控制器完成了第一部分,下一部分我们来接入MongoDB数据库实现图片上传、点赞、删除、评论等功能。

原文地址图灵社区