阅读 7892

走在JS上的全栈之路(一)

(这是一个系列文章:预计会有三期,第一期会以同构构建前后端应用为主,第二期会以GraphQL和MySQL为主,第三期会以Docker配合线上部署报警为主)

作者: 赵玮龙 (为什么总是我,因为有队友们无限的支持!!!)

首先声明下写这篇文章的初衷,自己也还是在全栈之路探索的学徒而已。写系列文章其一是记录下自己在搭建整站中的一些心得体会(传说有一种武功是学了就会忘记的,那就是写代码。。。),其二是希望与各位读者交流下其中遇到的坑和设计思路,怀着向即将出现的留言区学习的心态来此~~


正片的分界线

同构应用本身的优缺点我不准备在这里阐述过多,并且也一直有很多争论的方向和论点,我们在这里就不展开了。当然如果你质疑同构应用的必要性,我也并不否认比如这篇文章就说得很好。那你可能会质疑为什么我还要写这个主题,原因是我们的全栈之路是能让我们做各种我们想做的事情而不受到技术的局限性。如果说我好奇他们争论的对错,顺手实现了呢?(希望你也常常抱着这样的态度去学习,那么你一定会走的更远!)

本文所有技术栈选型如下:

  • node = 10.0.0
  • react >= 16.3.0
  • react-router >= 4.2.0
  • webpack >= 4.6.0
  • isomorphic-fetch >= 2.2.0
  • koa >= 2.5.0
  • koa-router >= 7.4.0
  • react-redux >= 5.0.0
  • redux >= 4.0.0

如果你发现很多写法都变了是时候更新技术栈了少年~

我们开始之前先想一下同构应用需要解决哪些问题:

  • 代码兼容性(js宿主环境不一致node, browser)
  • 首屏渲染
  • 首屏渲染后数据同步问题
  • 前后端页面路由同步

代码兼容性问题

首先项目开始时我们先想一个问题运行在 browser 端的代码可以完美的运行在 node 端吗? 当然是不能的,但是我们同构的目的不就是希望代码的复用价值提高吗?我们先想一下有哪些地方是 node 端不支持的而在 browser 端必须使用的。比如全局 window 对象 node 端是 global ,还有 v10-node 端支持基本所有ES6语法都是支持的。而 browser 端因为浏览器兼容性问题并不是这样的,但是 module 方面 node 端却不支持 import 静态引用,而浏览器端的 webpack 已经支持基于 import 的 tree shaking 了。遇到这么多兼容问题。。不得不先感叹一下js执行环境的不一致啊,都统一成v8并且去掉全局变量模块不好吗?还是要有很长路要走的。

首先配置熟悉的 .babelrc (客户端的写法我在第一篇文章中有详细的说过,可以移步这里)其实同构应用只需要让node端兼容import以及react的jsx就ok了。当然了如果我们之后用 Babel 自然node的代码也不会直接运行在远端机而是会编译之后再运行。这个其实除去webpack 编译打包之外还有个小问题无非是node原生模块比如 require('path') , require('stream') 我们不希望被打包,这个只需要设置 target:node webpack会帮我们忽略掉这些模块。说了这么多,我们只是希望我们之前的 .babelrc 能够打包 node 代码,所以我们只需要在入口文件添加一个钩子 @babel/register (这个@的写法是 bable7 新版本的模块写法,我的第一篇文章中有提到)。下面我来看下我们可能遇到的第一个坑,本地开发阶段我们需要在开发过程中利用自己的已有node服务去编译 webpack 文件。保证客户端的代码可以顺利执行。

const webpack = require('webpack');
const logger = require('koa-logger');
const config = require('./webpack.config');
const webpackDevMiddleware = require('./middleware/koa-middleware-dev');

const router = new Router();
const app = new koa();

// const Production = process.env.NODE_ENV === 'production';

const compiler = webpack(config);
// logger记录
app.use(logger());
// 替换原有的webpack-dev-middleware
app.use(webpackDevMiddleware(compiler, {
  publicPath: config.output.publicPath,
}));
复制代码

先说第一个坑,可以从代码中看到我们自己实现了一个属于自己的 webpackDevMiddleware ,原因是因为koa本身没有成熟的 webpack-dev-middleware 这个插件本身是基于 express 造的,所以我们就自己实现一个也并不麻烦:

const devMiddleware = require('webpack-dev-middleware');

