node koa demo

Node 的特征

网上找了一些 node 的教程,都是版本比较低的,之后看了阮一峰的版本比较高,是 7(自己写时是 10 )

我初学 node 觉得最明显的特征是 同步异步 和 模块化,当然这些都是 js (ES6) 本来的特点

在 node 中大多数操作都是异步的,所以性能较高,但是要写回调,要么就用 async 和 await 来控制异步。

模块化就是可以通过 module.exports = {...} 来把函数变为一个模块,并对外暴露这个对象。模块之间只关心提供的接口并 require 所需的 ,除此之外互不影响

Demo

阮一峰老师的教程是搭建一个完整的 koa 框架的例子,实现了 mvc 模式,(他还写了 单元测试、websocket、MVVM 的例子),但我觉得我只是想初步认识 node,学习他的 mvc 模式的实现就已经够了。(最后连数据库层也忽略了,因为不怎么用 Node 连数据库,如果要用再学)

目录结构

目录 用途
config 其他配置文件
node_modules npm 下载的依赖
src 编写的代码
src / controllers 控制器
src / views 视图
static 静态文件
app.js 项目入口
package-lock.json npm 关于依赖的配置文件
package.json 主配置文件

搭建环境

安装 node 并 设置 npm 国内镜像 npm config set registry https://registry.npm.taobao.org

创建项目安装依赖

npm init -y
npm i koa koa-bodyparser koa-router mime mz nunjucks --save-dev

下载 bootstrap 解压后放在 static 目录

编写代码

首先写控制器

src/controllers/index.js

module.exports = {
    'GET /': async (ctx, next) => {
        ctx.render('index.html', {
            title: 'Welcome'
        })
    }
}

这就是一个模块,对外暴露的是一个有 'GET /' 属性的对象。也可以理解为一个有 'GET /' 键的 map

值是一个函数,接受上下文和下一个中间键两个参数。由于使用了 nunjucks 模板引擎,所以可以使用 render(视图, 参数) 的方法

src/controllers/signin.js

module.exports = {
    'POST /signin': async (ctx, next) => {
        let email = ctx.request.body.email || '',
            password = ctx.request.body.password || ''
        if (email === '[email protected]' && password === '123456') {
            ctx.render('signin-ok.html', {
                title: 'Sign In OK',
                name: 'Mr Node'
            })
        } else {
            ctx.render('signin-failed.html', {
                title: 'Sign In Failed'
            })
        }
    }
}

注意 koa 本身是无法解析表单提交请求中的字段的,所以需要依赖 koa-bodyparser 后可以 ctx.request.body.email 来获得表单中字段的值

然后写视图

由于 hexo 使用 nunjucks 作为模板引擎,在文章中写关键字需要用 和 进行输出源码,实际中请忽略

src/views/base.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>{{ title }}</title>
    <link rel="stylesheet" href="/static/css/bootstrap.css">
    <script src="/static/js/bootstrap.js"></script>
</head>
<body>
    <header class="navbar navbar-static-top" style="background: lightblue">
        <div class="navbar-header">
            <a href="/" class="navbar-brand">Header</a>
        </div>
    </header>
        {% raw %}{% block main %} {% endblock %}{% endraw %}
    <div style="background-color:#ddd; padding: 20px 0;">
        <div class="container">
            <a href="/">Footer</a>
        </div>
    </div>
</body>
</html>

该模板通过 {{title}} 接受 title 的值,并通过 {% block main %} {% endblock %}提供一个内容的插入区域

src/views/index.html

{% raw %}{% extends "base.html" %} {% block main %}{% endraw %}
<div class="container">
    <div class="row">
        <div class="col-md-6">
            <div class="panel panel-default">
                <div class="panel-heading">
                    <h3 class="panel-title"><span class="glyphicon glyphicon-user"></span> Please sign in</h3>
                </div>
                <div class="panel-body">
                    <form action="/signin" method="post">
                        <div class="form-group">
                            <label>Email address</label>
                            <input type="email" name="email" class="form-control" placeholder="Email">
                            <p class="help-block">Use email: [email protected]</p>
                        </div>
                        <div class="form-group">
                            <label>Password</label>
                            <input type="password" name="password" class="form-control" placeholder="Password">
                            <p class="help-block">Use password: 123456</p>
                        </div>
                        <button type="submit" class="btn btn-primary">Sign In</button>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
{% raw %}{% endblock %}{% endraw %}

该模板通过 {% extends "base.html" %} 继承了 base.html,并通过 {% block main %} ... {% endblock %} 在 base.html 的 main 的位置插入了内容

src/views/signin-ok.html

{% raw %}{% extends "base.html" %} {% block main %}{% endraw %}
<div class="container">
    <div class="row">
        <div class="col-md-12">
            <h1>Sign in successfully!</h1>
            <div class="alert alert-success"> <strong>Well done!</strong> You successfully signed in as {{ name }}!
            </div>
            <p><a href="/">Back to home</a></p>
        </div>
    </div>
</div>
{% raw %}{% endblock %}{% endraw %}

src/views/signin-fail.html

{% raw %}{% extends "base.html" %} {% block main %}{% endraw %}
<div class="container">
    <div class="row">
        <div class="col-md-12">
            <h1>Sign in failed!</h1>
            <div class="alert alert-danger"> <strong>Sorry!</strong> Your email or password does not match! Please <a href="/">try again</a>.
        </div>
    </div>
