node + koa + ts 构建应用

适合新手或前端学习 node.js 作后台的起步教程javascript

内容:

  • 项目构建和配置
  • 先后端请求 header 基本配置说明
  • 接口编写和传参处理
  • 上传图片
  • 连接数据库和接口操做
  • 登陆注册用户模块
  • session 的使用(这里我用的是本身写的一个模块)
  • 增删改查功能
  • 项目构建推送到线上

这里我使用 typescript 去编写的理由是由于很是好用的类型提示和代码追踪,因此在纯 javascript 编程的项目中,typescript 是最好维护和阅读的,这里建议使用 vs code 这个代码编辑器。第一次写后台应用,因此编程思路可能跟纯后台的编程思惟有所不一样,不过我习惯标准的 jsdoc 注释规范去编码,因此应该是不存在代码阅读难度的。html

代码地址:node-koa前端

先来看下目录结构java

项目构建和配置

1. cd project 并建立 src 目录
mkdir src
复制代码
2. 初始化 package.json,以后的全部配置和命令都会写在里面
npm init
复制代码
3. 安装 koa 和对应的路由 koa-router
npm install koa koa-router 
复制代码
4. 安装 TypeScript 对应的类型检测提示
npm install --save-dev @types/koa @types/koa-router 
复制代码
5. 而后就是 TypeScript 热更新编译
npm install --save-dev typescript ts-node nodemon
复制代码
这里会有个坑(这里使用的是window环境下)就是 ts-nodenodemon 这两个须要全局安装才能执行热更新的命令
npm install -g -force ts-node nodemon
复制代码
6. 再配置一下 package.json 设置
"scripts": {
    "start": "tsc && node dist/index.js",
    "watch-update": "nodemon --watch 'src/**/*' -e ts,tsx --exec 'ts-node' ./src/index.ts"
},
复制代码
若是执行不了 npm watch-update 那就执行 nodemon --watch 'src/**/*' -e ts,tsx --exec 'ts-node' ./src/index.ts
不肯定是否 window 环境下的问题仍是 npm 的问题,项目首次建立并执行的时候,全部依赖均可以本地安装而且 npm watch-update 也能够完美执行

可是再次打开项目的时候就出错了,目前还没找到缘由,不过以上方法能够解决node

7. 最后选装的中间件 koa-body 中间件做为解析POST传参和上传图片用
npm install koa-body
复制代码
8. 配置代码参数

modules/config.ts 项目设置mysql

class ModuleConfig {
    /** 端口号 */
    public readonly port = 1995;
    /** 数据库配置 */
    public readonly db = {
        host: 'localhost',
        user: 'root',
        password: 'root',
        /** 数据库名 */
        database: 'test',
        /** 连接上限次数 */
        connection_limit: 10
    }
    /** 上传图片存放目录 */
    public readonly upload_path = 'public/upload/images/';
    /** 上传图片大小限制 */
    public readonly upload_img_size = 5 * 1024 * 1024;
    // formData.append('img', file)
    /** 前端上传图片时约定的字段 */
    public readonly upload_img_name = 'img';    
    /** 用户临时表 */
    public readonly user_file = 'public/user.json';
    /** token 长度 */
    public readonly token_size = 28;
}
/** 项目配置 */
const config = new ModuleConfig();
export default config;
复制代码

先后端请求 header 基本配置说明

index.ts 文件下git

import * as Koa from 'koa';
import * as koaBody from 'koa-body';
import config from './modules/config';          // 项目配置
import router from './api/main';                // 路由模块,后面有说
import stateInfo from './modules/state';        // 自定义的请求返回格式模块
import session from './modules/session';        // 自定义的 session 模块,后面有说
import './api/apiUser';                         // 用户模块
import './api/apiUpload';                       // 上传文件模块
import './api/apiTest';                         // 基础测试模块
import './api/apiTodo';                         // 用户列表模块

const App = new Koa();

