# 正文

前几节我们了解并实现了组件的直出和基本同构,完成了一个组件在服务端和浏览器端的渲染,并且实现了基本的交互-绑定事件。但这毕竟只能是算是demo,用于实际的项目开发还差的远。

所以从本节正式开始建造 react ssr 同构应用开发骨架,如同开始建造房子的骨架。

要想这个骨架能用于实际项目开发,最基本的就是要开发方便,用起来省事儿,另一个就是具备一些基础的能力,让我们只专注于业务开发。

哪些能力呢?

比如路由处理、副作用处理、seo支持、css 支持等。

现在从头开始吗?

前两节的内容呢?

当然不是,每一个小结都是层层递进的,都作为后面小结的基础,都是我们建造骨架的重要组成部分。

基于前几节的内容,同时为了方便后续的开发和调试,我们先来让这个项目的使用变得方便一些,就像是建房子的工具,不然咱们这个房子盖的太累,浪费时间和精力。

怎样才能更方便呢?

# 工程化支持第一步

大家有没有发现我们现在的 demo存在严重的体验问题。

服务的运行需要经过多次手动操作。

前端代码的构建需要手动执行编译

npm run dev

服务端代码的编译需要手动执行编译

npm run babel-node

最后手动启动服务

node app.js

后续的文件改动还要重新执行上面的步骤。

大家可以自行运行起来,体验一下这个过程。

工欲善其事,必先利其器

这种开发体验太差了,会浪费我们很多时间且严重影响心情,良好的开发体验可以让我们事半功倍,心情舒畅。

既然这样,那我们势必需要进行优化,通过程序或者工具来代替人工执行,以达到提升效率的目的。

# 确定优化目标

如何实现这个优化呢?

做之前我们先进行问题分析,然后设计出一套解决方案。

首先要明确我们想达到的一个具体的目标。

  • 前后端代码可以自动编译
  • 每次修改代码,node 服务可以自动重启

另外我也不想自己刷新页面,所以热更新也是必须的,不过本节不打算直接介绍热更新,在后面章节会介绍。

# 实现思路分析

前端代码构建体验优化

前端代码的构建可以使用 webpack 搞定,开启 watch 功能就可以了,更改了文件就会自动打包。

后端代码构建

后端代码这里分为两部分,一个是用来处理请求的 node 代码,只运行于服务端。

另一部分就是组件以及组件相关的代码,也需要在服务端运行来完成组件的 ssr,所以我们也需要打包一份给服务端使用。

在上一节我们使用的是babel cli来编译的,当然这是一个有效的方法,还有一个更好的办法就是使用webpack来编译。

诶?也能用webpapck?

不要以为webpack只能用于前端代码的构建,一切皆模块,只要是 js 代码都可以被编译,只是编译的目标平台不同而已。

使用 webpack 编译,并且开启watch监听,就能做到实时编译了。

node 服务自启

node 服务这里要分两部分来看待。

  • (1)服务的启动
  • (2)服务的自启

单纯的服务启动和自启,很简单,可以使用工具nodemon来完成,不过除了使用工具外是否还有其他的方法处理?

我们可以使用自定义进程

让一个服务运行在我们创建的进程中,然后在适当的时机杀掉进程(关闭服务),重新创建一个服务进程启动服务(重启启动)。

综合分析

上面我们分析了各个环节的实现思路,单纯的实现每一步应该没什么难度,但是多个操作如何才能统一为一个操作呢?

也就是可以通过一个命令来启动前端代码编译和监听、后端代码编译和监听、同时启动 node 服务并且能够自动重启。

好了,问题很明确了,实现方案也有了。

# 自定义进程

上面的多个操作可以看做是独立的服务,可以让每个服务运行在独立的进程中,然后利用进程间通信来达到我们要的效果。

启动阶段

在这个阶段启动各个服务。

监听阶段

webpack开启 watch选项后可以自动编译,这个不用我们来干预。

node服务的自启,可以在服务端代码构建完成的时候执行。

# 相关实现

# 项目目录

先来规定下本骨架的目录结构,可以大概的了解下都包含哪些资源。

