前端烂笔头专注前端开发
首页/Egg.js + Mysql + React + Antd-Mobile 实战移动端私人日记本/
Egg.js + Mysql + React + Antd-Mobile 实战移动端私人日记本
2020-02-01 1023

课程大纲

一、Egg.js 基础入门

1、Egg.js 开发环境搭建及生成项目目录讲解

2、理解 Egg.js 的路由机制

3、编写简单的 GET 和 POST 接口

4、Egg.js 中如何使用前端模板

二、React 编写日记界面

1、React 开发环境搭建接入 Ant Design Mobile

2、通过 vw 适配移动端方案

3、日记列表页开发

4、日记详情页开发

5、日记编辑页面开发

三、Egg.js 服务端开发

1、本地安装 Mysql 数据库

2、Navicat 操作数据库创建日记表

3、编写添加日记接口、更新日记接口

4、编写获取日记列表接口、获取日记详情接口、删除日记接口

5、联调接口

四、总结

Egg.js 基础入门

简介

Egg.js 是啥呀?鸡蛋吗?开个小玩笑。Egg.js 是基于 Koa 的上层架构,简单说就是 Egg.js 是基于 Koa 二次开发的后端 node 解决方案。截止目前(2020-01-06) Egg 的最新版本为 v2.26.0Github 上的星星居高不下,目前已达到了14.6k+之多。可见大家对 Egg 的喜爱程度。

那么为什么我会选择 Egg 作为服务端的开发框架,而不选择 nest、Think.js、hapi等框架呢?首先 Egg 是阿里团队开发的,国内首屈一指的大厂。你不必担心这个框架的生态,更不用担心它会被停止维护,因为阿里内部很多系统也是在使用这个框架制作的。其次 Egg 在文档上做的不错,中英文文档对国人非常友好,说实话本人英文能力有限,虽说看看英文文档问题不大,但是多少看起来还是有点吃力。遇到问题的时候,还能去社区或者技术群里喊几句,遇到类似问题的朋友也会不惜余力的支援你。(普通小开发 不喜轻喷)

还有一个很重要的原因,Egg 继承于 Koa,在它的基础模型上,做了一些增强,在写法上可以说是十分便捷。相比之下 Koa 还是基础了,太多东西需要二次封装。在之后的开发中你会见识到 Egg 的强大之处。

Egg.js 开发环境搭建及生成项目目录讲解

我的环境:

  • 操作系统:macOS
  • node版本:12.6.0
  • npm 版本: 6.9.0

通过如下脚本初始化项目:

mkdir egg-demo && cd egg-demo
npm init egg
// 选择 simple 模式的
npm install

如果 npm 不能使用的话建议安装 yarn

初始化项目目录如下如所示:

项目文件结构分析

这里我挑重要的讲,因为有些开发中我们也不常去修改,不用浪费太多的精力去了解,当然有兴趣的小伙伴自己可以研究透彻一些。

  • app 文件夹:我们的主逻辑几乎都会在这个文件夹内完成,controller 是控制器文件夹,主要写一些业务代码,之后会在 app 文件夹里新建一个 service 文件夹,专门用来操作数据库,让业务逻辑和数据库操作分而治之,代码会清晰很多。
  • public文件夹:公用文件夹,把一些公用资源都放在这个文件夹下。
  • config 文件夹: 这里存放一些项目的配置,如跨域的配置、数据库的配置等等。
  • logs文件夹: 日志文件夹,正常情况下不用修改和查看里边内容。
  • run文件夹:运行项目时,生成的配置文件,基本不修改里边的文件。
  • test 文件夹: 测试使用的一些配置文件,测试接口的时候会用到。
  • .auto.conf.js: 项目自动生成的文件,一般不需要修改。
  • .eslintignore和.eslintrc: 代码格式化配置文件。
  • .gitignore: git 提交的时候忽略的文件。
  • package.json: 包管理和命令配置文件,开发时需要经常修改。

Egg.js 目录约定规范

Koa 之所以不适合团队项目的开发,是因为它缺少规范。Egg.js 在基于 Koa 的基础上制定了一些规范,所以我们放置一些脚本文件的时候,是要按照 Egg.js 的规范来的。

app/router.js 是放置路由的地方

public 文件夹放置一些公共资源如图片、公用的脚本等

app/service 文件夹放置数据库操作的内容

view 文件夹自然是放置前端模板的地方

middleware 是放置中间件的地方,这个很重要,鉴权等操作可以通过中间件的形式加入到路由,俗称路由守卫

还有挺多一些规范就不在此一一例举了,大家可以移步官方文档中文文档非常友好,向深入研究的同学可以挑灯夜读一番。

说了这么多好像忘记一件事情,咱们启动一下项目看看呗。在启动之前我们修改一点内容:

// /app/controller/home.js
'use strict';

const Controller = require('egg').Controller;

class HomeController extends Controller {
  async index() {
    const { ctx } = this;
    ctx.body = 'hi, egg';
  }
  async test() {
    const { ctx } = this;
    ctx.body = '测试接口';
  }
}

module.exports = HomeController;
// app/router.js
'use strict';

/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  router.get('/test', controller.home.test);
};

到项目根目录启动项目,命令行如下:

npm run dev
// 或者
yarn dev

正常情况下,Egg.js 默认启动 7001 端口,看到下图所示说明项目启动成功了。

我们通过浏览器查看如下所示:

我们在 /app/controller/home.js 文件中写的 test 方法成功被执行。

理解 Egg.js 的路由机制

路由(Router)主要用来描述请求 URL 和具体承担执行的 Controller 的对应关系,Egg.js 约定了 app/router.js 文件用于统一所有路由规则。

简单来说,上述例子,我们在 app/controller/home.js 里写了 test 方法,然后在 app/router.js 文件中将 test 方法以 GET 的形式抛出。这便是 URL 和 Controller 的对应关系。Egg.js 的方便就是体现在上下文已经为我们打通了,app 便是全局应用的上下文。路由和控制器都存放在全局应用上下文 app 里,所以你只需要关心你的业务逻辑和数据库操作便可,无需再为其他琐碎小事分心。

控制器(Controller)内主要编写业务逻辑,我们来了解一下如何命名,比如我现在希望新建一个与用户相关的控制器,我们可以这么写:

// 在 app/controller/ 下新建 user.js
'use strict';

const Controller = require('egg').Controller;

class UserController extends Controller {
  async index() {
    const { ctx } = this;
    ctx.body = '用户';
  }
}

module.exports = UserController;

首字母大写驼峰命名,UserController 继承 Controller ,内部可以使用 async、await 的方式编写函数。

编写简单的 GET 和 POST 接口

上面其实已经简单的写了如何编写 GET 接口,我们在这里就再加点别的知识点,获取路由上的查询参数,即 /user?username=nick 问好后面的便是查询参数,通过如下代码获取:

// 在 app/controller/user.js
'use strict';

const Controller = require('egg').Controller;