module.exports = (compiler, option) => {
  const expressMiddleware = devMiddleware(compiler, option);

  const koaMiddleware = async (ctx, next) => {
    const { req } = ctx;
    // 修改res的兼容方法
    const runNext = await expressMiddleware(ctx.req, {
      end(content) {
        ctx.body = content;
      },
      locals: ctx.state,
      setHeader(name, value) {
        ctx.set(name, value);
      }
    }, next);
  };

// 把webpack-dev-middleware的方法属性拷贝到新的对象函数
  Object.keys(expressMiddleware).forEach(p => {
    koaMiddleware[p] = expressMiddleware[p];
  });

  return koaMiddleware
}
复制代码

可以看到我们主要是要兼容 koa 的 async 函数以及里面参数的问题, express 的中间件的是 (req, res, next) => {} 而 koa 的中间件是 (ctx, next) => {} 所以我们需要转换下形式并且在 express 会有部分 api 和 express 中不一致 导致我们需要转换下方法,具体到 webpack-dev-middleware 用到哪些方法有兴趣的可以浏览下它的源码,这里我们就不做源码解析了。简单说明下只有三个方法在用。

express => koa

res.end => ctx.body            关闭http请求链接,并且设置回复报文体
res.locals => ctx.state        设置挂载穿透namespace
res.setHeader => ctx.set       header设置
复制代码

首屏渲染(涵盖路由同步)

首屏渲染我们要面临的问题会涉及到前后端路由同构,所以我们就放在这里一起说。服务端首屏第一步需要对于路由进行匹配(直接上代码):

// 采用koa-router的用法
app.use(router.routes())
   .use(router.allowedMethods());

appRouter(router);