├── dist // 生产环境打包后的资源存放目录
│ ├── static //打包的静态资源文件
│ | ├── index.js // 打包后的文件
│ ├── server //服务端文件目录
│ | ├── app.js //node server 启动入口
├── src // 源码目录
│ ├── client //前端代码
│ │ ├── app // 前端渲染入口
│ | ├── pages // 业务页面
│ │ | ├── index //默认首页
│ │ ├── router // 路由配置
│ │ ├── common // 存放通用组件和通用模块
│ ├── server // node 代码
│ │ ├── app // 服务入口
│ │ ├── middlewares //中间件
│ │ | ├──  react-ssr.js //ssr 中间件
│ ├── share // 双端共享的代码可以放这里
├── webpack //构建配置
│ ├── scripts //构建脚本目录
│ │ | ├──  start.js //开发环境的所有服务启动入口
│ ├── webpack.dev.config.js //前端代码的开发环境编译配置
│ ├── webpack.server.config.js 服务端代码的编译配置

# 确定执行命令

开发环境的启动入口

npm run dev // 用来启动开发环境

对应的package.json配置

"dev": "node webpack/scripts/start.js",

前端代码编译开启 watch

 "scripts": {
    //...
    "fe:watch": "webpack --config  ./webpack/webpack.dev.config.js --watch",
    //...
  },

服务端代码打包配置

和前端的打包配置差不多,需要配置target=node, 增加externals 选项,使用webpack-node-externals来排除不需要打包的模块,因为 node 端会自动载入这些包,可以让打包的文件更小。

// ./webpack/webpack.server.config

//webpack 配置文件
const path = require('path')
const nodeExternals = require('webpack-node-externals')
const webpack = require('webpack');
const resolvePath = (pathstr) => path.resolve(__dirname, pathstr);

process.env.BABEL_ENV = 'node';//设置 babel 的运行的环境变量

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

module.exports = {
    target: 'node',
    entry: resolvePath('../src/server/app/index.js'),//入口文件
    output: {
        filename: 'app.js',
        path: resolvePath('../dist/server')
    },
    externals: [nodeExternals()],
    module: {
        rules: [{
            test: /\.jsx?$/,
            loader: 'babel-loader',
            exclude: /node_modules/
        }
        ]
    }
}

通过 api 方式启动服务端代码监听

因为我们需要监听服务端代码的构建过程,当每次编译完成时,通知主进程重启 node 服务,cli 模式下无法满足需要,所以需要调用api 来执行构建监听。

// ./webpack/scripts/svr-code-watch.js

//基于 webpack  开启对服务端代码的编译和监听
//配置文件为 webpack.server.config.js

const webpack  = require('webpack');
const config =  require('../webpack.server.config');
const constantCode = require('./constant');
config.mode='development';//设置编译模式


//编译对象
const compiler = webpack(config);

const watching = compiler.watch({
    aggregateTimeout: 300, // 类似节流功能,聚合多个更改一起构建
    ignored: /node_modules/, //排除文件
    poll: 2000, //轮训的方式检查变更 单位:秒  ,如果监听没生效,可以试试这个选项.
}, (err, stats) => {

    let json = stats.toJson("minimal");
    if(json.errors){
        json.errors.forEach(item => {
            console.log(item);
        });
    }
    if (json.warnings) {
        json.warnings.forEach(item => {
            console.log(item);
        });
    }

    //定一个常量,编译完成后 通知主进程来重启node 服务,主进程通过此标志来进行判断是否重启
    console.log(constantCode.SVRCODECOMPLETED);
});

compiler.hooks.done.tap('done',function (data) {
    console.log('\n svr code done' ); //编译完成动作
});

//收到退出信号 退出自身进程
process.stdin.on('data', function (data) {
    if (data.toString() === 'exit') {
        process.exit();
    }
});

ps: 文件 './constant'说明

该文件用来存放开发环境构建过程中的常量

// ./webpack/scripts/constant.js

//用于开发环境的构建过程中的常量
module.exports = {
    SVRCODECOMPLETED:'SVRCODECOMPLETED',//表示服务端代码编译完成的标志
}

配置服务端代码构建执行命令

  "scripts": {
   //...
    "svr:watch": "node ./webpack/scripts/svr-code-watch.js"
  },

创建node 服务启动入口

为了方便控制,我们单独创建一个开发环境的node 服务启动入口,引入打包后的入口文件即可。

// ./webpack/svr-dev-server.js

//开发环境 node 服务启动入口

//公用配置文件,定义一些通用的数据
const proConfig = require('../../src/share/pro-config');

//node server port
const nodeServerPort = proConfig.nodeServerPort;


