node服务端渲染(完整demo)

32次阅读

共计 13029 个字符,预计需要花费 33 分钟才能阅读完成。

简介
nodejs 搭建多页面服务端渲染

技术点

koa 搭建服务
koa-router 创建页面路由
nunjucks 模板引擎组合 html
webpack 打包多页面
node 端异步请求
服务端日志打印

项目源码 git clone https://gitee.com/wjj0720/nod…

运行

npm i
npm start

一、现代服务端渲染的由来
服务端渲染概念:是指,浏览器向服务器发出请求页面,服务端将准备好的模板和数据组装成完整的 HTML 返回给浏览器展示

1、前端后端分离
早在七八年前,几乎所有网站都使用 ASP、Java、PHP 做后端渲染,随着网络的加快,客户端性能提高以及 js 本身的性能提高,我们开始往客户端增加更多的功能逻辑和交互,前端不再是简单的 html+css 更多的是交互,前端页在这是从后端分离出来「前后端正式分家」

2、客户端渲染
随着 ajax 技术的普及以及前端框架的崛起(jq、Angular、React、Vue) 框架的崛起,开始转向了前端渲染, 使用 JS 来渲染页面大部分内容达到局部刷新的作用

优势

局部刷新,用户体验优
富交互
节约服务器成本

缺点

不利于 SEO(爬虫无法爬取 ajax)请求回来的数据
受浏览器性能限制、增加手机端的耗电
首屏渲染需要等 js 运行才能展示数据

3、现在服务端渲染
为了解决上面客户端渲染的缺点,然前后端分离后必不能合,如果要把前后端部门合并,拆掉的肯定是前端部门

现在服务端渲染的特点

前端开发人员编写 html+css 模板
node 中间服务负责前端模板和后台数据的组合
数据依然由 java 等前服务端语言提供

优势

前后端分工明确
SEO 问题解决

4、前、后端渲染相关讨论参考

知乎问答:为什么现在又流行服务器端渲染 html
精读前后端渲染之争
服务端渲染 vs 客户端渲染

二、项目开始
确保你安装 node
第一步 让服务跑起来
目标: 创建 node 服务, 通过浏览器访问, 返回 ’hello node!'(html 页面其实就是一串字符串)
/** 创建项目目录结构如下 */
│─ package-lock.json
│─ package.json
│─ README.md
├─bin
│─ www.js

// 1. 安装依赖 npm i koa
// 2. 修改 package.json 文件中 scripts 属性如下
“scripts”: {
“start”: “node bin/www.js”
}