class UserController extends Controller {
  async index() {
    const { ctx } = this;
    const { username } = ctx.query;
    ctx.body = username;
  }
}

module.exports = UserController;

注意需要添加路由参数

'use strict';

/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  router.get('/test', controller.home.test);
  router.get('/user', controller.user.index);
};

再去浏览器访问一下,看看能否展示查询参数:

还有一种获取申明参数,可以通过 ctx/params 的方式获取到:

// 在 app/controller/user.js
'use strict';

const Controller = require('egg').Controller;

class UserController extends Controller {
  async index() {
    const { ctx } = this;
    const { username } = ctx.query;
    ctx.body = username;
  }

  async getid() {
    const { ctx } = this;
    const { id } = ctx.params;
    ctx.body = id;
  }
}

module.exports = UserController;
'use strict';

/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  router.get('/test', controller.home.test);
  router.get('/user', controller.user.index);
  router.get('/getid/:id', controller.user.getid);
};

如图所示,getid/999 后面的 999,被作为 ctx.params 里面的 id 被返回给了网页。

GET 讲完我们再讲讲 POST,开发项目时,我们在需要操作内容的时候便会使用到 POST 形式的接口,因为我们可能要传的数据包比较大,这里就不细说 GET 和 POST 接口的区别了,不然就变成面试课程了。真的要说我就说一句,它们没区别,都是基于 TCP 协议。

来看看 POST 接口在 Egg 中的应用,在上面说到的 app/controller/user.js 内添加一个方法:

...
async add() {
  const { ctx } = this;
  const { title, content } = ctx.request.body;
  // 框架内置了 bodyParser 中间件来对这两类格式的请求 body 解析成 object 挂载到 ctx.request.body 上
  // HTTP 协议中并不建议在通过 GET、HEAD 方法访问时传递 body,所以我们无法在 GET、HEAD 方法中按照此方法获取到内容。
  ctx.body = {
    title,
    content,
  };
}
...
'use strict';

/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  router.get('/test', controller.home.test);
  router.get('/user', controller.user.index);
  router.get('/getid/:id', controller.user.getid);
  router.post('/add', controller.user.add);
};

浏览器不方便请求 POST 接口,我们借助 Postman 来发送 POST 请求,没有下载的同学可以下载一个,对于开发来说 Postman 可以说是必备的工具,测试接口非常方便。当你点击 Postman 发送请求的时候,你会接收不到返回,因为请求跨域了,那么我们需要通过 egg-cors 这个 npm 包来解决跨域问题。首先安装它,然后在 config/plugin.js 中引入如下所示:

// config/plugin.js
'use strict';

exports.cors = {
  enable: true,
  package: 'egg-cors',
};

然后在 config/config.default.js 中加入如下代码:

// config/config.default.js
config.security = {
  csrf: {
    enable: false,
    ignoreJSON: true,
  },
  domainWhiteList: [ '*' ], // 配置白名单
};

config.cors = {
  // origin: '*', //允许所有跨域访问,注释掉则允许上面 白名单 访问
  credentials: true, // 允许 Cookie 跨域
  allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS',
};

我目前配置的是全部可访问。然后再重新启动项目,打开 Postman 请求 add 接口如下所示,注意请求体需要 JSON(Application/json) 形式:

说到这里,不得不提 Service 服务。我们上面的接口业务逻辑都是放在 Controller 里面,若是我需要操作数据库的情况,我们就需要把操作数据库的方法放在 Service 里。

首先我们新建文件夹 app/controller/service ,在文件夹内新建 user.js 代码如下:

'use strict';

const Service = require('egg').Service;

class UserService extends Service {
  async user() {
    return {
      title: '你妈贵姓',
      content: '免贵姓李',
    };
  }
}
module.exports = UserService;

然后去 app/controller/user.js 里进行调用:

...
async index() {
  const { ctx } = this;
  const { title, content } = await ctx.service.user.user();
  ctx.body = {
    title,
    content,
  };
}
...
// app/router.js
...
router.post('/getUser', controller.user.index);

每次在控制器内新增方法,一定不要忘记在 router,js 内增加路由。

目前还没连接数据库,姑且先将就着这么写,真实连接数据库,会在 service 文件夹内创建一些数据库相关操作的脚本,后续的内容会说明。

Egg.js 中如何使用前端模板

若是有同学需要制作简单的静态页,类似公司的官网、宣传页等,可以考虑使用前端模板来编写页面。

首先我们安装模板插件 egg-view-ejs

npm install egg-view-ejs -save

然后在 config/plugin.js 里面声明需要用到的插件

exports.ejs = {
  enable: true,
  package: 'egg-view-ejs',
};

接着我们需要去 config/config.default.js 里配置 ejs ,这一步我们会将 .ejs 的后缀改成 .html 的后缀。

config.view = {
   mapping: {'.html': 'ejs'} //左边写成.html后缀,会自动渲染.html文件
};

app 目录下创建 view 文件夹,并且新建一个 index.html 文件如下所示:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title><%-title%></title>
</head>
<body>
    <!-- 使用模板数据 -->
    <h1><%-title%></h1> 
</body>
</html>

修改 app/controller/home.js 脚本如下所示:

// app/controller/home.js
'use strict';

const Controller = require('egg').Controller;

class HomeController extends Controller {
  async index() {
    const { ctx } = this;
    // index.html 默认回去 view 文件夹寻找,Egg 已经封装好这一层了
    await ctx.render('index.html', {
      title: '你妈贵姓',
    });
  }
  async test() {
    const { ctx } = this;
    ctx.body = '测试接口';
  }
}

module.exports = HomeController;

重启整个项目,浏览器查看 http://localhost:7001 如下图所示:

title 变量已经被加载进来,模板正常显示。

到这一步同学们顺利的跟下来,基本上对 Egg 有了一个大致的了解,当然光了解这些基础知识不足以完成整个项目的编写,但是基础还是很重要的嘛,毕竟 Egg 是基于 Koa 二次封装的,很多内置的设置项需要通过小用例去熟悉,希望同学们不要偷懒,跟完上面的内容,最好是不要复制粘贴,逐行的去敲完才能真正的变成自己的知识。

React 编写日记界面

简介

自 React 16.8 发布之后,React 引入了 Hooks 写法,即函数组件内支持状态管理。什么概念呢,就是我们在用 React 写代码的时候,几乎可以抛弃之前的 Class 写法。之所以说是“几乎”,是因为有些地方还是需要用到 Class 写法,但是 React 的作者 Dan 说了,“Hooks 将会是 React 的未来” 。那么我们这回就全程使用 Hooks 写法,把日记项目敲一遍。

React 开发环境搭建接入 Ant Design Mobile

本次课程的 React 环境,我们采用官方提供的 create-react-app 来初始化,如果你的 npm 版本大于 5.2 ,那么可以使用以下命令行初始化项目:

npx create-react-app diary
cd diary
npm run start

