前端早早聊大会,与掘金联合举办。加 codingdreamer 进大会技术群,赢在新的起跑线。
第二十九届|前端数据可视化专场,高强度一次性洞察可视化的前端玩法,7-17 全天直播,9 位讲师 9 个小时的知识轰炸(阿里云/蚂蚁/奇安信/小米等),报名上车? ):
所有往期都有全程录播,上手年票一次性解锁全部
正文如下
不想自己搭建数据库和后台编辑管理功能,如果把语雀当做是一个云数据库呢,有没有偷巧的办法?
Node.js(以下简称为 Node) 对前端的最大魅力,无外乎可以启动一个 HTTP 服务后,来提供网站的服务能力,这可以帮助前端工程师完成很多好玩的作品,如何起服务呢:
Node 起一个服务
在 Node 的网络请求这里,两个最神奇的东西就是请求和返回,也就是 request 和 response,我们所谓提供的 web 服务,都是在 req 和 res 上面做各种加工,原生 Node 提供了这样的能力,但所有的脏活累活我们都得自己干,不论请求类型的判断,还是是 url 的解析,还是状态码的返回,市面上的 Node 框架也都是在 req 和 res 的基础上做各种封装:
const http = require('http')
const server = http.createServer((req, res) => {
// 在这里基于 req 的请求类型和参数完成各种业务处理
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('Hello ZaoZaoLiao')
})
server.listen(3000, '127.0.0.1', () => {})
Express 起一个服务
作为上古经典框架,Express 框架(现在已经比几年前轻巧很多了)帮你做好了很多事情,比如请求类型和路由都不需要你处理了,可以拎过来马上根据用户的访问来返回不同的内容,大二全的设计能让你充分偷懒:
const express = require('express')
const app = express()
app.get('/', (req, res) => {
res.send('早')
})
app.get('/hi', (req, res) => {
res.send('早聊')
})
app.listen(3001)
Koa 起一个服务
虽然 Express 很香,但它太重了,特别是早期基于 callback 的设计,让不少团队在 callback hell 的泥潭里填坑填了好多年,而 Koa 更小而美,支持异步(虽然 1 代 Koa 的 generator 有点丑陋),它只做最纯粹的部分,比如上下文的处理、流的处理、Cookie 的处理等等。
当然 Koa 最吸引我们的是它的洋葱模型,请求可以一层层的进去,再一层层的出来,如果洋葱核心是我们要处理的业务,那么每一层皮都可以看作是外围的一些业务处理,请求在 Koa 中进出要穿越的这些皮,就是 Koa 的中间件,这样理论上我们可以为一个应用扩展出三四十个中间件,处理安全的,处理缓存的,处理日志的,处理页面渲染的...来让应用再次长得肥胖,不过中间件也要根据实际情况做增删,并不是越多越好,越多意味着不确定性越强(尤其是三方中间件),性能也会受影响(社区的的代码层次不齐,整体不一定可控,中间件多执行节点自然也多)。
无论如何,Koa 让我们可以更精细的控制请求的进入和流出,这给开发者带来了诸多便利:
const Koa = require('koa')
const app = new Koa()
const indent = (n) => new Array(n).join(' ')
const mid1 = () => async (ctx, next) => {
ctx.body = `<h3>请求 => 进入第一层中间件</h3>`
await next()
ctx.body += `<h3>响应 <= 从第一层中间件穿过</h3>`
}
const mid2 = () => async (ctx, next) => {
ctx.body += `<h2>${indent(4)}请求 => 进入第二层中间件</h2>`
await next()
ctx.body += `<h2>${indent(4)}响应 <= 从第二层中间件穿出</h2>`
}
app.use(mid1())
app.use(mid2())
app.use(async (ctx, next) => {
ctx.body += `<h1>${indent(12)}::处理核心业务 ::</h1>`
})
app.listen(2333)
Egg 起一个服务
Koa 虽然小而美,可以集成大量中间件,但一个复杂的企业级应用,需要更严谨的约束,无论是功能模型上的设计(体现在目录结构上),还是框架本身的能力集成(体现在模块的书写方式、彼此暴露的接口和调用形式上),都需要有一个既有约束力又方便扩展的架构,这时候 Egg 就登场了,Egg 奉行『约定优于配置』,按照一套统一的约定进行应用开发,除了 service/controller/loader/context...的进一步抽象和改造外,还提供了强大的插件能力,如官方文档所写,一个插件可以包含:
- extend:扩展基础对象的上下文,提供各种工具类、属性。
- middleware:增加一个或多个中间件,提供请求的前置、后置处理逻辑。
- config:配置各个环境下插件自身的默认配置项。
一个独立领域下的插件实现,可以在代码维护性非常高的情况下实现非常完善的功能,而插件也支持配置各个环境下的默认(最佳)配置,让我们使用插件的时候几乎可以不需要修改配置项。
// app/controller/home.js
const Controller = require('egg').Controller
class HomeController extends Controller {
async index() {
const { ctx } = this
ctx.body = 'hi, egg'
}
}
module.exports = HomeController
// app/controller/router.js
module.exports = app => {
const { router, controller } = app
router.get('/', controller.home.index)
}
// config/config.default.js
module.exports = appInfo => {
const config = exports = {}
config.keys = appInfo.name + '_1598512467216_9757'
config.middleware = []
const userConfig = {
// myAppName: 'egg',
}
return {
...config,
...userConfig,
}
}
// config/plugin.js
module.exports = {
// static: {
// enable: true,
// }
}
大家可以前往 Egg 和 Koa 查看更多信息,官方写的非常好了。
本地起一个简单的博客服务
Egg 是基于 Koa 来封装的,还可以继续基于 Egg 封装更偏业务向的企业级框架,我们把焦点回归到 Koa,结合获取语雀 API 的能力,我们来用 Koa 搭建一个本地服务吧,本地不安装数据库,数据都从语雀上拿,模板引擎可以用 Pug,目录可以这样设计:
.
├── README.md
├── app # 整体应用服务
│ ├── controllers # 控制器:处理业务逻辑
│ │ ├── article.js # 文章详情业务处理
│ │ └── home.js # 路由跳转至指定页面业务处理
│ ├── router # 路由
│ │ └── routes.js # 路由信息配置
│ ├── tasks # 对接第三方的一些服务任务
│ │ └── yuque.js # yuque 业务逻辑处理:获取文档列表、文档详情、保存文档
│ └── views # 页面
│ ├── includes
│ ├── layout.pug
│ └── pages
├── config # 服务配置文件
│ └── config.js
├── index.js # 入口文件
├── package-lock.json
├── package.json
└── public # 静态资源
├── css
│ ├── nav.css
│ └── style.css
└── images
├── logo.png
├── mobile-banner.png
└── pc-banner.png
模块可以安装这几个:
- axios:可以用在浏览器和 Node.js 的基于 Promise 的 HTTP 客户端
- Koa:基于 Node.js 平台的 Web 开发框架
- koa-static:Koa 静态文件服务中间件
- koa-router:Koa 路由中间件
- koa-views:Koa 模版渲染中间件
- moment:JavaScript 日期处理类库
获取语雀的数据可以这样处理:
const fs = require('fs')
const { resolve } = require('path')
const axios = require('axios')
// 获取配置信息
const config = require('../../config/config')
const { repoId, api, token } = config.yuque
// 把语雀拿来的文章存到本地
const saveYuque = (article, html) => {
// 先检查一下, pages 目录的路径是否存在
// 路径不存在就自动生成一个 pages 目录(首次使用服务), 否则会报错, 会一致无法使用本地缓存
// 路径存在, 直接在该路径保存语雀的博客文章
const path = __dirname.substring(0, __dirname.length - 9) + 'public/pages'
if (!fs.existsSync(path)) {
fs.mkdirSync(path)
}
const file = resolve(__dirname, `../../public/pages/${article.id}.html`)
if (!fs.existsSync(file)) {
fs.writeFile(file, html, err => {
if (err) console.log(err)
console.log(`${article.title} 已写入本地`)
})
}
}
// 封装统一的请求
const _request = async (pathname) => {
const url = api + pathname
return axios.get(url, {
headers: { 'X-Auth-Token': token }
}).then(res => {
return res.data.data
}).catch(err => {
console.log(err)
})
}
// 获取配置文件指定 repoId 下的所有文章
const getDocList = async () => {
try {
const res = await _request(`/repos/${repoId}/docs`)
return res
} catch (err) {
console.log('获取文章列表失败: ', err)
return []
}
}
// 获取配置文件指定 repoId 下的指定文章内容
const getDocDetail = async (docId) => {
try {
const res = await _request(`/repos/${repoId}/docs/${docId}?raw=1`)
return res
} catch (err) {
console.log('获取文章内容失败: ', err)
return {}
}
}
module.exports = {
// getYuqueUser,
getDocDetail,
getDocList,
saveYuque
}
路由可以添加几个博客页面:
// 页面
const Home = require('../controllers/home')
const Article = require('../controllers/article')
module.exports = router => {
// 网站前台页面
// router.get(url, controller)
router.get('/', Home.homePage)
router.get('/about', Home.about)
router.get('/joinus', Home.joinus)
router.get('/contact', Home.contact)
router.get('/article/:_id', Article.detail)
}
几个页面交给主控制器处理:
// 获取配置文件指定 repoId 下的所有文章的方法
const { getDocList } = require('../tasks/yuque')
const { teamName } = require('../../config/config')
// 根据指定路径, 用一个 controller 把页面返回给客户端
exports.homePage = async ctx => {
const articles = await getDocList()
// render(pug, pug 内需要的变量)
ctx.body = await ctx.render('pages/index', {
title: '首页',
teamName,
articles
})
}
exports.about = async ctx => {
ctx.body = await ctx.render('pages/about', {
teamName
})
}
exports.joinus = async ctx => {
ctx.body = await ctx.render('pages/joinus', {
teamName
})
}
exports.contact = async ctx => {
ctx.body = await ctx.render('pages/contact', {
teamName
})
}
控制器的代码可以这样处理:
const fs = require('fs')
const { resolve } = require('path')
const { getDocDetail, saveYuque } = require('../tasks/yuque')
const config = require('../../config/config')
const { root } = config
const streamEnd = fd => new Promise((resolve, reject) => {
fd.on('end', () => resolve())
fd.on('finish', () => resolve())
fd.on('error', reject)
})
// 查看文章详情
exports.detail = async ctx => {
const _id = ctx.params._id
const fileName = resolve(root, `${_id}.html`)
const fileExists = fs.existsSync(fileName)
// 首先去本地找是否缓存过资源,如果缓存过直接返回
if (fileExists) {
console.log('命中文章缓存,直接返回')
// 拿到文件流,pipe 给 koa 的 res,让它接管流的返回
ctx.type = 'text/html; charset=utf-8'
ctx.status = 200
const rs = fs.createReadStream(fileName).pipe(ctx.res)
await streamEnd(rs)
} else {
console.log('未命中文章缓存,重新拉取')
// 如果没缓存过,则从语雀 API 获取后直接返回
const article = await getDocDetail(_id)
const body = article.body_html.replace('<!doctype html>', '')
// 服务器返回新拿到的文章数据
const html = await ctx.render('pages/detail', {
body,
article,
siteTitle: article.title
})
// 本地文件缓存也写一份
saveYuque(article, html)
ctx.body = html
}
}
流程虽然简单,但如果大家去面试的时候,被面试官问起这里都缓存如何处理,以这种形式肯定是过不了关的,这里还需要考虑很多边界条件和风险点,比如资源有无、权限、有效性、类型及安全检查、流量判断...等等等等,其中缓存的部分,往往会成为一个考察重点,大家可以在上面多花一些心思,如下伪代码仅抛砖引玉:
// 304 缓存有效期判断, 使用 If-Modified-Since,用 Etag 也可以
const fStat = fs.statSync(filePath)
const modified = req.headers['if-modified-since']
const expectedModified = new Date(fStat.mtime).toGMTString()
if (modified && modified == expectedModified) {
res.statusCode = 304
res.setHeader('Content-Type', mimeType[ext])
res.setHeader('Cache-Control', 'max-age=3600')
res.setHeader('Last-Modified', new Date(expectedModified).toGMTString())
return
}
// 文件头信息设置
res.statusCode = 200
res.setHeader('Content-Type', mimeType[ext])
res.setHeader('Cache-Control', 'max-age=3600')
res.setHeader('Content-Encoding', 'gzip')
res.setHeader('Last-Modified', new Date(expectedModified).toGMTString())
// gzip 压缩,文件流 pipe 回去
const stream = fs.createReadStream(filePath, {
flags: 'r'
})
stream.on('error', () => {
res.writeHead(404)
res.end()
})
stream.pipe(zlib.createGzip()).pipe(res)
前端早早聊会时不时发一些面向技术小白的学习文章,大家可以果断关注本账号,常年跟进新动态。
别忘了第二十九届|前端数据可视化专场,高强度一次性洞察可视化的前端玩法,7-17 全天直播,9 位讲师(阿里云/蚂蚁/奇安信/小米等),报名上车? ):
所有往期都有全程录播,可以购买年票一次性解锁全部
?更多活动
点赞,评论,求 Mark。
最后的效果
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!