// 先统一设置请求头信息...
App.use(async (ctx, next) => {
    ctx.set({
        'Access-Control-Allow-Origin': '*', // 打开跨域
        'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept Authorization',
    });
    
    // 若是前端设置了 XHR.setRequestHeader('Content-Type', 'application/json')
    // ctx.set 就必须携带 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept Authorization'
    // 若是前端设置了 XHR.setRequestHeader('Authorization', 'xxxx') 一样的就是上面 Authorization 字段
    // 而且这里要转换一下状态码
    if (ctx.request.method === 'OPTIONS') {
        ctx.response.status = 200;
    }
    try {
        await next();
    } catch (err) {
        ctx.response.status = err.statusCode || err.status || 500;
        ctx.response.body = {
            message: err.message
        }
    }
});
// 使用中间件处理 post 传参 和上传图片
App.use(koaBody({
    multipart: true,
    formidable: {
        maxFileSize: config.upload_img_size
    }
}));
// 开始使用路由
App.use(router.routes())
复制代码

完事以后运行项目 (代码热更新)github

nodemon --watch 'src/**/*' -e ts,tsx --exec 'ts-node' ./src/index.ts
复制代码

接口编写和传参处理

先来定义一个路由而后导出来使用,后面可能会有多个模块的接口,因此所有都是基于这个去使用,index.ts 也是 api/main.ts 文件下sql

import * as Router from 'koa-router';       
/** api路由模块 */
const router = new Router();
export default router;
复制代码

api/apiTest.ts 文件下,来写个不用链接数据库的 GETPOST 请求做为测试用,而且接收参数,写好以后在前端请求,前端的代码我就不作说明了,看注释应该懂,声明文件都写好了。写完以后再到前端页面请求一下是否正确跑通了。typescript

import router from './main';
import html from '../modules/template';
import stateInfo from '../modules/state'; // 这个是我写好的状态数据返回到前端的一个统一格式模块,具体看代码,这里不作过多描述。
// '/*' 监听所有
router.get('/', (ctx, next) => {
    // 指定返回类型
    ctx.response.type = 'html';
    ctx.body = html;
    console.log('根目录');
    // 302 重定向到其余网站
    // ctx.status = 302;
    // ctx.redirect('https://www.baidu.com');
})
// get 请求
router.get('/getHome', (ctx, next) => {
    /** 接收参数 */
    const params: object | string = ctx.query || ctx.querystring;
    console.log('get /getHome', params);
    ctx.body = stateInfo.getSuccessData({
        method: 'get',
        port: 1995,
        time: Date.now()
    });
})
// post 请求
router.post('/sendData', (ctx, next) => {
    /** 接收参数 */
    const params: object = ctx.request.body || ctx.params;
    console.log('post /sendData', params);
    const result = {
        data: '请求成功'
    }
    ctx.body = stateInfo.getSuccessData(result, 'post success')
})
复制代码

上传图片

modules/api/apiUpload.ts 文件下,这里我尚未用到七牛或者其余平台的接口,只是简单模拟了一个上传异步的方法代替,固然,有老哥作过的能够告诉我~

import router from './main';
import * as fss from 'fs';
import * as path from 'path';
import config from '../modules/config';
import stateInfo from '../modules/state';
// 上传图片
router.post('/uploadImg', async (ctx, next) => {
    const file = ctx.request.files[config.upload_img_name];
    let fileName = ctx.request.body.name || `img_${Date.now()}`;
    fileName = `${fileName}.${file.name.split('.')[1]}`;
    // 建立可读流
    const render = fs.createReadStream(file.path);
    const filePath = path.join(config.upload_path, fileName); // 这里说明一下,文件是默认保存到配置好的本地目录下,至于上到七牛那边话,可能须要其余操做,目前还没作到那一步
    const fileDir = path.join(config.upload_path);
    // 判断文件所在目录是否存在(这两行代码能够忽略不写)
    if (!fs.existsSync(fileDir)) {
        fs.mkdirSync(fileDir);
    }
    // 建立写入流
    const upStream = fs.createWriteStream(filePath);
    render.pipe(upStream);
    const result = {
        image: '',
        file: ''
    }
    /** 模拟上传到七牛云 */
    function uploadApi() {
        return new Promise(function (resolve, reject) {
            const delay = Math.floor(Math.random() * 5) * 100 + 500;
            setTimeout(() => {
                result.image = `http://${ctx.headers.host}/${config.upload_path}${fileName}`;
                result.file = `${config.upload_path}${fileName}`;
                resolve();
            }, delay);
        });
    }
    await uploadApi();
    ctx.body = stateInfo.getSuccessData(result, '上传成功');
})
复制代码

连接数据库和接口操做