启动成功的话,默认是启动 3000 端口,打开浏览器输入 http://localhost:3000 会看到如下页面:

清除 diary 项目 src 目录下的一些文件,最后的目录结构如下图所示:

下面我们来引入 Ant Design Mobile ,首先我们需要把它下载到项目来,打开命令行工具再项目根目录输入下列命令:

npm install antd-mobile --save

然后在 diary/src/index.js 引入 and 的样式文件:

// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import 'antd-mobile/dist/antd-mobile.css';

ReactDOM.render(<App />, document.getElementById('root'));

然后在 diary/src/App.js 内引入一个组件测试一下:

// App.js
import React from 'react';
import { Button } from 'antd-mobile';

function App() {
  return (
    <div className="App">
      <Button type='primary'>测试</Button>
    </div>
  );
}

export default App;

然后重启一下项目,打开浏览器启动移动端模式查看效果:

移动端网页在点击的时候,会有 300 毫秒延迟,所以我们需要在 diary/public/index.html 文件内加入一段脚本代码:

// index.html
...
<script src="https://as.alipayobjects.com/g/component/fastclick/1.0.6/fastclick.js"></script>
<script>
  if ('addEventListener' in document) {
    document.addEventListener('DOMContentLoaded', function() {
      FastClick.attach(document.body);
    }, false);
  }
  if(!window.Promise) {
    document.writeln('<script src="https://as.alipayobjects.com/g/component/es6-promise/3.2.2/es6-promise.min.js"'+'>'+'<'+'/'+'script>');
  }
</script>
...

antd 的样式是可以通过按需加载的,如果想学习按需加载的同学,可以移步到官网学习如何引入

通过 vw 适配移动端方案

众所周知,移动端的分辨率千变万化,我们很难去完美的适配到每一种分辨率下页面能完美的展示。做不到完美,起码也要努力的去做到一个大致,通过 vw 去适配移动端的分辨率。它能将页面内的 px 单位转化为 vw vh,来适应手机多变的分辨率问题。不想做适配的同学也可以跳过这一步,继续下面的学习。

首先我们需要将项目隐藏的 webpack 配置放出来,通过如下命令行:

npm run eject

运行完成之后,项目目录结构如下图所示:

多了两个配置项,如图所示。若是运行 npm run eject 无法执行的话,建议先将项目的 .git 文件删除,rm -rf .git ,然后再次运行 npm run eject

然后再安装几个插件,指令如下所示:

npm install postcss-aspect-ratio-mini postcss-px-to-viewport postcss-write-svg postcss-cssnext postcss-viewport-units cssnano cssnano-preset-advanced

安装完成之后,打开 diary/config/webpack.config.js 脚本,去修改 postcss 的 loader 插件。

首先引入上面安装好的包,可以放在第 28 行下面:

// 28 行
const postcssNormalize = require('postcss-normalize');

const postcssAspectRatioMini = require('postcss-aspect-ratio-mini');
const postcssPxToViewport = require('postcss-px-to-viewport');
const postcssWriteSvg = require('postcss-write-svg');
const postcssCssnext = require('postcss-cssnext');
const postcssViewportUnits = require('postcss-viewport-units');
const cssnano = require('cssnano');

const appPackageJson = require(paths.appPackageJson);
////

然后去 100 行开始添加 postcss 的一些配置:

{
  // Options for PostCSS as we reference these options twice
  // Adds vendor prefixing based on your specified browser support in
  // package.json
  loader: require.resolve('postcss-loader'),
    options: {
      // Necessary for external CSS imports to work
      // https://github.com/facebook/create-react-app/issues/2677
      ident: 'postcss',
        plugins: () => [
          require('postcss-flexbugs-fixes'),
          require('postcss-preset-env')({
            autoprefixer: {
              flexbox: 'no-2009',
            },
            stage: 3,
          }),
          // Adds PostCSS Normalize as the reset css with default options,
          // so that it honors browserslist config in package.json
          // which in turn let's users customize the target behavior as per their needs.
          postcssNormalize(),
          postcssAspectRatioMini({}),
          postcssPxToViewport({ 
            viewportWidth: 750, // 针对 iphone6 的设计稿
            viewportHeight: 1334, // 针对 iphone6 的设计稿
            unitPrecision: 3,
            viewportUnit: 'vw',
            selectorBlackList: ['.ignore', '.hairlines', 'am'], // 这里添加 am 是因为引入了 antd-mobile 组件库,否则组件库内的单位都会被改为 vw 单位,样式会乱
            minPixelValue: 1,
            mediaQuery: false
          }),
          postcssWriteSvg({
            utf8: false
          }),
          postcssCssnext({}),
          postcssViewportUnits({}),
          cssnano({
            preset: "advanced", 
            autoprefixer: false, 
            "postcss-zindex": false 
          })
        ],
          sourceMap: isEnvProduction && shouldUseSourceMap,
    },
  },

添加完之后重启项目,通过浏览器查看单位是否变化:

同理,其他的组件库也可以通过这种形式适配移动端项目,不过要注意一下 selectorBlackList 属性需要添加一下相应的组件库名字,避开转化为 vw

日记列表页开发

一顿操作之后,接下来将开发一些页面,不过在开发页面之前,我们需要添加路由机制。通过 react-router-dom 插件控制项目的路由,先来安装它:

npm i react-router-dom -save

然后我们修改一下目录结构,首先在 src 目录下新建 Home 文件夹,在文件夹内新建 index.jsxstyle.css ,内容如下:

// Home/index.jsx
import React from 'react'
import './style.css'

const Home = () => {
  return (
    <div>
      Home
    </div>
  )
}

export default Home

接下来我们编辑路由配置页面,路由的原理其实就是页面通过浏览器地址的变化,动态的加载浏览器地址所对应的组件页面。打个比方,我现在给 / 首页配置一个 Home 组件,那么当浏览器访问 http://localhost:3000 的时候,页面会渲染对应的 Home 组件。那么我们先把 App.js 改为 Router.js 代码如下:

// Router.js
import React from 'react';
import Home from './Home';

import {
  BrowserRouter as Router,
  Switch,
  Route,
  Link
} from "react-router-dom";

const RouterMap = () => {
  return <Router>
    <Switch>
      <Route exact path="/">
        <Home />
      </Route>
    </Switch>
  </Router>
}

export default RouterMap;

稍作解释,Switch 的表现和 JavaScript 中的 switch 差不多,即当匹配到相应的路由时,不再往下匹配。我们会在 src/index.js 脚本内引入这个 RouterMap,具体代码如下所示:

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import RouterMap from './Router';
import 'antd-mobile/dist/antd-mobile.css';

ReactDOM.render(<RouterMap />, document.getElementById('root'));

然后重启项目,查看浏览器表现:

我们在 Home 组件内编写日记项目的首页,首页我们会以一个列表的形式展示,那么我们可以用到 antd 中的 Card 卡片组件,我们看看代码如何实现:

// Home/index.jsx
import React from 'react'
import { Card } from 'antd-mobile'
import './style.css'
const list = [0,1,2,3,4,5,6,7,8,9]

const Home = () => {
  return (
    <div className='diary-list'>
      {
        list.map(item => <Card className='diary-item'>
          <Card.Header
            title="我和小明去捉迷藏"
            thumb="https://gw.alipayobjects.com/zos/rmsportal/MRhHctKOineMbKAZslML.jpg"
            extra={<span>晴天</span>}
          />
          <Card.Body>
            <div>{item}</div>
          </Card.Body>
          <Card.Footer content="2020-01-09" />
        </Card>)
      }
    </div>
  )
}

export default Home
// Home/style.css
.diary-list .diary-item {
  margin-bottom: 20px;
}

.diary-item .am-card-header-content {
  flex: 7 1;
}

可以通过浏览器查询元素如修改组件内部的样式,如通过 .am-card-header-content 修改标题的宽度。组件库的合理使用,有助于工作效率的提升。这个页面虽然简单,但是也算是一个抛砖引玉的作用,大家可以对 atnd 这一套组件库进行细致的研究,在工作中业务需求分析的时候,能做到融会贯通,升职加薪指日可待。

日记详情页开发

src 目录下新建一个 Detail 文件夹,我们来编写详情页面:

// Detail/index.jsx
import React from 'react'
import { NavBar, Icon, List } from 'antd-mobile'

const Detail = () => {
  return (<div className='diary-detail'>
    <NavBar
      mode="light"
      icon={<Icon type="left" />}
      onLeftClick={() => console.log('onLeftClick')}
    >我和小明捉迷藏</NavBar>
    <List renderHeader={() => '2020-01-09 晴天'} className="my-list">
      <List.Item wrap>
        今天我和小明去西湖捉迷藏,
        小明会潜水,躲进了湖底,我在西湖边找了半天都没找到,
        后来我就回家了,不跟他嘻嘻哈哈的了。
      </List.Item>
    </List>
  </div>)
}

export default Detail

在头部使用了 NavBar 导航栏标签,展示标题以及返回按钮。内容选择 List 列表组件,简单的展示日记的内容部分。不要忘记了去 Router.js 路由脚本里加上 Detail 的路由:

const RouterMap = () => {
  return <Router>
    <Switch>
      <Route exact path="/">
        <Home />
      </Route>
      <Route exact path="/detail">
        <Detail />
      </Route>
    </Switch>
  </Router>
}

浏览器输入 http://localhost:3000/detail 查看效果:

我们将首页列表和详情页面联系在一起,实现点击首页列表项,跳转到对应的详情页面,将 id 参数带到路由里,然后在详情页面通过筛选拿到浏览器查询字符串的 id 参数。我们先修改首页的代码:

import React from 'react'
import { Card } from 'antd-mobile'
import { Link } from 'react-router-dom'
import './style.css'
const list = [0,1,2,3,4,5,6,7,8,9]

const Home = () => {
  return (
    <div className='diary-list'>
      {
        list.map(item => <Link to={{ pathname: 'detail', search: `?id=${item}` }}><Card className='diary-item'>
          <Card.Header
            title="我和小明去捉迷藏"
            thumb="https://gw.alipayobjects.com/zos/rmsportal/MRhHctKOineMbKAZslML.jpg"
            extra={<span>晴天</span>}
          />
          <Card.Body>
            <div>{item}</div>
          </Card.Body>
          <Card.Footer content="2020-01-09" />
        </Card></Link>)
      }
    </div>
  )
}

export default Home

引入 Link 标签,将 Card 组件包裹起来,通过 to 属性设置跳转路径和附带在路径上的参数如上述代码所示。接下来我们在 Detail 组件内接受这个参数,我们通过编写工具方法来获取想要的参数,在 src 下新建一个文件夹 utils,在文件夹内新建 index.js 脚本,代码如下所示:

function getQueryString(name) {
  var reg = new RegExp("(^|&)"+ name +"=([^&]*)(&|$)");
  var r = window.location.search.substr(1).match(reg);
  if(r != null) {
      return  unescape(r[2]); 
  } else{
      return null
  };
}

module.exports = {
  getQueryString
}

此方法为获取浏览器查询字符串的方法,接下来打开 Detail 组件,引入 utils 获取 getQueryString 方法,同时我们在详情页里需要点击回退按钮,Hooks 写法 react-router-dom 为我们提供了 useHistory 方法来实现回退,具体代码图下所示:

// Detail/index.jsx
import React from 'react'
import { NavBar, Icon, List } from 'antd-mobile'
import { useHistory } from 'react-router-dom'
import { getQueryString } from '../utils'

const Detail = () => {
  const history = useHistory()
  const id = getQueryString('id')
  return (<div className='diary-detail'>
    <NavBar
      mode="light"
      icon={<Icon type="left" />}
      onLeftClick={() => history.goBack()}
    >我和小明捉迷藏{id}</NavBar>
    <List renderHeader={() => '2020-01-09 晴天'} className="my-list">
      <List.Item wrap>
        今天我和小明去西湖捉迷藏,
        小明会潜水,躲进了湖底,我在西湖边找了半天都没找到,
        后来我就回家了,不跟他嘻嘻哈哈的了。
      </List.Item>
    </List>
  </div>)
}

export default Detail

获取到 id 属性后,将它显示在标题上,我们来看看浏览器的效果:

日记编辑页面开发

和小明玩了十天捉迷藏之后,我觉得十分无聊。我们还是赶紧把编辑页面写了,加点有意思的日记信息。老套路,我们在 src 目录下新建 Edit 文件夹,开始编写我们的日记输入组件:

// Detail/index.jsx
import React, { useState } from 'react'
import { List, InputItem, TextareaItem, DatePicker, ImagePicker } from 'antd-mobile'
import './style.css'

const Edit = () => {
  const [date, setDate] = useState()
  const [files, setFile] = useState([])
  const onChange = (files, type, index) => {
    console.log(files, type, index);
    setFile(files)
  }

  return (<div className='diary-edit'>
    <List renderHeader={() => '编辑日记'}>
      <InputItem
        clear
        placeholder="请输入标题"
      >标题</InputItem>
      <TextareaItem
        rows={6}
        placeholder="请输入日记内容"
      />
      <DatePicker
        mode="date"
        title="请选择日期"
        extra="请选择日期"
        value={date}
        onChange={date => setDate(date)}
      >
        <List.Item arrow="horizontal">日期</List.Item>
      </DatePicker>
      <ImagePicker
        files={files}
        onChange={onChange}
        onImageClick={(index, fs) => console.log(index, fs)}
        selectable={files.length < 1}
        multiple={false}
      />
    </List>
  </div>)
}

export default Edit
// Detail/style.css
.diary-edit {
  height: 100vh;
  background: #fff;
}

上述代码,添加了四块内容,分别是标题、内容、日期、图片。组件之间的搭配纯属自己安排,同学们可以按照自己喜欢的排版布局进行设置,注意编写完之后一定要去路由页面添加路由地址:

// Router.js
import React from 'react';
import Home from './Home';
import Detail from './Detail';
import Edit from './Edit';

import {
  BrowserRouter as Router,
  Switch,
  Route,
  Link
} from "react-router-dom";

const RouterMap = () => {
  return <Router>
    <Switch>
      <Route exact path="/">
        <Home />
      </Route>
      <Route exact path="/detail">
        <Detail />
      </Route>
      <Route exact path="/edit">
        <Edit />
      </Route>
    </Switch>
  </Router>
}

export default RouterMap;

然后去浏览器预览一下界面如何:

接下来又可以记录和小红的快乐故事了呢~~

Egg.js 服务端开发

还记得最开始我们创建的 egg-demo 项目吗?我们就用那个项目进行服务端开发的工作。我们第一件要做的事情就是在本地安装一下 MySQL 数据库,如何安装倾听我细细道来。

本地安装 Mysql 数据库

1、下载安装 MySQL

进入 MySQL 官网 下载 MySQL 数据库社区版

请选择适合自己的版本,笔者是 MacOS 系统,所以选择第一个安装包,注意选择不登录下载

下载完成之后,按照导航提示进行安装,进行到 root 用户配置密码时,一定要记住密码,后面会用到的:

安装完成之后,可以进入系统便好设置这边启动数据库:

图形界面对于新手来说,是非常友好的。对数据库的可视化操作,能提高新手的工作效率,笔者使用的这款 Navicat for MySQL 是一款轻量级的数据库可视化工具,这里不提供下载地址,因为怕被起诉侵权。大家可以去网上自己搜一下下载资源,还是很多的,这点能力大家还是要培养起来。

在启动数据库的情况下,我们打开 Navicat 工具链接本地数据库,如图所示:

保存之后,在左侧列表会有测试数据库项,链接数据库成功后会变成绿色:

我们能看到,我本地数据库的版本号和端口号,这样我们就链接上了本地数据库了,接下来我们开始创建 diary 数据库和创建表:

新建表的时候大家注意,我们先填写表的字段名称,保存之后再填写表的名称。在写字端的时候,大家注意选择字端的字符集,选择 utf8mb4 ,否则不支持中文输入:

这里一定要把 id 字端设置为自增,且作为主键:

然后点击左上角的保存按钮 ,保存这张表。我们在 diary 表内添加一条记录:

到这里,我们的数据库工作差不多结束了,有不明白的同学也可以私信我,我会亲自为你们排忧解难。

接下来我们可以打开 egg-demo 项目,要链接数据库的话,我们需要安装一个 egg-mysql 包,在项目根目录下运行如下命令行:

npm i --save egg-mysql

开启插件:

// config/plugin.js
exports.mysql = {
  enable: true,
  package: 'egg-mysql',
};
// config/config.default.js
exports.mysql = {
    // 单数据库信息配置
    client: {
      // host
      host: 'localhost',
      // 端口号
      port: '3306',
      // 用户名
      user: 'root',
      // 密码
      password: '******',
      // 数据库名
      database: 'diary',
    },
    // 是否加载到 app 上,默认开启
    app: true,
    // 是否加载到 agent 上,默认关闭
    agent: false,
  };

密码需要填写上面让你记住的那个密码

