本章讲了四个页面:下载页、列表页、详情页、播放页的简单实现。

  • 列表页:SEO价值、前后端通信
  • 详情页:相对简单
  • 播放页:不看播放器,主要就是评论列表
  • 下载页:静态页面基本上

网站架构

image.png
上面的图解释下:

  • 下载页:浏览器请求到达BFF之后,路由中间件判断是下载页,返回页面html,以及其他静态资源 ;
  • 详情页:浏览器请求路由匹配后,BFF拿到id,在通过ID去请求后台server拿到数据(RPC通信),然后在渲染模版(模版引擎)并返回;
  • 播放页:比详情页多了Ajax请求(页面上有点赞操作);
  • 列表页:这里简单实现了前后端同构的技术方案;

    APP下载页

    APP下载页其实就是路由匹配后返回静态文件,代码如下: ```javascript

const koa = require(‘koa’); const fs = require(‘fs’); const mount = require(‘koa-mount’); const static = require(‘koa-static’);

const app = new koa();

app.use( static(__dirname + ‘/source/‘) );

app.use( mount(‘/‘, async (ctx) => { ctx.body = fs.readFileSync(__dirname + ‘/source/index.htm’, ‘utf-8’) }) );

// app.listen(4000); module.exports = app;

  1. 这里的静态文件用的是koa-static,请求到了之后先返回了/source/index.htm,在/source/index.htm中有一些link标签,他们需要的文件因为又了koa-static制定的目录,其目录下面的文件都能按照路径返回。<br />路由用中间件的是koa-mount
  2. <a name="fbxnK"></a>
  3. ### 详情页面
  4. 详情页面的技术点:模版引擎、RPC通信
  5. <a name="uHrB0"></a>
  6. #### 模版引擎
  7. 模版引擎要点
  8. - include 子模板
  9. - xss 过滤、模板 helper 函数
  10. 课程使用了es6 模版字符串,借用了vm模块的runInNewContext方法,实现了一个类似ejs的基本模版引擎。
  11. 1. 如何把字符串作为代码
  12. - eval
  13. - new Function(codeStr)()
  14. ```javascript
  15. let code = `var a = 10; let b = 20; console.log('b is:', b)`
  16. new Function(code)()
  • node的vm模块 ```javascript const vm = require(‘vm’) global.a = 100;

// 运行在当前环境中[当前作用域] vm.runInThisContext(‘console.log(a)’); // 100

// 运行在新的环境中[其他作用域] vm.runInNewContext(‘console.log(a)’); // a is not defined

  1. vm.runInThisContextvm.runInNewContext其不同点就是代码的作用域不同,vm.runInNewContext需要给一个参数,作为作用域对象,所以大致假设一个字符串:
  2. ```javascript
  3. `<div>${user.name}</div>`


那么利用vm模块我们把user指定为其作用域

  1. const user = { name: 'yxnne' }
  2. vm.runInNewContext('`<div>${user.name}</div>`', { user })
  • 转义

不过需要转义一下,因为user可能包含script标签
比如:

  1. const user = {
  2. name : '<script>alert()</script>'
  3. }