modules/api/mysql.ts 文件下,这里封装了一个数据库增删改查的方法,以后全部的数据库操做都是经过这个方法去完成。

import * as mysql from 'mysql';         
import config from './config';
import { mysqlErrorType } from './interfaces'; // 这个是TS数据类型模块,这里也不作说明,用过TS的应该知道
/** 数据库 */
const pool = mysql.createPool({
    host: config.db.host,
    user: config.db.user,
    password: config.db.password,
    database: config.db.database
});
/** * 数据库增删改查 * @param command 增删改查语句 * @param value 对应的值 */
export default function query(command: string, value?: Array<any>): Promise<any> {
    /** 错误信息 */
    let errorInfo: mysqlErrorType = null;
    return new Promise((resolve, reject) => {
        pool.getConnection((error: any, connection) => {
            if (error) {
                errorInfo = {
                    info: error,
                    message: '数据库链接出错'
                }
                reject(errorInfo);
            } else {
                const callback: mysql.queryCallback = (error: any, results, fields) => {
                    connection.release();
                    if (error) {
                        errorInfo = {
                            info: error,
                            message: '数据库增删改查出错'
                        }
                        reject(errorInfo);
                    } else {
                        resolve({ results, fields });
                    }
                }
                if (value) {
                    pool.query(command, value, callback);
                } else {
                    pool.query(command, callback);
                }
            }
        });
    });
}
复制代码

这里我用到的本地服务是用(upupw)搭建,超简单的操做,数据库表工具是navicat

upupw下载地址 navicat下载地址

我是一个彻底不懂 mysql 的前端,因此这里的操做我是边问个人 PHP 同事边作笔记边操做的,因此就没什么好说的了,可能看这篇文章的你会知道这些。

登陆注册用户模块

user表的格式

api/apiUser.ts 文件下

import router from './main';
import query from '../modules/mysql';
import stateInfo from '../modules/state';
import session from '../modules/session'; // 这个模块是我按照本身的思路去自行写的,等下再说明
import config from '../modules/config';
import { mysqlErrorType, mysqlQueryType, userInfoType } from '../modules/interfaces';

// 注册
router.post('/register', async (ctx) => {
    /** 接收参数 */
    const params: userInfoType = ctx.request.body;
    /** 返回结果 */
    let bodyResult = null;
    /** 帐号是否可用 */
    let validAccount = false;
    // console.log('注册传参', params);
    if (!/^[A-Za-z0-9]+$/.test(params.account)) {
        return ctx.body = stateInfo.getFailData('注册失败!帐号必须为6-12英文或数字组成');
    }
    if (!/^[A-Za-z0-9]+$/.test(params.password)) {
        return ctx.body = stateInfo.getFailData('注册失败!密码必须为6-12英文或数字组成');
    }
    if (!params.name.trim()) {
        params.name = '用户未设置昵称';
    }
    // 先查询是否有重复帐号
    await query(`select account from user where account = '${ params.account }'`).then((res: mysqlQueryType) => {
        // console.log('注册查询', res);
        if (res.results.length > 0) {
            bodyResult = stateInfo.getFailData('该帐号已被注册');
        } else {
            validAccount = true;
        }
    }).catch((error: mysqlErrorType) => {
        // console.log('注册查询错误', error);
        bodyResult = stateInfo.getFailData(error.message);
    })
    // 再写入表格
    if (validAccount) {
        await query('insert into user(account, password, name) values(?,?,?)', [params.account, params.password, params.name]).then((res: mysqlQueryType) => {
            // console.log('注册写入', res);
            bodyResult = stateInfo.getSuccessData(params, '注册成功');
        }).catch((error: mysqlErrorType) => {
            // console.log('注册写入错误', error);
            bodyResult = stateInfo.getFailData(error.message);
        })
    }
    ctx.body = bodyResult;
})

