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

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

做者: 赵玮龙 (为何老是我,由于有队友们无限的支持!!!)html

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


正片的分界线node

同构应用自己的优缺点我不许备在这里阐述过多,而且也一直有不少争论的方向和论点,咱们在这里就不展开了。固然若是你质疑同构应用的必要性,我也并不否定好比这篇文章就说得很好。那你可能会质疑为何我还要写这个主题,缘由是咱们的全栈之路是能让咱们作各类咱们想作的事情而不受到技术的局限性。若是说我好奇他们争论的对错,顺手实现了呢?(但愿你也经常抱着这样的态度去学习,那么你必定会走的更远!)react

本文全部技术栈选型以下:webpack

  • 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

若是你发现不少写法都变了是时候更新技术栈了少年~git

咱们开始以前先想一下同构应用须要解决哪些问题:github

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

代码兼容性问题

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

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

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 地址,敬请期待!

以上所说的全部项目中的体感,见解仅仅表明我的见解,若是你有不一样的意见和本身更加独到的看法,期待在下面看到你的留言。仍是那句话,但愿你们在共同踩坑的同时共勉前行。也但愿这里的拙见对你可能有所帮助或者启发!

相关文章
相关标签/搜索