在开发前,先明确我们博客需要哪些功能。由于本教程面向初学者,所以只实现了博客最基本的功能,其余的功能(如用户管理、角色设置、归档、标签、分页等)读者可自行实现。
功能设计
功能及路由设计如下:
- 注册
- 注册页:
GET /signup - 注册(包含上传头像):
POST /signup
- 注册页:
- 登录
- 登录页:
GET /signin - 登录:
POST /signin
- 登录页:
- 登出:
GET /signout - 查看文章
- 主页:
GET /posts - 个人主页:
GET /posts?author=xxx - 查看一篇文章(包含留言):
GET /posts/:postId
- 主页:
- 发表文章
- 发表文章页:
GET /posts/create - 发表文章:
POST /posts
- 发表文章页:
- 修改文章
- 修改文章页:
GET /posts/:postId/edit - 修改文章:
POST /posts/:postId/edit
- 修改文章页:
- 删除文章:
GET /posts/:postId - 留言
- 创建留言:
POST /posts/:postId/comment - 删除留言:
GET /posts/:postId/comment/:commentId
- 创建留言:
说明:以上接口与《一起学Node.js》保持一致。
登出、删除操作其实应该用RESTful风格的DELETE操作,或者使用POST。但本书我们采用服务端渲染,不使用ajax或fetch交互,为图方便选用了GET接口。在真实生产环境应该避免这样使用,思考下为什么。
页面设计
页面效果
因为《一起学Node.js》作于2016年,当时jQuery 和Semantic-UI还有不少拥趸,所以使用它们来实现前端页面的设计。最终效果图如下:
注册页
登录页
未登录时的主页(或用户页)
登录后的主页(或用户页)
发表文章页
编辑文章页
未登录时的文章页
登录后的文章页
通知
组件
前面提到过,我们可以将模板拆分成一些组件,然后使用 ejs 的 include 方法将组件组合起来进行渲染。我们将页面切分成以下组件:
文章页:
根据上面的组件切分图,我们创建以下样式及模板文件:
public/css/style.css
/* ---------- 全局样式 ---------- */
body {
width: 1100px;
height: 100%;
margin: 0 auto;
padding-top: 40px;
}
a:hover {
border-bottom: 3px solid #4fc08d;
}
.button {
background-color: #4fc08d !important;
color: #fff !important;
}
.avatar {
border-radius: 3px;
width: 48px;
height: 48px;
float: right;
}
/* ---------- nav ---------- */
.nav {
margin-bottom: 20px;
color: #999;
text-align: center;
}
.nav h1 {
color: #4fc08d;
display: inline-block;
margin: 10px 0;
}
/* ---------- nav-setting ---------- */
.nav-setting {
position: fixed;
right: 30px;
top: 35px;
z-index: 999;
}
.nav-setting .ui.dropdown.button {
padding: 10px 10px 0 10px;
background-color: #fff !important;
}
.nav-setting .icon.bars {
color: #000;
font-size: 18px;
}
/* ---------- post-content ---------- */
.post-content h3 a {
color: #4fc08d !important;
}
.post-content .tag {
font-size: 13px;
margin-right: 5px;
color: #999;
}
.post-content .tag.right {
float: right;
margin-right: 0;
}
.post-content .tag.right a {
color: #999;
}
public/js/footer.js
$(function () {
// 点击按钮弹出下拉框
$('.ui.dropdown').dropdown();
// 鼠标悬浮在头像上,弹出气泡提示框
$('.post-content .avatar').popup({
inline: true,
position: 'bottom right',
lastResort: 'bottom right'
});
});
views/header.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>
<%= title %>
</title>
<link rel="stylesheet" href="//cdn.uino.cn/deno/semantic.css">
<link rel="stylesheet" href="/static/css/style.css">
<script src="//cdn.bootcss.com/jquery/1.11.3/jquery.min.js" defer></script>
<script src="//cdn.bootcss.com/semantic-ui/2.1.8/semantic.min.js" defer></script>
<script src="/static/js/footer.js" defer></script>
</head>
<body>
<%- include('components/nav') %>
<%- include('components/nav-setting') %>
<%- include('components/notification') %>
views/footer.ejs
</body>
</html>
views/components
在 views 目录下新建 components 目录用来存放组件,在该目录下创建以下文件views/components/nav.ejs:
<div class="nav">
<div class="ui grid">
<div class="four wide column"></div>
<div class="eight wide column">
<a href="/posts">
<h1>
<%= title %>
</h1>
</a>
<p>
<%= description %>
</p>
</div>
</div>
</div>
views/components/nav-setting.ejs:
<div class="nav-setting">
<div class="ui buttons">
<div class="ui floating dropdown button">
<i class="icon bars"></i>
<div class="menu">
<% if (user) { %>
<a class="item" href="/posts?userId=<%= user.id %>">个人主页</a>
<div class="divider"></div>
<a class="item" href="/posts/create">发表文章</a>
<a class="item" href="/signout">退出登陆</a>
<% } else { %>
<a class="item" href="/signin">登陆</a>
<a class="item" href="/signup">注册</a>
<% } %>
</div>
</div>
</div>
</div>
views/components/notification.ejs:
<div class="ui grid">
<div class="four wide column"></div>
<div class="eight wide column">
<% if (success) { %>
<div class="ui success message">
<p><%= success %></p>
</div>
<% } %>
<% if (error) { %>
<div class="ui error message">
<p><%= error %></p>
</div>
<% } %>
</div>
</div>
views/posts.ejs
为了方便观察效果,我们先创建主页的模板:
<%- include('header') %>
<%- include('footer') %>
ejs.ts
修改src/tools/ejs.ts,修改render函数,添加第三个参数locals:
export function render(
path: string,
data: Record<string, any> = {},
locals: Record<string, any> = {},
): Promise<string> {
return renderFile("views/" + path + ".ejs", {
user: null,
success: null,
error: null,
...globals.meta,
...locals,
...data,
}, {
cache: true,
filename: path,
});
}
为什么要这么做呢?
因为我们组装ejs模板的数据应该有3个来源:全局配置、中间件、参数。这三者优先级不一样。
为了简化代码,最好我们在中间件操作context上下文,这样就省得每处调用时自己组装这3种数据了。
于是我们再添加一个装饰器Render:
// deno-lint-ignore-file no-explicit-any
import { Context, createParamDecorator } from "oak_nest";
export type Render = (
path: string,
data: Record<string, any>,
) => Promise<string>;
export const Render = createParamDecorator(
(context: Context): Render => {
return (path: string, data: Record<string, any>) => {
return render(path, data, context.state.locals);
};
},
);
posts
新建src/posts目录,新建posts.controller.ts:
import { Controller, Get } from "oak_nest";
import { Render } from "../tools/ejs.ts";
@Controller("/posts")
export class PostsController {
@Get("/")
async getAll(@Render() render: Render) {
return await render("posts", {});
}
}
新建posts.module.ts:
import { Module } from "oak_nest";
import { PostsController } from "./posts.controller.ts";
@Module({
controllers: [
PostsController,
],
})
export class PostsModule {
}
app
修改src/app.module.ts,把PostsModule引入:
修改src/app.controller.ts,把刚才的Render装饰器用上。
async version(@Render() render: Render) {}
main.ts
修改src/main.ts,创建app之后:
app.useStaticAssets("./public", {
prefix: "static",
});
意思是将根目录下public文件夹代理为http://localhost:8000/static/。
比如我们刚才创建的public/css/style.css,就用http://localhost:8000/static/css/style.css就能访问到。
验证
访问http://localhost:8000/posts,看到页面如下:
以及
作业
下一步,我们就要实现注册、登陆接口了。你可以尝试做下。