// 3. www.js 写入如下代码
const Koa = require(‘koa’);
let app = new Koa();
app.use(ctx => {
ctx.body = ‘hello node!’
});
app.listen(3000, () => {
console.log(‘ 服务器启动 http://127.0.0.1:3000’);
});

// 4 npm start 浏览器访问 http://127.0.0.1:3000 查看效果

第二步 路由的使用
目标: 使用 koa-router 根据不同 url 返回不同页面内容

依赖 npm i koa-routerkoa-router 更多细节 请至 npm 查看
/** 新增 routers 文件夹 目录结构如下
│─.gitignore
│─package.json
│─README.md
├─bin
│ │─www.js
├─node_modules
└─routers
│─home.js
│─index.js
│─user.js
*/
// 项目中应按照模块对路由进行划分, 示例简单将路由划分为首页 (/) 和用户页(/user) 在 index 中将路由集中管理导, 出并在 app 实例后挂载到 app 上
/** router/home.js 文件 */
// 引包
const homeRouter = require(‘koa-router’)()
// 创建路由规则
homeRouter.get([‘/’, ‘/index.html’, ‘/index’, ‘/home.html’, ‘/home’], (ctx, next) => {
ctx.body = ‘home’
});
// 导出路由备用
module.exports = homeRouter

/** router/user.js 文件 */
const userRouter = require(‘koa-router’)()
userRouter.get(‘/user’, (ctx, next) => {
ctx.body = ‘user’
});
module.exports = userRouter

/** router/index.js 文件 */
// 路由集中点
const routers = [
require(‘./home.js’),
require(‘./user.js’)
]
// 简单封装
module.exports = function (app) {
routers.forEach(router => {
app.use(router.routes())
})
return routers[0]
}

/** www.js 文件改写 */
// 引入 koa
const Koa = require(‘koa’)
const Routers = require(‘../routers/index.js’)
// 实例化 koa 对象
let app = new Koa()

// 挂载路由
app.use((new Routers(app)).allowedMethods())

// 监听 3000 端口
app.listen(3000, () => {
console.log(‘ 服务器启动 http://127.0.0.1:3000’)
})

第三步 加入模板
目标: 1. 使用 nunjucks 解析 html 模板返回页面 2. 了解 koa 中间件的使用
依赖 npm i nunjucks
nunjucks 中文文档
/*
* 我向项目目录下加入两个准备好的 html 文件 目录结构如下
│─.gitignore
│─package.json
│─README.md
├─bin
│ │─www.js
│─middlewares // 新增中间件目录
│ ├─nunjucksMiddleware.js //nunjucks 模板中间件
├─node_modules
│─routers
│ │─home.js
│ │─index.js
│ │─user.js
│─views // 新增目录 作为视图层
├─home
│ ├─home.html
├─user
├─user.html
*/
/* nunjucksMiddleware.js 中间件的编写
* 什么是中间件: 中间件就是在程序执行过程中增加辅助功能
*nunjucksMiddleware 作用: 给请求上下文加上 render 方法 将来在路由中使用
*/
const nunjucks = require(‘nunjucks’)
const path = require(‘path’)
const moment = require(‘moment’)
let nunjucksEVN = new nunjucks.Environment(new nunjucks.FileSystemLoader(‘views’))
// 为 nkj 加入一个过滤器
nunjucksEVN.addFilter(‘timeFormate’, (time, formate) => moment(time).format(formate || ‘YYYY-MM-DD HH:mm:ss’))

// 判断文件是否有 html 后缀
let isHtmlReg = /\.html$/
let resolvePath = (params = {}, filePath) => {
filePath = isHtmlReg.test(filePath) ? filePath : filePath + (params.suffix || ‘.html’)
return path.resolve(params.path || ”, filePath)
}

/**
* @description nunjucks 中间件 添加 render 到请求上下文
* @param params {}
*/
module.exports = (params) => {
return (ctx, next) => {
ctx.render = (filePath, renderData = {}) => {
ctx.type = ‘text/html’
ctx.body = nunjucksEVN.render(resolvePath(params, filePath), Object.assign({}, ctx.state, renderData))
}
// 中间件本身执行完成 需要调用 next 去执行下一步计划
return next()
}
}
/* 中间件挂载 www.js 中增加部分代码 */

// 头部引入文件
const nunjucksMiddleware = require(‘../middlewares/nunjucksMiddleware.js’)
// 在路由之前调用 因为我们的中间件是在路由中使用的 故应该在路由前加到请求上下文 ctx 中
app.use(nunjucksMiddleware({
// 指定模板文件夹
path: path.resolve(__dirname, ‘../views’)
})
/* 路由中调用 以 routers/home.js 为例 修改代码如下 */
const homeRouter = require(‘koa-router’)()
homeRouter.get([‘/’, ‘/index.html’, ‘/index’, ‘/home.html’, ‘/home’], (ctx, next) => {
// 渲染页面的数据
ctx.state.todoList = [
{name: ‘ 吃饭 ’, time: ‘2019.1.4 12:00’},
{name: ‘ 下午茶 ’, time: ‘2019.1.4 15:10’},
{name: ‘ 下班 ’, time: ‘2019.1.4 18:30’}
]
// 这里的 ctx.render 方法就是我们通过 nunjucksMiddleware 中间件添加的
ctx.render(‘home/home’, {
title: ‘ 首页 ’
})
})
module.exports = homeRouter

第四步 抽取公共模板
目标: 抽取页面的公用部分 如导航 / 底部 /html 模板等
/**views 目录下增加两个文件夹_layout(公用模板) _component(公共组件) 目录结构如下
│─.gitignore
│─package.json
│─README.md
├─bin
│ │─www.js /koa 服务
│─middlewares // 中间件目录
│ ├─nunjucksMiddleware.js //nunjucks 模板中间件
├─node_modules
│─routers // 服务路由目录
│ │─home.js
│ │─index.js
│ │─user.js
│─views // 页面视图层
│─_component
│ │─nav.html (公用导航)
│─_layout
│ │─layout.html (公用 html 框架)
├─home
│ ├─home.html
├─user
├─user.html
*/
<!– layout.html 文件代码 –>
<!DOCTYPE html>
<html>
<head>
<meta charset=”UTF-8″>
<title>{{title}}</title>
</head>
<body>
<!– 占位 名称为 content 的 block 将放在此处 –>
{% block content %}
{% endblock %}
</body>
</html>

<!– nav.html 公用导航 –>
<ul>
<li><a href=”/”> 首页 </a></li>
<li><a href=”/user”> 用户页 </a></li>
</ul>
<!– home.html 改写 –>
<!– njk 继承模板 –>
{% extends “../_layout/layout.html” %}
{% block content %}
<!– njk 引入公共模块 –>
{% include “../_component/nav.html” %}
<h1> 待办事项 </h1>
<ul>
<!– 过滤器的调用 timeFormate 即我们在中间件中给 njk 加的过滤器 –>
{% for item in todoList %}
<li>{{item.name}} —> {{item.time | timeFormate}}</li>
{% endfor %}
</ul>
{% endblock %}

<!– user.html –>
{% extends “../_layout/layout.html” %}
{% block content %}
{% include “../_component/nav.html” %}
用户中心
{% endblock %}

第五步 静态资源处理
目标: 处理页面 jscssimg 等资源引入

依赖

用 webpack 打包静态资源 npm i webpack webpack-cli -D
处理 js npm i @babel/core @babel/preset-env babel-loader -D
处理 less npm i css-loader less-loader less mini-css-extract-plugin -D
处理文件 npm i file-loader copy-webpack-plugin -D
处理 html npm i html-webpack-plugin -D
清理打包文件 npm i clean-webpack-plugin -D

> * 相关插件使用 查看 npm 相关文档 *

/* 项目目录 变更
│ .gitignore
│ package.json
│ README.md
├─bin
│ www.js
├─config // 增加 webpack 配置目录
│ webpack.config.js
├─middlewares
│ nunjucksMiddleware.js
├─routers
│ home.js
│ index.js
│ user.js
├─src
│ │─template.html // + html 模板 以此模板为每个入口生成 引入对应 js 的模板
│ ├─images // + 图资源目录
│ │ ww.jpg
│ ├─js // + js 目录
│ │ ├─home
│ │ │ home.js
│ │ └─user
│ │ user.js
│ └─less // + css 目录
│ ├─common
│ │ common.less
│ │ nav.less
│ ├─home
│ │ home.less
│ └─user
│ user.less
└─views
├─home
│ home.html
├─user
│ user.html
├─_component
│ nav.html
└─_layout // webpac 打包后的 html 模板
├─home
│ home.html
└─user
user.html
*/
<!– template.html 内容 –>
<!DOCTYPE html>
<html lang=”en”>
<head>
<meta charset=”UTF-8″>
<meta name=”viewport” content=”width=device-width, initial-scale=1.0″>
<meta http-equiv=”X-UA-Compatible” content=”ie=edge”>
<title>{{title}}</title>
</head>
<body>
<!– njk 模板继承后填充 –>
{% block content %}
{% endblock %}
</body>
</html>
/* src/js/home/home.js 一个入口文件 */

import ‘../../less/home/home.less’ // 引入 css
import img from ‘../../images/ww.jpg’ // 引入图片
console.log(111);
let add = (a, b) => a + b; // 箭头函数
let a = 3, b = 4;
let c = add(a, b);
console.log(c);
// 这里只做打包演示代码 不具任何意义
<!– less/home/home.less 内容 –>
// 引入公共样式
@import ‘../common/common.less’;
@import ‘../common/nav.less’;

.list {
li {
color: rebeccapurple;
}
}
.bg-img {
width: 200px;
height: 200px;
background: url(../../images/ww.jpg); // 背景图片
margin: 10px 0;
}
/* webpack 配置 webpack.config.js */
const path = require(‘path’);
const CleanWebpackPlugin = require(‘clean-webpack-plugin’);
const HtmlWebpackPlugin = require(‘html-webpack-plugin’);
const MiniCssExtractPlugin = require(“mini-css-extract-plugin”);
const CopyWebpackPlugin = require(‘copy-webpack-plugin’);

// 多入口
let entry = {
home: ‘src/js/home/home.js’,
user: ‘src/js/user/user.js’
}

module.exports = evn => ({
mode: evn.production ? ‘production’ : ‘development’,
// 给每个入口 path.reslove
entry: Object.keys(entry).reduce((obj, item) => (obj[item] = path.resolve(entry[item])) && obj, {}),
output: {
publicPath: ‘/’,
filename: ‘js/[name].js’,
path: path.resolve(‘dist’)
},
module: {
rules: [
{// bable 根据需要转换到对应版本
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: ‘babel-loader’,
options: {
presets: [‘@babel/preset-env’]
}
}
},
{// 转换 less 并交给 MiniCssExtractPlug 插件提取到单独文件
test: /\.less$/,
loader: [MiniCssExtractPlugin.loader, ‘css-loader’, ‘less-loader’],
exclude: /node_modules/
},
{// 将 css、js 引入的图片目录指到 dist 目录下的 images 保持与页面引入的一致
test: /\.(png|svg|jpg|gif)$/,
use: [{
loader: ‘file-loader’,
options: {
name: ‘[name].[ext]’,
outputPath: ‘./images’,
}
}]
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/,
use: [{
loader: ‘file-loader’,
options: {
name: ‘[name].[ext]’,
outputPath: ‘./font’,
}
}]
}
]
},
plugins: [
// 删除上一次打包目录(一般来说删除自己输出过的目录)
new CleanWebpackPlugin([‘dist’, ‘views/_layout’], {
// 当配置文件与 package.json 不再同一目录时候需要指定根目录
root: path.resolve()
}),
new MiniCssExtractPlugin({
filename: “css/[name].css”,
chunkFilename: “[id].css”
}),
// 将 src 下的图片资源平移到 dist 目录
new CopyWebpackPlugin(
[{
from: path.resolve(‘src/images’),
to: path.resolve(‘dist/images’)
}
]),
// HtmlWebpackPlugin 每个入口生成一个 html 并引入对应打包生产好的 js
…Object.keys(entry).map(item => new HtmlWebpackPlugin({
// 模块名对应入口名称
chunks: [item],
// 输入目录 (可自行定义 这边输入到 views 下面的_layout)
filename: path.resolve(‘views/_layout/’ + entry[item].split(‘/’).slice(-2).join(‘/’).replace(‘js’, ‘html’)),
// 基准模板
template: path.resolve(‘src/template.html’)
}))
]
});

<!– package.json 中添加 –>
“scripts”: {
“start”: “node bin/www.js”,
“build”: “webpack –env.production –config config/webpack.config.js”
}

运行 npm run build 后生成 dist views/_layout 两个目录
<!– 查看打包后生成的模板 views/_layout/home/home.html–>
<!DOCTYPE html>
<html lang=”en”>
<head>
<meta charset=”UTF-8″>
<meta name=”viewport” content=”width=device-width, initial-scale=1.0″>
<meta http-equiv=”X-UA-Compatible” content=”ie=edge”>
<title>{{title}}</title>
<!– 引入了 css 文件 –>
<link href=”/css/home.css” rel=”stylesheet”></head>
<body>
{% block content %}
{% endblock %}
<!– 引入了 js 文件 此时打包后的 js/css 在 dist 目录下面 –>
<script type=”text/javascript” src=”/js/home.js”></script></body>
</html>
<!– view/home/home.html 页面改写 –>
<!– njk 继承模板 继承的目标来自 webpack 打包生成 –>
{% extends “../_layout/home/home.html” %}
{% block content %}
<!– njk 引入公共模块 –>
{% include “../_component/nav.html” %}
<h1> 待办事项 </h1>
<ul class=”list”>
<!– 过滤器的调用 timeFormate 即我们在中间件中给 njk 加的过滤器 –>
{% for item in todoList %}
<li>{{item.name}} —> {{item.time | timeFormate}}</li>
{% endfor %}
</ul>
<div class=”bg-img”> 背景图 </div>
<!– 页面图片引入方式 –>
<img src=”/images/ww.jpg”/>
{% endblock %}
/**koa 处理静态资源
* 依赖 npm i ‘koa-static
*/

// www.js 增加 将静态资源目录指向 打包后的 dist 目录
app.use(require(‘koa-static’)(path.resolve(‘dist’)))
运行
npm run build
npm start
浏览器访问 127.0.0.1:3000 查看页面 js css img 效果

第六步 监听编译
目标: 文件发生改实时编译打包

依赖 npm i pm2 concurrently
/** 项目中文件发生变动 需要重启服务才能看到效果是一件蛋疼的事, 故需要实时监听变动 */
<!– 我们要监听的有两点 一是 node 服务 而是 webpack 打包 package.json 变动如下 –>
“scripts”: {
// concurrently 监听同时监听两条命令
“start”: “concurrently \”npm run build:dev\” \”npm run server:dev\””,
“dev”: “npm start”,
// 生产环境 执行两条命令即可 无监听
“product”: “npm run build:pro && npm run server:pro”,
// pm2 –watch 参数监听服务的代码变更
“server:dev”: “pm2 start bin/www.js –watch”,
// 生产不需要用监听
“server:pro”: “pm2 start bin/www.js”,
// webpack –watch 对打包文件监听
“build:dev”: “webpack –watch –env.production –config config/webpack.config.js”,
“build:pro”: “webpack –env.production –config config/webpack.config.js”
}

第七步 数据请求
目标: node 请求接口数据 填充模板

依赖 npm i node-fetch
/* 上面的代码中 routers/home.js 首页路由中我们向页面渲染了下面的一组数据 */
ctx.state.todoList = [
{name: ‘ 吃饭 ’, time: ‘2019.1.4 12:00’},
{name: ‘ 下午茶 ’, time: ‘2019.1.4 15:10’},
{name: ‘ 下班 1 ’, time: ‘2019.1.4 18:30’}
]
/* 但 数据是同步的 项目中我们必然会向 java 获取其他后台拿到渲染数据再填充页面 我们来看看怎么做 */
/* 我们在根目录下创建一个 util 的目录作为工具库 并简单封装 fetch.js 请求数据 */
const nodeFetch = require(‘node-fetch’)
module.exports = ({url, method, data = {}}) => {
// get 请求 将参数拼到 url
url = method === ‘get’ || !method ? “?” + Object.keys(data).map(item => `${item}=${data[item]}`).join(‘&’) : url;
return nodeFetch(url, {
method: method || ‘get’,
body: JSON.stringify(data),
headers: {‘Content-Type’: ‘application/json’},
}).then(res => res.json())
}
/* 在根目录下创建一个 service 的目录作为数据层 并创建一个 exampleService.js 作为示例 */
// 引入封装的 请求工具
const fetch = require(‘../util/fetch.js’)
module.exports = {
getTodoList (params = {}) {
return fetch({
url: ‘https://www.easy-mock.com/mock/5c35a2a2ce7b4303bd93fbda/example/todolist’,
method: ‘post’,
data: params
})
},
//…
}
/* 将请求加入到路由中 routers/home.js 改写 */
const homeRouter = require(‘koa-router’)()
let exampleService = require(‘../service/exampleService.js’) // 引入 service api
// 将路由匹配回调 改成 async 函数 并在请时候 await 数据回来 再调用 render
homeRouter.get([‘/’, ‘/index.html’, ‘/index’, ‘/home.html’, ‘/home’], async (ctx, next) => {
// 请求数据
let todoList = await exampleService.getTodoList({name: ‘ott’})
// 替换原来的静态数据
ctx.state.todoList = todoList.data
ctx.render(‘home/home’, {
title: ‘ 首页 ’
})
})
// 导出路由备用
module.exports = homeRouter

第八步 日志打印
目标: 使程序运行可视

依赖 npm i log4js
/* 在 util 目录下创建 logger.js 代码如下 作简单的 logger 封装 */
const log4js = require(‘log4js’);
const path = require(‘path’)
// 定义 log config
log4js.configure({
appenders: {
// 定义两个输出源
info: {type: ‘file’, filename: path.resolve(‘log/info.log’) },
error: {type: ‘file’, filename: path.resolve(‘log/error.log’) }
},
categories: {
// 为 info/warn/debug 类型 log 调用 info 输出源 error/fatal 调用 error 输出源
default: {appenders: [‘info’], level: ‘info’ },
info: {appenders: [‘info’], level: ‘info’ },
warn: {appenders: [‘info’], level: ‘warn’ },
debug: {appenders: [‘info’], level: ‘debug’ },
error: {appenders: [‘error’], level: ‘error’ },
fatal: {appenders: [‘error’], level: ‘fatal’ },
}
});
// 导出 5 种类型的 logger
module.exports = {
debug: (…params) => log4js.getLogger(‘debug’).debug(…params),
info: (…params) => log4js.getLogger(‘info’).info(…params),
warn: (…params) => log4js.getLogger(‘warn’).warn(…params),
error: (…params) => log4js.getLogger(‘error’).error(…params),
fatal: (…params) => log4js.getLogger(‘fatal’).fatal(…params),
}
/* 在 fetch.js 中是哟 logger */
const nodeFetch = require(‘node-fetch’)
const logger = require(‘./logger.js’)

module.exports = ({url, method, data = {}}) => {
// 加入请求日志
logger.info(‘ 请求 url:’, url , method||’get’, JSON.stringify(data))

// get 请求 将参数拼到 url
url = method === ‘get’ || !method ? “?” + Object.keys(data).map(item => `${item}=${data[item]}`).join(‘&’) : url;

return nodeFetch(url, {
method: method || ‘get’,
body: JSON.stringify(data),
headers: {‘Content-Type’: ‘application/json’},
}).then(res => res.json())
}

<!– 日志打印 –>
[2019-01-09T17:34:11.404] [INFO] info – 请求 url: https://www.easy-mock.com/mock/5c35a2a2ce7b4303bd93fbda/example/todolist post {“name”:”ott”}

注: 仅共学习参考, 生产配置自行斟酌! 转载请备注来源!

正文完
 0