// 登陆
router.post('/login', async (ctx) => {
    /** 接收参数 */
    const params: userInfoType = ctx.request.body;
    /** 返回结果 */
    let bodyResult = null;
    // console.log('登陆', params);
    if (params.account.trim() === '') {
        return ctx.body = stateInfo.getFailData('登陆失败!帐号不能为空');
    }
    if (params.password.trim() === '') {
        return ctx.body = stateInfo.getFailData('登陆失败!密码不能为空');
    }
    // 先查询是否有当前帐号
    await query(`select * from user where account = '${ params.account }'`).then((res: mysqlQueryType) => {
        // console.log('登陆查询', res.results);
        // 再判断帐号是否可用
        if (res.results.length > 0) {
            const data: userInfoType = res.results[0];
            // 最后判断密码是否正确
            if (data.password == params.password) {
                data.token = session.setRecord(data);
                bodyResult = stateInfo.getSuccessData(data ,'登陆成功');
            } else {
                bodyResult = stateInfo.getFailData('密码不正确');
            }
        } else {
            bodyResult = stateInfo.getFailData('该帐号不存在,请先注册');
        }
    }).catch((error: mysqlErrorType) => {
        // console.log('登陆查询错误', error);
        bodyResult = stateInfo.getFailData(error.message);
    })
    ctx.body = bodyResult;
})

// 获取用户信息
router.get('/getUserInfo', async (ctx) => {
    const token: string = ctx.header.authorization;
    /** 接收参数 */
    const params = ctx.request.body;
    /** 返回结果 */
    let bodyResult = null;
    console.log('getUserInfo', params, token);
    if (token.length != config.token_size) {
        return ctx.body = stateInfo.getFailData('token 不正确');
    }
    let state = session.updateRecord(token);
    if (!state.success) {
        return ctx.body = stateInfo.getFailData(state.message);
    }
    await query(`select * from user where account = '${ state.info.account }'`).then((res: mysqlQueryType) => {
        // 判断帐号是否可用
        if (res.results.length > 0) {
            const data: userInfoType = res.results[0];
            bodyResult = stateInfo.getSuccessData(data);
        } else {
            bodyResult = stateInfo.getFailData('该帐号不存在,可能已经从数据库中删除');
        }
    }).catch((error: mysqlErrorType) => {
        bodyResult = stateInfo.getFailData(error.message);
    })
    ctx.body = bodyResult;
})

// 退出登陆
router.get('/logout', ctx => {
    const token: string = ctx.header.authorization;
    /** 接收参数 */
    const params = ctx.request.body;
    console.log('logout', params, token);
    if (token.length != config.token_size) {
        return ctx.body = stateInfo.getFailData('token 不正确');
    }
    const state = session.removeRecord(token);
    if (state) {
        return ctx.body = stateInfo.getSuccessData('退出登陆成功');
    } else {
        return ctx.body = stateInfo.getFailData('token 不存在');
    }
})
复制代码

session 的使用(这里我用的是本身写的一个模块)

实现思路:利用js内存进行读写用户的token信息,只在对内存写的时候(异步写入防止阻塞)把信息以json格式写入到json文件中,而后实例化的时候读取上次纪录的token信息并把过时的剔除掉。

思路实现过程:

  1. public/目录下新建一个user.json的文件做为一张临时的 token 纪录表
  2. 而后定义一个 ModuleSession 的模块,userRecord 这个私有的属实是 {} ,而后以 token 做为key去存储用户信息,这个信息里面带有一个参数 online 意思是在线的时间,以后取值作判断的时候会用到
  3. ModuleSession 的模块中,对外暴露的只有3个方法:
    1. setRecord 首次设置纪录并返回 token 登陆用,而且纪录在 userRecord 里面,再写入到临时表 user.json
    2. updateRecord 这个是每次请求的时候都会先执行的方法,用来判断前端传过来的 token 是否在 userRecord 里面,如在存在再判断 userRecord[token].online 及其余操做,所有经过判断就说明当前 token 没问题而且更新 userRecord[token].online 为当前时间,最后再写入到临时表去。这个过程可能比较复杂,仍是看代码比较好理解点。
    3. removeRecord 这个逻辑就简单了,直接从 userRecord 中删除当前token,退出登陆用
  4. ModuleSession 在实例化时,先从 user.json 临时表里面读取上次写入的信息,而后剔除过期的token,保证数据同步。
  5. 最后就是在代码运行的过程当中,定时去剔除过期的token,减小读写和内存大小的性能,具体仍是看代码吧。

modules/session.ts 文件下

import * as fs from 'fs';
import config from './config';
import { userRecordType, userInfoType, sessionResultType } from '../modules/interfaces';

class ModuleSession {
    constructor() {
        this.init();
    }

    /** 效期(小时) */
    private maxAge = 12;

    /** 更新 & 检测时间间隔(10分钟) */
    private interval = 600000;