所以增加一个转义函数

  1. const user = {
  2. name : '<script>alert()</script>'
  3. }
  4. // 调用转义函数 _
  5. vm.runInNewContext('`<div>${ _(user.name) }</div>`', {
  6. user,
  7. _: function () {
  8. if (!markup) return '';
  9. return String(markup)
  10. .replace(/&/g, '&amp;')
  11. .replace(/</g, '&lt;')
  12. .replace(/>/g, '&gt;')
  13. .replace(/'/g, '&#39;')
  14. .replace(/"/g, '&quot;')
  15. }
  16. }
  17. })
  • include实现

include可以在当前模版中包含其他模版的代码,这里的思路还是定义一个include函数,inclue函数可以接受模版名称,返回模版代码:

  1. const context = {
  2. include(name) {
  3. return templateMap[name]()
  4. },
  5. _: function(markup) { /* ..略.. */ }
  6. }

我们把所有模版用对象作成k-v的形式,这样就能根据name对应上了:

  1. const templateMap = {
  2. templateA: '`<h2>${include("templateB")}</h2>`',
  3. templateB: '`<p>hahahaha</p>`'
  4. }

把模版对象里面统一变成函数,这样:

  1. Object.keys(templateMap).forEach(key=> {
  2. const temp = templateMap[key];
  3. templateMap[key] = vm.runInNewContext(`
  4. (function() {return ${ temp }})
  5. `, context);
  6. })
  7. console.log(templateMap['templateA']());

以上只是思路。
按照思路,封装一个简单的,完整的:

  1. const fs = require('fs');
  2. const vm = require('vm');
  3. const templateCache = {};
  4. // 创建模板渲染context
  5. const templateContext = vm.createContext({
  6. include: function (name, data) {
  7. const template = templateCache[name] || createTemplate(name)
  8. return template(data);
  9. }
  10. });
  11. /**
  12. *
  13. * @param {*} templatePath 模板路径
  14. * @returns 渲染好的html字符串
  15. */
  16. function createTemplate(templatePath) {
  17. templateCache[templatePath] = vm.runInContext(
  18. `(function (data) {
  19. with (data) {
  20. return \`${fs.readFileSync(templatePath, 'utf-8')}\`
  21. }
  22. })`,
  23. templateContext
  24. );
  25. return templateCache[templatePath]
  26. }
  27. module.exports = createTemplate

RPC通信

还是采用tcp建立全双工通信,协议使用protocol-buffers的方案。
整体代码部分在这里。

详情页实现

code: 详情页代码

播放页面

这一页的整体流程是,用户请求到达BFF,BFF请求server拿到相关数据后,render模版,用户侧呈现页面。不过这页存在交互行为,所以需要发起Ajax请求。
API的方案

  • RESTful
    • 简单易懂
    • 可以快速搭建
    • 在数据的聚合方面有很大劣势(涉及多个资源,可能需要多个请求,而且存在大量的无用属性)
  • GraphQL
    • 专注数据聚合,前端需要什么就拿什么

      GraphQL

      简单讲就是前端可以定义自己想要获得的数据,后端定义每一个字段的获取方式,当前端发来请求后,后端按照既定的方式聚合数据。
      graphQL是发生在BFF层聚合数据的技术,并不是前后端通信的方式。
      比如前端发请求:
      1. fetch('url?query=hello').then(res=> {
      2. console.log(res);
      3. })

BFF接受到query=hello后:

  1. const { graphql, buildSchema } = require('graphql');
  2. // graphql schema协议
  3. const schema = buildSchema(`
  4. type Query {
  5. hello: String
  6. }
  7. `);
  8. // 获得数据的方式
  9. root = {
  10. hello: () => {
  11. return 'Hello world!';
  12. },
  13. };
  14. // 路由把hello参数取出然后调用这里
  15. function(query) {
  16. // 依照graphql的规则去取数据
  17. return graphql(schema, query, root).then((response) => {
  18. return response;
  19. });
  20. }

koa- graphql是现成的koa中间件(),举个例子:

  1. const app = new (require('koa'));
  2. const graphqlHTTP = require('koa-graphql');
  3. app.use(
  4. graphqlHTTP({
  5. schema: require('./schema')
  6. })
  7. )
  8. app.listen(3000);
  1. const { graphql, buildSchema } = require('graphql');
  2. // 定义数据
  3. // type Query定义了查询的方法
  4. // 请求comment,返回Array<Comment>
  5. const schema = buildSchema(`
  6. type Comment {
  7. id: Int
  8. avatar: String
  9. name: String
  10. isTop: Boolean
  11. content: String
  12. publishDate: String
  13. commentNum: Int
  14. praiseNum: Int
  15. }
  16. type Query {
  17. comment: [Comment]
  18. }
  19. `)
  20. // 定义了饭回数据的方法
  21. schema.getQueryType().getFields().comment.resolve = () => {
  22. // 真实情况就return 真实取到的数据
  23. return [{
  24. id: 1001,
  25. avatar: "https://static001.geekbang.org/account/avatar/00/0f/52/62/1b3ebed5.jpg",
  26. name: "abc",
  27. isTop: true,
  28. content: "哈哈哈哈",
  29. publishDate: "今天",
  30. commentNum: 10,
  31. praiseNum: 5
  32. }]
  33. }
  34. module.exports = schema;

type Query定义了查询的方法请求comment,返回Array, 比如query={comment{name}},返回:

  1. {
  2. "data": {
  3. "comment" : [
  4. { "name": "abc"}
  5. ]
  6. }
  7. }

播放页实现

整体流程是,用户请求到达BFF,BFF请求server拿到相关数据后,render模版,用户侧呈现页面。不过这页存在交互行为,所以需要发起Ajax请求,前端Ajax请求到达后端后,后端使用GraphQL的Mutation的方式处理。Mutation就是对数据要有所改变,比如点赞评论,点赞之后点赞数要增加,这就是一个Mutation。

  1. const schema = buildSchema(`
  2. // ...
  3. type Mutation {
  4. praise(id: Int): Int
  5. }
  6. `)
  7. // 处理mutation
  8. // 处理name是praise的mutation
  9. schema.getMutationType().getFields().praise.resolve = (args0, { id }) => {
  10. mockDatabase[id].praiseNum++;
  11. return mockDatabase[id].praiseNum
  12. }

全部代码在这里:
code: 播放页面

列表页

前后端同构

这里讲解的技术方案是同构。
后端需要渲染列表页,基于一下几个点:

  • 首屏加速
  • SEO
  • 前端也需要渲染列表
  • 无刷新过滤、排序

啥是前前后端同构 ?
image.png
同一个模板/组件,可在浏览器渲染,也可在 Node.js 渲染

  • ReactDomServer.renderToString()
  • VueServerRenderer.renderToString()

举个列子:node端使用ReactDomServer.renderToString() 输出渲染后的组件,
假设现在有个React组件:

  1. const React = require('react');
  2. class App extends React.Component {
  3. render() {
  4. return (
  5. <p></p>
  6. )
  7. }
  8. }
  9. module.exports = <App />

借助babel进行语法转化,然后借助ReactDomServer.renderToString() 可以转化成html:

  1. require("@babel/register")({
  2. presets: ['@babel/preset-react']
  3. })
  4. const ReactDOMServer = require('react-dom/server');
  5. console.log(
  6. ReactDOMServer.renderToString(
  7. require('./index.jsx')
  8. )
  9. )

但是如果使用了redux或者vuex之类的,会相对麻烦点,不过推荐Next.js(),这是个成熟的同构方案。
axios也是前后端同构的请求方案,是HTTP请求器。
前后端同构的关键是职责分离,那一些事处理环境的,哪些事处理数据的,等等…

列表页实现

同构流程是这样:

  • 当浏览器请求到服务端后,服务端先得到页面数据,用数据渲染react组件,然后转成html string,插入到待返回的html中,然后将html返回给浏览器;
  • 在返回给浏览器的html中存在一段“同构代码”,就是react组件用webpack打包之后的js文件。
  • 当浏览器获得server返回的html之后,解析html呈现页面,可以快速的显示出html内容(执行到同构代码的时候,会再次渲染一次,但是用户感知不到,react有机制可以将开销最小化)
  • 当页面出现交互行为,发请求其实走的就是浏览器端执行的代码了,可以去请求API或者其他操作。

code: 列表页代码