阅读 3523

Egg + React 实战日记本(万字长文,望收藏点赞)

一、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/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 操作数据库创建日记表

图形界面对于新手来说,是非常友好的。对数据库的可视化操作,能提高新手的工作效率,笔者使用的这款 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
复制代码

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

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

总结

万字长文,看到最后的朋友想必也是热爱学习,希望提高自己的人。全文涉及到的知识点可能会比较粗略,但是还是那句老话,师父领进门,修行靠个人。更多好文可以关注我的 个人博客 还要我的 知乎专栏 。有问题可以添加我的个人博客里的微信群,学习讨论。这篇长文写到我吐血,希望对大家有所帮助。

关注下面的标签,发现更多相似文章
评论