    /** 用户 token 纪录 */
    private userRecord: userRecordType = {}; 

    /** * 写入文件 * @param obj 要写入的对象 */
    private write(obj?: userRecordType) {
        const data = obj || this.userRecord;
        // 同步写入(貌似不必)
        // fs.writeFileSync(config.user_file, JSON.stringify(data), { encoding: 'utf8' });
        // 异步写入
        fs.writeFile(config.user_file, JSON.stringify(data), { encoding: 'utf8' }, (err) => {
            if (err) {
                console.log('session 写入失败', err);
            } else {
                console.log('session 写入成功');
            }
        })
    }

    /** 从本地临时表里面初始化用户状态 */
    private init() {
        const userFrom = fs.readFileSync(config.user_file).toString();
        this.userRecord = userFrom ? JSON.parse(userFrom) : {};
        this.checkRecord();
        // console.log('token临时表', userFrom, this.userRecord);
    }

    /** 生成 token */
    private getToken(): string {
        const getCode = (n: number): string => {
            let codes = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz123456789';
            let code = '';
            for (let i = 0; i < n; i++) {
                code += codes.charAt(Math.floor(Math.random() * codes.length));
            }
            if (this.userRecord[code]) {
                return getCode(n);
            }
            return code;
        }
        const code = getCode(config.token_size);
        return code;
    }
    
    /** 定时检测过时的 token 并清理 */
    private checkRecord() {
        const check = () => {
            const now = Date.now();
            let isChange = false;
            for (const key in this.userRecord) {
                if (this.userRecord.hasOwnProperty(key)) {
                    const item = this.userRecord[key];
                    if (now - item.online > this.maxAge * 3600000) {
                        isChange = true;
                        delete this.userRecord[key];
                    }
                }
            }
            if (isChange) {
                this.write();
            }
        }
        // 10分钟检测一次
        setInterval(check, this.interval);
        check();
    }

    /** * 设置纪录并返回 token * @param data 用户信息 */
    public setRecord(data: userInfoType) {
        const token = this.getToken();
        data.online = Date.now();
        this.userRecord[token] = data;
        this.write();
        return token;
    }

    /** * 更新并检测 token * @param token */
    public updateRecord(token: string) {
        let result: sessionResultType = {
            message: '',
            success: false,
            info: null
        }

        if (!this.userRecord.hasOwnProperty(token)) {
            result.message = 'token 已过时或不存在';
            return result;
        } 
        
        const userInfo = this.userRecord[token];

        const now = Date.now();

        if (now - userInfo.online > this.maxAge * 3600000) {
            result.message = 'token 已过时';
            return result;
        }

        result.message = 'token 经过验证';
        result.success = true;
        result.info = userInfo;

        // 更新在线时间并写入临时表
        // 这里优化一下,写入和更新的时间间隔为10分钟,避免频繁写入
        if (now - userInfo.online > this.interval) {
            this.userRecord[token].online = now;
            this.write();
        }
        
        return result;
    }

    /** * 从纪录中删除 token 纪录(退出登陆时用) * @param token */
    public removeRecord(token: string) {
        if (this.userRecord.hasOwnProperty(token)) {
            delete this.userRecord[token];
            this.write();
            return true;
        } else {
            return false;
        }
    }

}

/** session 模块 */
const session = new ModuleSession();

export default session;
复制代码

增删改查功能

由于以后的接口都是依赖 token 的,因此这里我把 token 判断的代码抽到 index.ts 中去了

// 先统一设置请求配置 => 跨域,请求头信息...
App.use(async (ctx, next) => {
    ... // 以前的代码不变
    
    if (ctx.request.method === 'OPTIONS') {
        ctx.response.status = 200;
    } else {
        /** 过滤掉不用 token 也能够请求的接口 */
        const rule = /\/register|\/login|\/uploadImg|\/getData|\/postData/;
        /** 请求路径 */
        const path = ctx.request.path;
        // 这里进行全局的 token 验证判断
        if (!rule.test(path) && path != '/') {
            const token: string = ctx.header.authorization;
            if (token.length != config.token_size) {
                return ctx.body = stateInfo.getFailData(config.token_tip);
            }
            const state = session.updateRecord(token);
            if (!state.success) {
                return ctx.body = stateInfo.getFailData(state.message);
            }
            // 设置 token 信息到上下文中给接口模块里面调用
            ctx['the_state'] = state;
        } 
    }
})
复制代码