// 然后设置appRouter函数
module.exports = function(app, options={}) {
  // 页面router设置
  app.get(`${staticPrefix}/*`, async (ctx, next) => {
    // ...内容
  }
  // api路由
  app.get(`${apiPrefix}/user/info`, async(ctx, next) => {
    // ...内容
  }
}  
// 我们发现为了和服务的请求api区分开我们会在路由的前缀做一下区分当然名字如你所愿
复制代码

既然我们匹配了 页面/* 路由,作为单页面应用我们还需要有一个依赖的 layout 模版,先想一下模版需要哪些需要替换信息:

  • 每个页面的title不同
  • react操作的root节点(替换body)
  • 可替换script标签内的 window对象下的__INITIAL_STATE__(这个我们会放到后面数据同步去详细说)
  • 可替换的js文件(用于客户端代码执行,生产环境和线上环境的js会不一样。主要依据线上可执行代码的打包,webpack的工作,我们到后期系列-发布环节的时候会提到这个问题!)

好根据这几点我们看一下我们的 layout 模版应该是大概长什么样:

const Production = process.env.NODE_ENV === 'production';

module.exports = function renderFullPage(html, initialState) {
  html.scriptUrl = Production ? '' : '/bundle.js';
  return `
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8">
        <meta http-equiv="x-ua-compatible" content="IE=edge, chrome=1">
        <meta httpEquiv='Cache-Control' content='no-siteapp' />
        <meta name='renderer' content='webkit' />
        <meta name='keywords' content='demo' />
        <meta name="format-detection" content="telephone=no" />
        <meta name='description' content='demo' />
        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
        <title>${html.title}</title>
      </head>
      <body>
        <div id="root">${html.body}</div>
        <script type="application/javascript">
          window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}
        </script>
        <script src=${html.scriptUrl}></script>   
      </body>
    </>
  `
}

// 其中 scriptUrl 会根据后期上线设置的全局变量来改变。我们开发环境只是把 webpack-dev-middleware 帮我们打包好放在内存中的bundle.js文件放入html,生产环境的js文件我们后放到后期系列去说
复制代码

在发送的过程中除去 scriptUrl 和 initialState 以外呢,我们需要一个可替换的 title ,以及 body 可替换的 title 我们采用 react-helmet 具体的使用方法我们就不多的赘述了。有兴趣的可以看这里

在说如何塞入 body 之前我们会先去说一下整个渲染过程的流程图:

+-------------+                     +--------------+
|             |     api, js         |              |
|             +--------------------->              |
|   SERVER    |                     |    CLIENT    |
|             |                     |              |
|             <---------------------+              |
+---+---------+     api, js         +-------^------+
    |                                       |
    |                                       |
    |            +----------------+         | render
    |            |                |         |
    |            |      HTML      |         |
    +------------>                +---------+
                 +----------------+


复制代码

我们看到图中其实是第一次会吐出一个涵盖所有首屏所需要展示内容的完整html里面的js代码请求就是我们之前塞进模版的 scriptUrl ,后续如果还有用户行为的操作都会通过js中的请求 api 和服务端交互。这些都和正常的客户端逻辑没有区别了。那么关键点在于服务端需要渲染完整的html。 我们从这里开始:

// 页面route match
export const staticPrefix = '/page';

// routes定义
export const routes = [
  {
    path: `${staticPrefix}/user`,
    component: User,
    exact: true,
  },
  {
    path: `${staticPrefix}/home`,
    component: Home,
    exact: true,
  },
];
// route里的component筛选以及拿到相应component里相应的需要首屏展示依赖的fetchData
const promises = routes.map(
  route => {
    const match = matchPath(ctx.path, route);
    if (match) {
      let serverFetch = route.component.loadData
      return serverFetch(store.dispatch)
    }
  }
)
// 注意这时候需要在确认我们的数据拿到之后才能去正确的渲染我们的首屏页面
const serverStream = await Promise.all(promises)
.then(
  () => {
    return ReactDOMServer.renderToNodeStream(
      <Provider store={store}>
        <StaticRouter
          location={ctx.url}
          context={context}
          >
          <App/>
        </StaticRouter>
      </Provider>
    );
  }
);
// 这里的关键点我们会在后面详细阐述,我们采用了react 16新的api renderToNodeStream
// 正如这个api的名称一样,我们可以得到的不是一个字符串了,而是一个流
// console.log(serverStream.readable);  可以发现这是一个可读流
await streamToPromise(serverStream).then(
  (data) => {
    options.body = data.toString();
    if (context.status === 301 && context.url) {
      ctx.status = 301;
      ctx.redirect(context.url);
      return ;
    }

    if (context.status === 404) {
      ctx.status = 404;
      ctx.body = renderFullPage(options, store.getState());
      return ;
    }
    ctx.status = 200;
    ctx.set({
      'Content-Type': 'text/html; charset=utf-8'
    });
    ctx.body = renderFullPage(options, store.getState());
})
// console.log(serverStream instanceof Stream); 同样你可以检测这个serverStream的数据类型
复制代码

我们着重讲一下这个流的问题,还有 node 里面的异步回调的问题。 首先熟悉 node 的同学肯定对流不是很陌生了。这里我们只是概念性的说一下。如果想非常详细的了解流,建议还是去官网和别的专门说流的一些帖子比如国内的 cnode 论坛等。

流是数据的集合 —— 就像数组或字符串一样。区别在于流中的数据可能不会立刻就全部可用,并且你无需一次性地把这些数据全部放入内存。这使得流在操作大量数据或是数据从外部来源逐段发送过来的时候变得非常有用。

我们看到这个概念的时候会发现如果发送的首屏的 html 很大的话,采用流的方式反而会减轻服务端的压力。 既然 react 给我们封装了这个 api ,我们自然可以发挥它的长处。 我们来大概扫一眼可读流和可写流在 node 中有哪些 api 可用(这里我们先不去谈可读可写流)

  • 可写流~ events: data ,finish , error, close, pipe/unpipe

  • 可写流~ functions: write(), end(), cork(), uncork()

  • 可读流~ events: data, end, error, close, readable,

  • 可读流~ functions: pipe(), unpipe(), read(), unshift(), resume(), setEncoding()

这里我能用到的是可读流,上面代码中的两个 console.log() 也是帮我们确定了react的流类型。 既然是可读流我们需要发送到客户端可以利用监听事件监听流的发送和停止或者利用 pipe 直接导入到我们的可写流 res.write 上发送或者是 end() ,这里就是 pipe 方法的魔法,它pipe上游必须是一个可读流,下游是一个可写流,当然双向流也是可以的。那么思考上面的代码:

const serverStream = await Promise.all(promises)
.then(
  // ...内容
);

// 依然可以发送我们的可读流,但是之所以我没有这么写原因还是在于我希望动态的拼写html,并且在代码组织上把html模版单独提出一个文件
res.write('<!DOCTYPE html><html><head><title>My Page</title></head><body>')
res.write('<div id='root'>')
serverStream.pipe(res, { end: false });
serverStream.on('end', () => {
  res.write("</div></body></html>");
    res.end();
})
// 这么做会利用流的逐步发送功能达到数据传输效率的提升。但是我个人觉得代码的耦合性比这一些性能优化要来的更加重要,这个也要根据你的个人需求来定制你喜欢和需要的模式
复制代码

还有个疑问你可能比较在意我们分析下上面代码:

await streamToPromise(serverStream).then(
  // ...内容
)
// 你可能觉得有点奇怪为什么我不用监听事件呢?而要把这个流包装在streamToPromise里,我是怎么拿到流的变化的呢?
复制代码

这个详细的可以查看streamToPromise源码其实源码并不难。我们的目的是要让 stream 变成 promise 格式,变幻的过程当中主要是监听读写流的不同事件利用 buffer 数据格式,在各种相应的状态去做 promise 化,为什么需要这样做呢?原因还在于我们使用的koa。

我们都知道 async 函数的原理,如果你想了解更多koa的原理我还是建议看源码。我们这里要说明下整体原因,我们的回调函数会被 koa-router 放到 koa 的中间件use里,那么在koa中间件执行顺序中是和 async 的执行顺序一样除非你调用 next() 方法,那么如果你放在stream事件监听的回调函数里异步执行,其实这个 router 会因为你没有设置 res.end() 和 ctx.body 而执行koa 默认的代码返回404 NotFound所以我们必须在 await 里执行我们的有效返回代码!在我们有效返回我们的模版之后他会涵盖了我们的有效模版代码:

html内容

除去这些我们还会在服务端做相应的 redirect 和 4** 错误页面的一个定位转发我们响应准备好的页面:

// redirect include from to status(3**)
const RedirectWithStatus = ({ from, to, status }) => (
  <Route
    render={
      ({ staticContext }) => {
        if (staticContext) {
          staticContext.status = status;
        }
        return <Redirect from={from} to={to} />
      }
    }
  />
);

// 传递status给服务端
const Status = ({ code, children }) => (
  <Route
    render={
      ({ staticContext }) => {
        if (staticContext) {
          staticContext.status = code;
        }
        return children
       }
    }
  />
);

// 404 page
const NotFound = () => (
  <Status code={404}>
    <div>
      <h1>Sorry, we can't find page!</h1>
    </div>
  </Status>
);


const App = () => (
  <Switch>
    {
      routes.map((route, index) => (
        <Route {...route} key={index} />
      ))
    }
    <RedirectWithStatus
      from='/page/fuck'
      to='/page/user'
      status={301}
      exact
    />
    <Route component={NotFound} />
  </Switch>
);

复制代码

我们看到其实这些都是在react-router中做的兼容,那我们怎么在服务端拿到比如说相应的 status,比如 4** ,3** 这些状态值,我们需要在 server 端监控到这些重定向或者无法找到页面的状态。这里面 react-router 4 给我提供了 context 这个变量,注意它只在 server 端有, 所以在共用一套代码的时候 需要兼容 if (staticContext) 的写法保证代码不会报错, 并且这个 context 是你自己可以定义任何你想传输的属性,并且在 server 端也拿得到:

//  例如这样的判断
if (context.status === 301 && context.url) {}
复制代码

首屏渲染后数据同步问题

终于该轮到我们说数据同步的问题了,其实数据同步也非常简单。我们这里利用 redux 来做,其实不管用什么首先我们会把刚才服务端首屏渲染的数据在不通过 api 的方式放松给客户端,那么毫无疑问只有一个方法:

// 放在页面html中带过去,让客户端从window对象上拿
<script type="application/javascript">
  window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}
</script>
复制代码

至于 redux 数据的生成其实跟客户端一样,如果你感兴趣可以参考我前一篇文章

那么经过以上的种种坑过后,那么恭喜你已经有一个同构应用的雏形了。作为系列文章的开篇往往还是需要卖一个关子,完整的全栈项目 demo 会在系列完成之后给出 github 地址,敬请期待!

以上所说的所有项目中的体感,看法仅仅代表个人看法,如果你有不同的意见和自己更加独到的见解,期待在下面看到你的留言。还是那句话,希望大家在共同踩坑的同时共勉前行。也希望这里的拙见对你可能有所帮助或者启发!