//启动前检查端口是否占用,杀掉占用端口的进程
require('./free-port')(nodeServerPort);

//引入打包后的入口文件,这个入口我们在 webpack 配置中已设置好
require('../../dist/server/app');

最终编译入口处理

通过创建子进程的方式,整合多个服务到统一入口。

使用const {spawn} = require('child_process');//用于创建子进程 来创建子进程,此方式为异步执行。

然后通过子进程的std进行通信,达到重启 node服务的作用。

启动入口 ./webpack/scripts/start.js
const {spawn} = require('child_process');//用于创建子进程
const constantCode = require('./constant');
const chalk = require('chalk');//为控制台输出的信息增加点色彩
const log = console.log;
const proConfig  = require('../../src/share/pro-config');//双端的配置文件,配置一些基础参数,具体说明在后面

//node server port
const nodeServerPort = proConfig.nodeServerPort;

log(chalk.red('servers starting....'));

//前端代码构建 服务进程
const feCodeWatchProcess = spawn('npm', ['run', 'fe:watch'],{stdio:'inherit'});

//服务端代码监控和编译进程
const svrCodeWatchProcess = spawn('npm', ['run', 'svr:watch']);

//node 服务进程
let nodeServerProcess=null;
//启动 node 服务
const startNodeServer = () => { //重启 node 服务
    nodeServerProcess && nodeServerProcess.kill();
    nodeServerProcess = spawn('node',['./webpack/scripts/svr-dev-server.js']);
    nodeServerProcess.stdout.on('data', print);
}

//控制台输出信息
function print(data) {
    let str = data.toString();
    if (str.indexOf(constantCode.SVRCODECOMPLETED) > -1) { //接收到服务端代码编译完成的通知
        startNodeServer();//重启 node 服务
    } else {
        console.log(str);
    }
}

//监听服务端代码构建服务的对外输出  stdout 事件
svrCodeWatchProcess.stdout.on('data',print);

//杀掉子进程
const killChild=()=>{
    svrCodeWatchProcess && svrCodeWatchProcess.kill();
    nodeServerProcess && nodeServerProcess.kill();
    feCodeWatchProcess && feCodeWatchProcess.kill();
}

//主进程关闭退出子进程
process.on('close', (code) => {
    console.log('main process  close', code);
    killChild();
});
//主进程关闭退出子进程
process.on('exit', (code) => {
    console.log('main process  exit', code);
    killChild();
});

//非正常退出情况
process.on('SIGINT', function () {
    svrCodeWatchProcess.stdin.write('exit', (error) => {
        console.log('svr code watcher process exit!');
    });
    killChild();
});

ps:文件('../../src/share/pro-config')说明

该文件是服务端和客户端的公用基础配置文件,所以放在了share目录下

// ./src/share/pro-config.js

//双端公用的配置文件
module.exports = {
    wdsPort:9002,//webpack dev server 服务的运行端口
    nodeServerPort:9001,//node server 的监听端口
}

看下最终运行效果

image-20210214213802740

服务端代码的构建日志信息已隐藏,只在有错误的时候才会输出,现在在编译完成时会通知主进程重启 node 服务。

到这里,我们就基本上完成了一个重要的开发体验升级,为我们后续的进展提供了便利。

# 另外一种方式

下面给大家简单介绍另外一种方式

可以使用npm-run-all工具,该工具可以同时并行多个npm 命令,只需要配置下就可以了。

举个栗子

npm-run-all --parallel fe:watch svr:watch node:server

其实本质也是通过进程来完成的。

不过对于我个人来说,还是喜欢能自己实现的尽量自己来实现,这样可以学以致用,学习的东西不实践的话永远只是纸上谈兵。

当然工具的使用也必不可少,看具体的情况,因为它确实能够帮我们大大提高效率。

# 小结

这一节我们逐步分析并实现了一个基本的工程化的搭建和配置,让我们的项目在开发和调试的时候更加便利。

对于实现方式,本文算是其中一种,也可能还有其他方法,但是思想才是最重要的。只要有了思路,相信后面的实现也只是个时间问题。

我们应用骨架,走到这里就方便多了,但是还不够,不够彻底。

现在更改了代码,还需要刷新页面来查看效果,所以后面还需要加入热更新机制,这个优化我们后面再慢慢聊。

本节完整代码:

github.com/Bigerfe/koa… (opens new window)

阅读全文