先来看下数据库表结构

而后新建一个 apiTodo.ts 做为用户列表的增删改查接口模块

import router from './main';
import query from '../modules/mysql';
import stateInfo from '../modules/state';
import { mysqlQueryType, mysqlErrorType, sessionResultType } from '../modules/interfaces';

// 获取全部列表
router.get('/getList', async (ctx) => {
    const state: sessionResultType = ctx['the_state'];
    /** 返回结果 */
    let bodyResult = null;
    
    // console.log('getList');

    // 这里要开始连表查询
    await query(`select * from user_list where user_id = '${ state.info.id }'`).then((res: mysqlQueryType) => {
        // console.log('/getList 查询', res.results);
        bodyResult = stateInfo.getSuccessData({
            list: res.results.length > 0 ? res.results : [] 
        });
    }).catch((err: mysqlErrorType) => {
        bodyResult = stateInfo.getFailData(err.message);
    })

    ctx.body = bodyResult;
})

// 添加列表
router.post('/addList', async (ctx) => {
    const state: sessionResultType = ctx['the_state'];
    /** 接收参数 */
    const params = ctx.request.body;
    /** 返回结果 */
    let bodyResult = null;

    if (!params.content) {
        return ctx.body = stateInfo.getFailData('添加的列表内容不能为空!');
    }

    // 写入列表
    await query('insert into user_list(list_text, list_time, user_id) values(?,?,?)', [params.content, new Date().toLocaleDateString(), state.info.id]).then((res: mysqlQueryType) => {
        // console.log('写入列表', res.results.insertId);
        bodyResult = stateInfo.getSuccessData({
            id: res.results.insertId
        }, '添加成功');
    }).catch((err: mysqlErrorType) => {
        // console.log('注册写入错误', err);
        bodyResult = stateInfo.getFailData(err.message);
    })
    
    ctx.body = bodyResult;
})

// 修改列表
router.post('/modifyList', async (ctx) => {
    const state: sessionResultType = ctx['the_state'];
    /** 接收参数 */
    const params = ctx.request.body;
    /** 返回结果 */
    let bodyResult = null;

    if (!params.id) {
        return ctx.body = stateInfo.getFailData('列表id不能为空');
    }

    if (!params.content) {
        return ctx.body = stateInfo.getFailData('列表内容不能为空');
    }

    // 修改列表
    await query(`update user_list set list_text='${params.content}', list_time='${new Date().toLocaleDateString()}' where list_id='${params.id}'`).then((res: mysqlQueryType) => {
        console.log('修改列表', res);
        if (res.results.affectedRows > 0) {
            bodyResult = stateInfo.getSuccessData({}, '修改为功');
        } else {
            bodyResult = stateInfo.getFailData('列表id不存在');
        }
    }).catch((err: mysqlErrorType) => {
        // console.log('注册写入错误', err);
        bodyResult = stateInfo.getFailData(err.message);
    })

    ctx.body = bodyResult;
})

// 删除列表
router.post('/deleteList', async (ctx) => {
    const state: sessionResultType = ctx['the_state'];
    /** 接收参数 */
    const params = ctx.request.body;
    /** 返回结果 */
    let bodyResult = null;
    
    // 从数据库中删除
    await query(`delete from user_list where list_id=${params.id} and user_id = ${state.info.id}`).then((res: mysqlQueryType) => {
        console.log('从数据库中删除', res);
        if (res.results.affectedRows > 0) {
            bodyResult = stateInfo.getSuccessData({}, '删除成功');
        } else {
            bodyResult = stateInfo.getFailData('当前列表id不存在或已删除');
        }
    }).catch((err: mysqlErrorType) => {
        console.log('从数据库中删除失败', err);
        bodyResult = stateInfo.getFailData(err.message);
    })

    ctx.body = bodyResult;
})
复制代码

项目构建推送到线上

待更新...

最后说明

项目的全部前端调试都是写好的,由于是后端的分享,因此这里作代码说明太费时间了。前端的网络请求和其余一下基本操做能够看开头个人博客,基本的网页操做所有都有了,须要的老哥能够看下,最后能够的话给个人 GitHub 点个 star 吧~

相关文章
相关标签/搜索