</div>
{% raw %}{% endblock %}{% endraw %}

之后写控制器和视图的配置

config/controller.js

const fs = require('fs')

处理文件需要引入 fs 的内置模块

function addControllers(router, dir) {
    fs.readdirSync(dir).filter((f) => {
        return f.endsWith('.js')
    }).forEach((f) => {
        console.log('controller added: ' + f)
        let mapping = require(dir + '/' + f)
        addMapping(router, mapping)
    })
}

该函数接受 koa-router 的对象和控制器的路径,扫描所有的控制器并添加各个 url 的 map

这里用了 fs.readdirSync() 的同步读取的方法,因为读取控制器只有在项目启动的时候进行一次,并不需要关心性能,而同步的写法比较简单

function addMapping(router, mapping) {
    for (let url in mapping) {
        if (url.startsWith('GET ')) {
            let path = url.substring(4)
            router.get(path, mapping[url])
            console.log('url mapping registered: GET ' + path)
        } else if (url.startsWith('POST ')) {
            let path = url.substring(5)
            router.post(path, mapping[url])
            console.log('url mapping registered: POST ' + path)
        } else if (url.startsWith('PUT ')) {
            let path = url.substring(4)
            router.put(path, mapping[url])
            console.log('url mapping registered: PUT ' + path)
        } else if (url.startsWith('DELETE ')) {
            let path = url.substring(7)
            router.del(path, mapping[url])
            console.log('url mapping registered: DELETE ' + path)
        } else {
            console.log('url method unsupported: ' + url)
        }
    }
}

该函数接受 koa-router 对象和控制器的 map,对该控制器的 map 遍历然后注册 map 中的键 (url) 和值 (处理函数) 到路由中

module.exports = (dir) => {
    let controllersDir = dir || (__dirname + '/../src/controllers'),
        router = require('koa-router')()
    addControllers(router, controllersDir)
    return router.routes()
}

最后该模块暴露一个函数,该函数接受一个路径并返回所有路由

config/static.js

const path = require('path')
const mime = require('mime')
const fs = require('mz/fs')

module.exports = (url, dir) => {
    return async (ctx, next) => {
        let rpath = ctx.request.path
        if (rpath.startsWith(url)) {
            let fp = path.join(dir, rpath.substring(url.length))
            if (await fs.exists(fp)) {
                ctx.response.type = mime.getType(rpath)
                ctx.response.body = await fs.readFile(fp)
            } else {
                ctx.response.status = 404
            }
        } else {
            await next()
        }
    }
}

该模块暴露了一个函数,接受 prefix 静态文件请求前缀 和 dir 静态文件目录,用于判断请求对象是否是静态文件,如果是静态文件则通过 fs 模块读取,如果不是则通过 await next() 进行下一个中间键

config/template.js

const nunjucks = require('nunjucks')

function createEnv(path, opts) {
    let 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, {
                noCache: noCache,
                watch: watch,
            }), {
                autoescape: autoescape,
                throwOnUndefined: throwOnUndefined
            })
    if (opts.filters) {
        for (let f in opts.filters) {
            env.addFilter(f, opts.filters[f])
        }
    }
    return env
}

引入 nunjucks 模板模块,这个函数是官方写法,详情看文档

function templating(path, opts) {
    let env = createEnv(path, opts)
    return async (ctx, next) => {
        ctx.render = function (view, model) {
            ctx.response.body = env.render(view, Object.assign({}, ctx.state || {}, model || {}))
            ctx.response.type = 'text/html'
        }
        await next()
    }
}

module.exports = templating

该模块暴露了一个函数,接受模板目录和模板模块选项,为上下文注册了 render() 方法

最后写入口文件

app.js

const Koa = require('koa')
const bodyParser = require('koa-bodyparser')
const controller = require('./config/controller')
const templating = require('./config/template')

const isProduction = process.env.NODE_ENV === 'production'

const app = new Koa()

引入所有需要依赖的模块,获取环境设置,并新建 Koa 对象

app.use(async (ctx, next) => {
    let isStatic = ctx.request.url.indexOf('static') >= 0
    !isStatic && console.log(ctx.request.method + ' ' + ctx.request.url)
    let start = new Date().getTime(), 
        execTime
    await next()
    execTime = new Date().getTime() - start
    ctx.response.set('X-Response-Time', execTime + 'ms')
})

为 koa 注册第一个中间键,如果不是静态资源的话就在控制台输出 url

if (!isProduction) {
    let staticFiles = require('./config/static')
    app.use(staticFiles('/static/', __dirname + '/static'))
}

如果是开发环境则通过 koa 处理静态文件(生产环境中一般 nginx 等服务器会处理静态文件,而开发环境中可能不用)

app.use(bodyParser())

注册 koa-bodyparser 的中间键处理表单请求

app.use(templating(__dirname + '/src/views', {
    noCache: !isProduction,
    watch: !isProduction
}))

注册渲染模板的组件

app.use(controller())

注册控制器

app.listen(8080)
console.log('app started at port 8080...')

启动项目

node app.js

一般的做法是在 package.json 的 scripts 中加入

"start": "node app.js",

然后

npm run start