我们去 ``server文件夹新建一个文件diary.js` 添加一个搜索列表的方法:

// server/diary.js
'use strict';

const Service = require('egg').Service;

class DiaryService extends Service {
  async list() {
    const { app } = this;
    try {
      const result = await app.mysql.select('diary');
      return result;
    } catch (error) {
      console.log(error);
      return null;
    }
  }
}
module.exports = DiaryService;

然后在 controller/home.js 里引用添加一个新的获取日记列表的方法:

'use strict';

const Controller = require('egg').Controller;

class HomeController extends Controller {
  async list() {
    const { ctx } = this;
    const result = await ctx.service.diary.list();
    if (result) {
      ctx.body = {
        status: 200,
        data: result,
      };
    } else {
      ctx.body = {
        status: 500,
        errMsg: '获取失败',
      };
    }
  }
}

module.exports = HomeController;

要注意,每次添加新的方法的时候,都需要去路由文件里添加相应的接口:

// router.js
'use strict';

/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/list', controller.home.list);
};

此时重启项目运行如下命令行:

npm run dev

顺利启动之后,去浏览器获取一下这个接口,看是否能请求到数据,成功的获取如下:

这个时候,多少会有点成就感,那么我们就一撮而就,把其他几个接口都写了。

添加日记接口

添加接口,我们需要使用 POST 的请求方式,前面已经说过了 POST 如何获取请求体传入的参数,这里就不赘述了。我们直接来写接口,首先打开 service/diary.js 脚本添加 add 方法:

async add(params) {
  const { app } = this;
  try {
    const result = await app.mysql.insert('diary', params);
    return result;
  } catch (error) {
    console.log(error);
    return null;
  }
}

然后再去 controller/home.js 脚本里添加接口操作:

async add() {
  const { ctx } = this;
  const params = {
    ...ctx.request.body,
  };
  const result = await ctx.service.diary.add(params);
  if (result) {
    ctx.body = {
      status: 200,
      data: result,
    };
  } else {
    ctx.body = {
      status: 500,
      errMsg: '添加失败',
    };
  }
}

然后再去 router.js 路由脚本里,加一个路由配置:

'use strict';

/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/list', controller.home.list);
  router.post('/add', controller.home.add);
};

POST 接口需要通过 Postman 测试:

添加成功之后,就返回该条记录相应的 id 等信息,我们再来看看获取列表是不是会有上面天添加的数据:

这个时候必然是成功的,添加接口就这样完成了。

修改日记接口

首先我们分析一下,修改一篇日记的话,我们要先找到它的 id ,因为 id 是主键,通过 id 我们来更新该条记录的字段。那么我们先去 service/diary.js 添加一个数据库操作的方法:

async update(params) {
  const { app } = this;
  try {
    const result = await app.mysql.update('diary', params);
    return result;
  } catch (error) {
    console.log(error);
    return null;
  }
}

然后打开 contoller/home.js 添加修改方法:

async update() {
  const { ctx } = this;
  const params = {
    ...ctx.request.body,
  };
  const result = await ctx.service.diary.update(params);
  if (result) {
    ctx.body = {
      status: 200,
      data: result,
    };
  } else {
    ctx.body = {
      status: 500,
      errMsg: '编辑失败',
    };
  }
}

最后去 router.js 添加接口配置:

'use strict';

/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/list', controller.home.list);
  router.post('/add', controller.home.add);
  router.post('/update', controller.home.update);
};

去 Postman 修改第二条记录:

成功修改第二条记录。

获取文章详情接口

我们首先需要拿到 id 字段,去查询相对应的 id 的记录内容,还是去 service/diary.js 添加接口:

async diaryById(id) {
  const { app } = this;
  if (!id) {
    console.log('id不能为空');
    return null;
  }
  try {
    const result = await app.mysql.select('diary', {
      where: { id },
    });
    return result;
  } catch (error) {
    console.log(error);
    return null;
  }
}

controller/home.js

async getDiaryById() {
  const { ctx } = this;
  console.log('ctx.params', ctx.params);
  const result = await ctx.service.diary.diaryById(ctx.params.id);
  if (result) {
    ctx.body = {
      status: 200,
      data: result,
    };
  } else {
    ctx.body = {
      status: 500,
      errMsg: '获取失败',
    };
  }
}

router.js

'use strict';

/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/list', controller.home.list);
  router.post('/add', controller.home.add);
  router.post('/update', controller.home.update);
  router.get('/detail/:id', controller.home.getDiaryById);
};

删除接口

删除接口就比较简单了,找到对应的 id 记录,删除即可:

service/diary.js

async delete(id) {
  const { app } = this;
  try {
    const result = await app.mysql.delete('diary', { id });
    return result;
  } catch (error) {
    console.log(error);
    return null;
  }
}

controller/home.js

async delete() {
  const { ctx } = this;
  const { id } = ctx.request.body;
  const result = await ctx.service.diary.delete(id);
  if (result) {
    ctx.body = {
      status: 200,
      data: result,
    };
  } else {
    ctx.body = {
      status: 500,
      errMsg: '删除失败',
    };
  }
}

router.js

'use strict';

/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/list', controller.home.list);
  router.post('/add', controller.home.add);
  router.post('/update', controller.home.update);
  router.get('/detail/:id', controller.home.getDiaryById);
  router.post('/delete', controller.home.delete);
};

删除之后,只剩下 id 为 2 的记录,那么接口部分基本上都完成了,我们去前端对接相应的接口。

联调接口

前端的老本行,调试接口来了。我们切换到 diary 前端项目,先安装 axios :

npm i axios --save

然后在 utils 文件夹内添加一个脚本 axios.js ,我们来二次封装一下它。之所以要二次封装,是因为我们在统一处理接口返回的时候,可以在一个地方处理,而不用到各个请求返回的地方去修改。

// utils/axios.js
import axios from 'axios'
import { Toast } from 'antd-mobile'

// 根据 process.env.NODE_ENV 环境变量判断开发环境还是生产环境,我们服务端本地启动的端口是 7001
axios.defaults.baseURL = process.env.NODE_ENV == 'development' ? '//localhost:7001' : '' 
// 表示跨域请求时是否需要使用凭证
axios.defaults.withCredentials = false
axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
// post 请求是 json 形式的
axios.defaults.headers.post['Content-Type'] = 'application/json'

axios.interceptors.response.use(res => {
  if (typeof res.data !== 'object') {
    console.error('数据格式响应错误:', res.data)
    Toast.fail('服务端异常!')
    return Promise.reject(res)
  }
  if (res.data.status != 200) {
    if (res.data.message) Toast.error(res.data.message)
    return Promise.reject(res.data)
  }
  return res.data
})

export default axios

完成二次封装之后记得将 axios 抛出来。

接下来就是去首页请求列表接口了,打开 src/Home/index.jsx

// src/Home/index.jsx
import React, { useState, useEffect } from 'react'
import { Card } from 'antd-mobile'
import { Link } from 'react-router-dom'
import axios from '../utils/axios'
import './style.css'

const Home = () => {
  // 通过 useState Hook 函数定义 list 变量
  const [list, setList] = useState([])
  useEffect(() => {
    // 请求 list 接口,返回列表数据
    axios.get('/list').then(({ data }) => {
      setList(data)
    })
  }, [])
  return (
    <div className='diary-list'>
      {
        list.map(item => <Link to={{ pathname: 'detail', search: `?id=${item.id}` }}><Card className='diary-item'>
          <Card.Header
            title={item.title}
            thumb={item.url}
            extra={<span>晴天</span>}
          />
          <Card.Body>
            <div>{item.content}</div>
          </Card.Body>
          <Card.Footer content={item.date} />
        </Card></Link>)
      }
    </div>
  )
}

export default Home
.diary-list .diary-item {
  margin-bottom: 20px;
}

.diary-item .am-card-header-content {
  flex: 7 1;
}

.diary-item .am-card-header-content img {
  width: 30px;
}

打开浏览器,输入 http://localhost:3000 显示如下图所示:

详情页编写

接下来我们来到详情页的编写,打开 src/Detail/index.jsx

import React, { useState, useEffect } from 'react'
import { NavBar, Icon, List } from 'antd-mobile'
import { useHistory } from 'react-router-dom'
import { getQueryString } from '../utils'
import axios from '../utils/axios'

const Detail = () => {
  const [detail, setDetail] = useState({})
  const history = useHistory()
  const id = getQueryString('id')

  useEffect(() => {
    axios.get(`/detail/${id}`).then(({ data }) => {
      if (data.length) {
        setDetail(data[0])
      } 
    })
  }, [])

  return (<div className='diary-detail'>
    <NavBar
      mode="light"
      icon={<Icon type="left" />}
      onLeftClick={() => history.goBack()}
    >{detail.title || ''}</NavBar>
    <List renderHeader={() => `${detail.date} 晴天`} className="my-list">
      <List.Item wrap>
        {detail.content}
      </List.Item>
    </List>
  </div>)
}

export default Detail

编辑页面

添加文章页面,我们打开 src/Edit/index.jsx

import React, { useState } from 'react'
import { List, InputItem, TextareaItem, DatePicker, ImagePicker, Button, Toast } from 'antd-mobile'
import moment from 'moment'
import axios from '../utils/axios'
import './style.css'

const Edit = () => {
  const [title, setTitle] = useState('') // 标题
  const [content, setContent] = useState('') // 内容
  const [date, setDate] = useState('') // 日期
  const [files, setFile] = useState([]) // 图片文件
  const onChange = (files, type, index) => {
    console.log(files, type, index);
    setFile(files)
  }

  const publish = () => {
    if (!title || !content || !date) {
      Toast.fail('请填写必要参数')
      return
    }
    const params = {
      title,
      content,
      date: moment(date).format('YYYY-MM-DD'),
      url: files.length ? files[0].url : ''
    }
    axios.post('/add', params).then(res => {
      Toast.success('添加成功')
    })
  }

  return (<div className='diary-edit'>
    <List renderHeader={() => '编辑日记'}>
      <InputItem
        clear
        placeholder="请输入标题"
        onChange={(value) => setTitle(value)}
      >标题</InputItem>
      <TextareaItem
        rows={6}
        placeholder="请输入日记内容"
        onChange={(value) => setContent(value)}
      />
      <DatePicker
        mode="date"
        title="请选择日期"
        extra="请选择日期"
        value={date}
        onChange={date => setDate(date)}
      >
        <List.Item arrow="horizontal">日期</List.Item>
      </DatePicker>
      <ImagePicker
        files={files}
        onChange={onChange}
        onImageClick={(index, fs) => console.log(index, fs)}
        selectable={files.length < 1}
        multiple={false}
      />
      <Button type='primary' onClick={() => publish()}>发布</Button>
    </List>
  </div>)
}

export default Edit

注意,因为我没买 cdn 服务,所以没有资源上传接口,故这里的图片我们就采用 base64 存储。

添加成功之后,浏览列表页面。

删除谋篇文章

我们需要在详情页加个按钮,因为我们没有后台管理系统,按理说这个删除按钮需要放在后台管理页面,但是为了方便我就都写在一个项目里了,因为日记都是给自己看的,这就是为什么我说写的是日记项目而不是博客项目的原因,其实名字一变,这就是一个博客项目。

我们将删除按钮放在详情页看,打开 src/Detail/index.jsx ,在头部的右边位置加一个删除按钮,代码如下:

import React, { useState, useEffect } from 'react'
import { NavBar, Icon, List } from 'antd-mobile'
import { useHistory } from 'react-router-dom'
import { getQueryString } from '../utils'
import axios from '../utils/axios'

const Detail = () => {
  const [detail, setDetail] = useState({})
  const history = useHistory()
  const id = getQueryString('id')

  useEffect(() => {
    axios.get(`/detail/${id}`).then(({ data }) => {
      if (data.length) {
        setDetail(data[0])
      } 
    })
  }, [])

  const deleteDiary = (id) => {
    axios.post('/delete', { id }).then(({ data }) => {
      // 删除成功之后,回到首页
      history.push('/')
    })
  }

  return (<div className='diary-detail'>
    <NavBar
      mode="light"
      icon={<Icon type="left" />}
      onLeftClick={() => history.goBack()}
      rightContent={[
        <Icon onClick={() => deleteDiary(detail.id)} key="0" type="cross-circle-o" />
      ]}
    >{detail.title || ''}</NavBar>
    <List renderHeader={() => `${detail.date} 晴天`} className="my-list">
      <List.Item wrap>
        {detail.content}
      </List.Item>
    </List>
  </div>)
}

export default Detail

修改文章

修改文章,只需拿到文章的 id ,然后将修改的参数一并传给修改接口便可,我们先给详情页加一个修改按钮,打开 src/Detail/index.jsx ,再加一段代码

<NavBar
      mode="light"
      icon={<Icon type="left" />}
      onLeftClick={() => history.goBack()}
      rightContent={[
        <Icon style={{ marginRight: 10 }} onClick={() => deleteDiary(detail.id)} key="0" type="cross-circle-o" />,
        <img onClick={() => history.push(`/edit?id=${detail.id}`)} style={{ width: 26 }} src="//s.weituibao.com/1578721957732/Edit.png" alt=""/>
      ]}
    >{detail.title || ''}</NavBar>

上述代码加了一个 img 标签,点击之后跳转到编辑页面,顺便把相应的 id 带上。我们可以在编辑页面通过 id 去获取详情,赋值给变量再进行编辑,我们打开 src/Edit/index.jsx 页面:

import React, { useState, useEffect } from 'react'
import { List, InputItem, TextareaItem, DatePicker, ImagePicker, Button, Toast } from 'antd-mobile'
import moment from 'moment'
import axios from '../utils/axios'
import { getQueryString } from '../utils'
import './style.css'

const Edit = () => {
  const [title, setTitle] = useState('')
  const [content, setContent] = useState('')
  const [date, setDate] = useState('')
  const [files, setFile] = useState([])
  const id = getQueryString('id')
  const onChange = (files, type, index) => {
    console.log(files, type, index);
    setFile(files)
  }

  useEffect(() => {
    if (id) {
      axios.get(`/detail/${id}`).then(({ data }) => {
        if (data.length) {
          setTitle(data[0].title)
          setContent(data[0].content)
          setDate(new Date(data[0].date))
          setFile([{ url: data[0].url }])
        } 
      })
    }
  }, [])

  const publish = () => {
    if (!title || !content || !date) {
      Toast.fail('请填写必要参数')
      return
    }
    const params = {
      title,
      content,
      date: moment(date).format('YYYY-MM-DD'),
      url: files.length ? files[0].url : ''
    }
    if (id) {
      params['id'] = id
      axios.post('/update', params).then(res => {
        Toast.success('修改成功')
      })
      return
    }
    axios.post('/add', params).then(res => {
      Toast.success('添加成功')
    })
  }

  return (<div className='diary-edit'>
    <List renderHeader={() => '编辑日记'}>
      <InputItem
        clear
        placeholder="请输入标题"
        value={title}
        onChange={(value) => setTitle(value)}
      >标题</InputItem>
      <TextareaItem
        rows={6}
        placeholder="请输入日记内容"
        value={content}
        onChange={(value) => setContent(value)}
      />
      <DatePicker
        mode="date"
        title="请选择日期"
        extra="请选择日期"
        value={date}
        onChange={date => setDate(date)}
      >
        <List.Item arrow="horizontal">日期</List.Item>
      </DatePicker>
      <ImagePicker
        files={files}
        onChange={onChange}
        onImageClick={(index, fs) => console.log(index, fs)}
        selectable={files.length < 1}
        multiple={false}
      />
      <Button type='primary' onClick={() => publish()}>发布</Button>
    </List>
  </div>)
}

export default Edit

获取到详情之后,展示在输入页面。

整个项目前后端流程都已经跑通了,虽然数据库只有一张表,但是作为程序员,需要有举一反三的能力。当然如果想要把项目做的更复杂些,需要一些数据库设计的基础。

pm2部署线上环境

一般情况下,很多教程在线上部署这一块,非常不用心。我看过很多教程,讲到部署的时候,就是进入云服务器,然后通过 git 拉取代码库地址(通常是 Github 项目地址),然后进入项目根目录,在云服务器本地运行项目。这种做法没有错,也确实可以将项目正常部署到线上环境。但是你要修改项目的时候,是不是又得进入服务器,拉取代码库的最新地址,然后关闭之前的服务,重启当前最新代码的项目。这就让人十分头大,一两次能接受,但是每次都要做这样的繁琐劳动,这很不自动化。

今天我要讲的部署,是可以在我们当前电脑的本地环境运行指令,一件进入服务器自动拉取代码自动部署。在开始讲解之前,我默认大家已经有自己的云服务器了(没有的同学可以去买一个,学生可以买学生套餐,很便宜), 自动化部署的过程还是需要在服务器里安装 git 和 pm2 等全局包,以及在服务器安装好 MySQL,这里有我之前写好的一篇安装 MySQL 的文章,大家可以参考一下。还有一个要注意的是需要将服务器的公钥赋值到 Github 的 SSH and GPG keys 里,如下图所示:

因为在服务器里,拉取代码是需要权限的,需要提供服务器的公钥给 Github ,没有找到公钥的同学可以搜索一下 "SSH 公钥生成",教程还是很多的,这里不做赘述。

我们进入正题,首先部署我们的服务端代码 egg-demo ,先将它上传到 Github 。首先要去 Github 新建一个空项目:

然后将项目上传上去,命令行工具进入 egg-demo 根目录,运行指令如下:

git init
git add .
git commit -m "first"
git remote add origin git@github.com:Nick930826/egg-demo.git
git push -u origin master

由于我的服务器内 7001 端口已经被占用了,所以这个项目我们使用 7002 端口来部署,修改项目中 config/config.default.js 文件,添加如下代码:

config.cluster = {
  listen: {
    path: '',
    port: 7002,
    hostname: '0.0.0.0',
  },
};

改变端口号之后,我们在项目的根目录添加一个脚本,文件名为 ecosystem.config.js ,我们来看看它的配置项:

'use strict';

module.exports = {
  deploy: {
    production: {
      user: 'root', // 用户,一般都是填写 root 权限最高
      host: '47.99.134.126', // 服务器 IP 地址,大家可以看看自己的云服务器 IP 地址
      ref: 'origin/master', // 获取代码库的分支,我这边就拉取主分支代码
      repo: 'git@github.com:Nick930826/egg-demo.git', // 仓库地址
      path: '/workspace/egg-demo', // 项目在服务器的存放地址
      'post-deploy': 'git pull && npm install && npm run start', // 在本地运行 pm2 的时候,在项目根目录需要运行的命令行
      env: {
        NODE_ENV: 'production', // 正式环境
      },
    },
  },
};

然后再将项目上传到 Github 仓库。在我们运行部署之前,我们还需要做一件事情,就是需要链接我们服务器上启动的 MySQL 数据库。还是打开我们的 Navicat for MySQL ,点击左上角的链接按钮,如下图所示:

链接成功之后,我们来新建数据库 diary ,字段和开发环境的数据库一样便可:

好了之后,我们来部署一下服务端项目 egg-demo,我们在本地全局安装好 pm2 ,然后在项目的根怒撸运行如下指令:

pm2 deploy ecosystem.config.js production setup

第一次运行的时候,需要运行上述指令在服务器内初始化项目,初始化成功之后,我们来看看服务器内是否存在该项目:

成功初始化项目之后,我们在项目根目录运行一下如下指令:

pm2 deploy production

上图为部署成功的截图,我们在 Postman 试一试好不好使:

目前没有数据,我们用 add 接口添加一条数据:

后台部署完毕,接下来我们开始部署前台的项目,其实内容大同小异,首先我们在 diary 项目汇总加入脚本文件 ecosystem.config.js :

module.exports = {
  // 基础配置,项目名称,script 启动的路径地址,服务端会通过 server.js 跑一个web服务
  apps: [
    {
      name: 'diary',
      script: 'server.js',
      exec_mode: 'cluster',
      instances: process.argv.includes('beta') ? 1 : 'max',
      min_uptime: '60s', // 应用运行少于时间被认为是异常启动
      max_restarts: 30, // 最大异常重启次数,即小于min_uptime运行时间重启次数;
      autorestart: true, // 默认为true, 发生异常的情况下自动重启
      env: {
        NODE_ENV: 'production'
      },
      env_beta: {
        NODE_ENV: 'production',
        isBeta: 'true'
      },
      env_production: {
        NODE_ENV: 'production'
      }
    }
  ],
  deploy: {
    production: {
      user: 'root',
      host: '47.99.134.126',
      ref: 'origin/master',
      repo: 'git@github.com:Nick930826/diary.git', // 自己的代码仓库地址
      path: '/workspace/diary',
      'post-deploy': 'git reset --hard && git checkout master && git pull && rm -rf dist/static && npm install && npm run build && pm2 startOrReload ecosystem.config.js',
      env: {
        NODE_ENV: 'production'
      }
    }
  }
}

然后我们在项目的根目录创建一个文件 server.js 用来通过 web 服务器来启动项目,在此之前需要安装一个包 pushstate-server ,这个包专门用于启动通过 create-react-app 创建的 React 项目,可以自定义启动端口号,同时将启动脚本指向 build 文件夹:

// server.js
var server = require('pushstate-server');

server.start({
  port: 3005,
  directory: './build'  //你的react项目build以后的目录,我的server.js 跟目录build是同一级的
});

同时我们修改一下项目的接口,在开发环境下我们用的是 http://locahost:7001 而生产环境我们需要换成 http://47.99.134.126:7002 ,进入 axios.js 文件修改一行配置:

axios.defaults.baseURL = process.env.NODE_ENV == 'development' ? '//localhost:7001' : '//47.99.134.126:7002'

将代码提交到仓库,然后运行服务器初始化项目命令:

pm2 deploy ecosystem.config.js production setup

成功之后再次运行:

pm2 deploy production

此时我遇到了一个大问题,部署失败,报错信息是:

Unknown browser query `dead`

网上查阅了一番,把 package.json 里的 browserslist 改成如下:

"browserslist": {
  "production": [
    ">0.2%",
    "not op_mini all"
  ],
  "development": []
},

去掉 not dead ,再次运行指令 pm2 deploy producton ,很遗憾还是报错了,这次报错的信息是:

antd-mobile.css TypeError: Cannot read property 'length' of undefined

好吧,实在是受不了 antd-mobile 了,我们按照 官网 提供的按需加载来修改项目,首先安装几个依赖包:

npm install react-app-rewired customize-cra react-scripts --save

然后修改 package.json

/* package.json */
"scripts": {
-   "start": "react-scripts start",
+   "start": "react-app-rewired start",
-   "build": "react-scripts build",
+   "build": "react-app-rewired build",
-   "test": "react-scripts test --env=jsdom",
+   "test": "react-app-rewired test --env=jsdom",
}

使用 babel-plugin-import , 它是一个用于按需加载组件代码和样式的 babel 插件(原理)。

然后在项目根目录创建一个 config-overrides.js 用于修改默认配置。

const { override, fixBabelImports } = require('customize-cra');

module.exports = override(
  fixBabelImports('import', {
    libraryName: 'antd-mobile',
    style: 'css',
    }),
);

最后我们将项目入口也 src/index.js 里的全局 antd-mobile/dist/antd-mobile.css 删除。

进入服务器 /workspace/ 删除之前创建的 diary 项目,然后回到本地项目,在根目录再次运行:

pm2 deploy ecosystem.config.js production setup
pm2 deploy production

总结

万字长文,看到最后的朋友想必也是热爱学习,希望提高自己的人。全文涉及到的知识点可能会比较粗略,但是还是那句老话,师父领进门,修行靠个人。

大胡子程序员,专注于WEB和移动前端开发

如果文章对你有帮助,可以给作者一点鼓励。这将是作者持续输出的动力.

系统由 Node + React + Ant Desgin 驱动
浙ICP备19047249号-1 邮箱地址:xianyou1993@qq.com