Taro + dva 使用小结(搭建配置过程)

最近写一个微信小程序的项目,由于是协同开发,前期的搭建工作由另一个妹子完成,现在项目阶段一完成了,为了备忘回顾,做一个阶段性小结。
在写小程序之前经过对比最后采用了京东凹凸实验室开发的类react框架Taro,用框架的好处就不多说了,比直接写原生小程序方便太多。数据管理采用的是封装了reduxdva框架,如果没有学过的同学可以去看看文档。先声明篇幅比较长,如果你需要,还请看完,相信一定有帮助,不想看的同学文末放了GitHub地址,自己去下。

附上文档链接:

taro文档:https://nervjs.github.io/taro/docs/README.html
dva文档:https://dvajs.com/guide/

1.基础步骤

// 全局安装taro (cnpm为淘宝镜像)
 cnpm install -g @tarojs/cli
// 创建项目
taro init taro-demo

如下配置(推荐为项目配上ts):

项目创建简单配置

安装与 react-redux API 几乎一致的包 @tarojs/redux
cnpm install --save redux @tarojs/redux @tarojs/redux-h5 redux-thunk redux-logger
安装dva
cnpm install --save dva-core dva-loading
  • dva-core:封装了 redux 和 redux-saga的一个插件
  • dva-loading:管理页面的loading状态

2.整理项目文件

删除
  • 删除./src/page文件夹下的index文件夹
添加
  • ./src文件夹下添加如下文件夹(根据自己实际情况和项目需求进行配置,只罗列一些必要的):
    assets :静态资源,如images、scss、iconfont...
    components :编写共用组件
    config :项目配置文件
    models :dva插件model函数引用或者共用的js
    types :公共typescript类型申明
    utils :封装的插件

3.编写插件(主要且常用的)

1.在./src/config下创建index.ts,添加项目配置信息,例如:
/** 
 * 线上环境
 * 为了方便测试,使用的是聚合数据免费接口
 * 网址:https://www.juhe.cn/
 */
export const ONLINEHOST = 'http://api.juheapi.com'

/** 
 * 测试环境
 */
export const QAHOST = 'http://xxx.cn'

/** 
 * 线上mock
 */
export const MOCKHOST = 'http://xxx/mock'

/** 
 * 是否mock
 */
export const ISMOCK = false

/**
 * 当前的host  ONLINEHOST | QAHOST | MOCKHOST
 */
export const MAINHOST = ONLINEHOST

/**
 * 全局的分享信息 不用每一个都去写
 */
export const SHAREINFO = {
  'title': '分享标题',
  'path': '路径',
  'imageUrl': '图片'
}

2.在./src/utils下创建dva.ts,配置dva,内容如下:
import { create } from 'dva-core';
import { createLogger } from 'redux-logger';
import createLoading from 'dva-loading';

let app
let store
let dispatch
let registered

function createApp(opt) {
  // redux日志
  opt.onAction = [createLogger()]
  app = create(opt)
  app.use(createLoading({}))

  if (!registered) opt.models.forEach(model => app.model(model))
  registered = true
  app.start()

  store = app._store
  app.getStore = () => store
  app.use({
    onError(err) {
      console.log(err)
    },
  })

  dispatch = store.dispatch

  app.dispatch = dispatch
  return app
}

export default {
  createApp,
  getDispatch() {
    return app.dispatch
  }
}

3.在./src/config下创建requestConfig.ts,统一配置请求接口,内容如下:
/** 
 * 请求的公共参数
 */
export const commonParame = {}

/**
 * 请求映射文件
 */
export const requestConfig = {
  loginUrl: '/api/user/wechat-auth', // 微信登录接口
}
5.在./src/utils下创建tips.ts,整合封装微信原生弹窗,内容如下:
import Taro from '@tarojs/taro'
/**
 * 提示与加载工具类
 */
export default class Tips {
  static isLoading = false

  /**
   * 信息提示
   */
  static toast(title: string, onHide?: () => void) {
    Taro.showToast({
      title: title,
      icon: 'none',
      mask: true,
      duration: 1500
    });
    // 隐藏结束回调
    if (onHide) {
      setTimeout(() => {
        onHide();
      }, 500);
    }
  }
  /**
 * 弹出加载提示
 */
  static loading(title = '加载中', force = false) {
    if (this.isLoading && !force) {
      return
    }
    this.isLoading = true
    if (Taro.showLoading) {
      Taro.showLoading({
        title: title,
        mask: true
      })
    } else {
      Taro.showNavigationBarLoading()
    }
  }

  /**
   * 加载完毕
   */
  static loaded() {
    let duration = 0
    if (this.isLoading) {
      this.isLoading = false
      if (Taro.hideLoading) {
        Taro.hideLoading()
      } else {
        Taro.hideNavigationBarLoading()
      }
      duration = 500
    }
    // 隐藏动画大约500ms,避免后面直接toast时的显示bug
    return new Promise(resolve => setTimeout(resolve, duration))
  }

  /**
   * 弹出提示框
   */
  static success(title, duration = 1500) {
    Taro.showToast({
      title: title,
      icon: 'success',
      mask: true,
      duration: duration
    });
    if (duration > 0) {
      return new Promise(resolve => setTimeout(resolve, duration));
    }
  }
}

5.在./src/utils下创建common.ts,共用函数,内容如下:
/** 时间格式的转换 */
export const formatTime = time => {
 `${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}.${pad(time.getMilliseconds(), 3)}`
}

export var globalData: any = {} // 全局公共变量

6.在./src/utils下创建logger.ts,封装log函数,内容如下:
import {
  formatTime
} from './common'

const defaults = {
  level: 'log',
  logger: console,
  logErrors: true,
  colors: {
    title: 'inherit',
    req: '#9E9E9E',
    res: '#4CAF50',
    error: '#F20404',
  }
}

function printBuffer(logEntry, options) {
  const {
    logger,
    colors
  } = options;

  let {
    title,
    started,
    req,
    res
  } = logEntry

  // Message
  const headerCSS = ['color: gray; font-weight: lighter;']
  const styles = s => `color: ${s}; font-weight: bold`

  // render
  logger.group(`%c ${title} @${formatTime(started)}`, ...headerCSS)
  logger.log('%c req', styles(colors.req), req)
  logger.log('%c res', styles(colors.res), res)
  logger.groupEnd()
}

interface LogEntry {
  started?: object // 触发时间
}

function createLogger(options: LogEntry = {}) {
  const loggerOptions = Object.assign({}, defaults, options)
  const logEntry = options
  logEntry.started = new Date()
  printBuffer(logEntry, Object.assign({}, loggerOptions))
}

export {
  defaults,
  createLogger,
}

7.在./src/utils下创建request.ts,封装http请求,内容如下:
import Taro, { Component } from '@tarojs/taro'
import {
  ISMOCK,
  MAINHOST
} from '../config'
import {
  commonParame,
  requestConfig
} from '../config/requestConfig'
import Tips from './tips'

// import { createLogger } from './logger'

declare type Methods = "GET" | "OPTIONS" | "HEAD" | "POST" | "PUT" | "DELETE" | "TRACE" | "CONNECT";
declare type Headers = { [key: string]: string };
declare type Datas = { method: Methods;[key: string]: any; };
interface Options {
  url: string;
  host?: string;
  method?: Methods;
  data?: Datas;
  header?: Headers;
}

export class Request {
  //登陆的promise
  static loginReadyPromise: Promise<any> = Promise.resolve()
  // 正在登陆
  static isLogining: boolean = false
  // 导出的api对象
  static apiLists: { [key: string]: () => any; } = {}
  // token
  static token: string = ''

  // constructor(setting) {

  // }
  /**
   * @static 处理options
   * @param {Options | string} opts
   * @param {Datas} data
   * @returns {Options}
   * @memberof Request
   */
  static conbineOptions(opts, data: Datas, method: Methods): Options {
    typeof opts === 'string' && (opts = { url: opts })
    return {
      data: { ...conmomPrams, ...opts.data, ...data },
      method: opts.method || data.method || method || 'GET',
      url: `${opts.host || MAINHOST}${opts.url}`
    }
  }

  static getToken() {
    !this.token && (this.token = Taro.getStorageSync('token'))
    return this.token
  }

  /**
   * 
   * @static request请求 基于 Taro.request
   * @param {Options} opts 
   */
  static async request(opts: Options) {
    // token不存在
    // if (!this.getToken()) { await this.login() }

    // token存在
    // let options = Object.assign(opts, { header: { 'token': this.getToken() } })

    //  Taro.request 请求
    const res = await Taro.request(opts)

    // 是否mock
    if (ISMOCK) { return res.data }

    // 登陆失效 
    if (res.data.code === 99999) { await this.login(); return this.request(opts) }

    // 请求成功
    // if (res.data && res.data.code === 0 || res.data.succ === 0) { return res.data }
    if (res.data) { return res.data }

    // 请求错误
    const d = { ...res.data, err: (res.data && res.data.msg) || `网络错误~` }
    Tips.toast(d.err);
    throw new Error(d.err)
  }

  /**
   * 
   * @static 登陆
   * @returns  promise 
   * @memberof Request
   */
  static login() {
    if (!this.isLogining) { this.loginReadyPromise = this.onLogining() }
    return this.loginReadyPromise
  }

  /**
   *
   * @static 登陆的具体方法
   * @returns
   * @memberof Request
   */
  static onLogining() {
    this.isLogining = true
    return new Promise(async (resolve, reject) => {
      // 获取code
      const { code } = await Taro.login()

      // 请求登录
      const { data } = await Taro.request({
        url: `${MAINHOST}${requestConfig.loginUrl}`,
        data: { code: code }
      })

      if (data.code !== 0 || !data.data || !data.data.token) {
        reject()
        return
      }

      Taro.setStorageSync('token', data.data.token)
      this.isLogining = false
      resolve()
    })
  }

  /**
   *
   * @static  创建请求函数
   * @param {(Options | string)} opts
   * @returns
   * @memberof Request
   */
  static creatRequests(opts: Options | string): () => {} {
    return async (data = {}, method: Methods = "GET") => {
      const _opts = this.conbineOptions(opts, data, method)
      const res = await this.request(_opts)
      // createLogger({ title: 'request', req: _opts, res: res })
      return res
    }
  }

  /**
   *
   * @static 抛出整个项目的api方法
   * @returns
   * @memberof Request
   */
  static getApiList(requestConfig) {
    if (!Object.keys(requestConfig).length) return {}

    Object.keys(requestConfig).forEach((key) => {
      this.apiLists[key] = this.creatRequests(requestConfig[key])
    })

    return this.apiLists
  }
}

// 导出
const Api = Request.getApiList(requestConfig)
Component.prototype.$api = Api
export default Api as any

Tip:这时候tslint会报这样的错:类型“Component<any, any>”上不存在属性“$api”。,因为我们没有添加声明,我们可以这样解决,在./src目录下创建app-shim.d.ts,内容如下:

/**
 *
 * @static 添加taro等自定义类型
 * @interface Component
 */
import Taro, { Component } from '@tarojs/taro'

// 在Component上定义自定义方法类型
declare module '@tarojs/taro' {
  interface Component {
    $api: any
  }
}

//声明
declare var require: any
declare var dispach: any

这时候应该不报错了。

8.在./src/config下创建taroConfig.ts,封装taro小程序的一些方法,内容如下:
/**
 * 进行taro的处理 
 * 1.方法的改写
 * 2.utils的挂载
 * 
 */
import Taro, { Component } from "@tarojs/taro";
import { SHAREINFO } from '../config/index'

/**
 * navigateTo 超过8次之后 强行进行redirectTo 否则会造成页面卡死
 * 
 */
const nav = Taro.navigateTo
Taro.navigateTo = (data) => {
  if (Taro.getCurrentPages().length > 8) {
    return Taro.redirectTo(data)
  }
  return nav(data)
}


/**
 * Component挂载分享方法
 */
Component.prototype.onShareAppMessage = function () {
  return SHAREINFO
}

4.编写node命令快速创建pagecomponent

先来看一张图,就明白为什么需要编写这样一个命令了

文件示意图

当你每次需要创建一个页面的时候需要不断的创建,这样太麻烦了,而且容易出错,所以写个node命令快速生成如图中index文件夹下的5个文件,一条命令的事情,下面上代码:
首先在根目录下创建scripts文件夹,在该文件夹下添加如下文件:

  1. 添加./scripts/template.js,内容如下:
/**
 * pages页面快速生成脚本 
 * 用法:npm run tep `文件名`
 */

const fs = require('fs');

const dirName = process.argv[2];
const capPirName = dirName.substring(0, 1).toUpperCase() + dirName.substring(1);
if (!dirName) {
    console.log('文件夹名称不能为空!');
    console.log('示例:npm run tep test');
    process.exit(0);
}

//页面模板
const indexTep = `
import Taro, { Component, Config } from '@tarojs/taro'
import { View } from '@tarojs/components'
// import { connect } from '@tarojs/redux'
// import Api from '../../utils/request'
// import Tips from '../../utils/tips'
import { ${capPirName}Props, ${capPirName}State } from './${dirName}.interface'
import './${dirName}.scss'
// import { } from '../../components'

// @connect(({ ${dirName} }) => ({
//     ...${dirName},
// }))

class ${capPirName} extends Component<${capPirName}Props,${capPirName}State > {
  config:Config = {
    navigationBarTitleText: '标题'
  }
  constructor(props: ${capPirName}Props) {
    super(props)
    this.state = {}
  }

  componentDidMount() {
    
  }

  render() {
    return (
      <View className='${dirName}-wrap'>
          
      </View>
    )
  }
}

export default ${capPirName}
`

// scss文件模版
const scssTep = `
${dirName}-wrap {
    width: 100%;
    min-height: 100vh;
}
`

// config 接口地址配置模板
const configTep = `
export default {
  test: '/wechat/perfect-info', //xxx接口
}
`
// 接口请求模板
const serviceTep = `
import Api from '../../utils/request'

export const testApi = data => Api.test(
  data
)
`

//model模板

const modelTep = `
// import Taro from '@tarojs/taro';
import * as ${dirName}Api from './service';

export default {
  namespace: '${dirName}',
  state: {
  },

  effects: {},

  reducers: {}

}
`

const interfaceTep = `
/**
 * ${dirName}.state 参数类型
 *
 * @export
 * @interface ${capPirName}State
 */
export interface ${capPirName}State {}

/**
 * ${dirName}.props 参数类型
 *
 * @export
 * @interface ${capPirName}Props
 */
export interface ${capPirName}Props {}
`

fs.mkdirSync(`./src/pages/${dirName}`); // mkdir $1
process.chdir(`./src/pages/${dirName}`); // cd $1

fs.writeFileSync(`${dirName}.tsx`, indexTep); //tsx
fs.writeFileSync(`${dirName}.scss`, scssTep); // scss
fs.writeFileSync('config.ts', configTep); // config
fs.writeFileSync('service.ts', serviceTep); // service
fs.writeFileSync('model.ts', modelTep); // model
fs.writeFileSync(`${dirName}.interface.ts`, interfaceTep); // interface
process.exit(0);
  1. 添加./scripts/component.js,内容如下:
/**
 * pages页面快速生成脚本 
 * 用法:npm run com `文件名`
 */

const fs = require('fs');

const dirName = process.argv[2];
const capPirName = dirName.substring(0,1).toUpperCase() + dirName.substring(1);
if (!dirName) {
  console.log('文件夹名称不能为空!');
  console.log('示例:npm run com test');
  process.exit(0);
}

//页面模板
const indexTep = `import Taro, { Component } from '@tarojs/taro'
import { View } from '@tarojs/components'
import { ${capPirName}Props, ${capPirName}State } from './${dirName}.interface'
import './${dirName}.scss'

class ${capPirName} extends Component<${capPirName}Props,${capPirName}State > {
  constructor(props: ${capPirName}Props) {
    super(props)
    this.state = {}
  }
  static options = {
    addGlobalClass: true
  }
  static defaultProps:${capPirName}Props = {}

  render() {
    return (
      <View className='fx-${dirName}-wrap'>

      </View>
    )
  }
}

export default ${capPirName}
`

// scss文件模版
const scssTep = `
${dirName}-wrap {
    width: 100%;
 }
`

const interfaceTep = `
/**
 * ${dirName}.state 参数类型
 *
 * @export
 * @interface ${capPirName}State
 */
export interface ${capPirName}State {}

/**
 * ${dirName}.props 参数类型
 *
 * @export
 * @interface ${capPirName}Props
 */
export interface ${capPirName}Props {}
`

fs.mkdirSync(`./src/components/${dirName}`); // mkdir $1
process.chdir(`./src/components/${dirName}`); // cd $1

fs.writeFileSync(`${dirName}.tsx`, indexTep); //tsx
fs.writeFileSync(`${dirName}.scss`, scssTep); // scss
fs.writeFileSync(`${dirName}.interface.ts`, interfaceTep); // interface

Tip:最后也是重点,记得在根目录的package.jsonscripts里加上如下内容:

"scripts": {
  ...
  "tep": "node scripts/template",
  "com": "node scripts/component"
}

5.编写业务代码

上面4个步骤基本已经配置完了,接下去进入正题,可以愉快的撸代码了。
运行我们上面写的快速生成脚本,在命令行里输入:

cnpm run tep index

ok,这时候tslint应该不报找不到index的错了,可以看到我们page文件夹下生成了一个index的文件夹,里面包含config.tsindex.interface.tsindex.scssindex.tsxmodel.tsservice.ts

1.改写./src/app.tsx

首先先下载taro的@tarojs/async-await,在命令行输入如下:

cnpm i --save @tarojs/async-await

下载完了之后,按照如下改写app.tsx

import Taro, { Component, Config } from "@tarojs/taro";
import "@tarojs/async-await";
import { Provider } from "@tarojs/redux";
import "./utils/request";
import Index from "./pages/index";
import dva from './utils/dva'
import models from './models'
import './app.scss'
import { globalData } from "./utils/common";

const dvaApp = dva.createApp({
  initialState: {},
  models: models,
});

const store = dvaApp.getStore();

class App extends Component {
  config: Config = {
    pages: [
      'pages/index/index'
    ],
    window: {
      backgroundTextStyle: 'light',
      navigationBarBackgroundColor: '#fff',
      navigationBarTitleText: 'WeChat',
      navigationBarTextStyle: 'black'
    }
  }

  /**
   *
   *  1.小程序打开的参数 globalData.extraData.xx
   *  2.从二维码进入的参数 globalData.extraData.xx
   *  3.获取小程序的设备信息 globalData.systemInfo
   * @memberof App
   */
  async componentDidMount() {
    // 获取参数
    const referrerInfo = this.$router.params.referrerInfo;
    const query = this.$router.params.query;
    !globalData.extraData && (globalData.extraData = {});
    if (referrerInfo && referrerInfo.extraData) {
      globalData.extraData = referrerInfo.extraData;
    }
    if (query) {
      globalData.extraData = {
        ...globalData.extraData,
        ...query
      };
    }

    // 获取设备信息
    const sys = await Taro.getSystemInfo();
    sys && (globalData.systemInfo = sys);
  }

  componentDidShow() { }

  componentDidHide() { }

  componentDidCatchError() { }

  // 在 App 类中的 render() 函数没有实际作用
  // 请勿修改此函数
  render() {
    return (
      <Provider store={store}>
        <Index />
      </Provider>
    )
  }
}

Taro.render(<App />, document.getElementById('app'))

发现tslint找不到模块“./models”。这样的错,不要急,我们在./models文件夹下创建index.ts这样一个文件夹,内容如下:

import index from '../pages/index/model';// index 页面的model

// 这里记得export的是数组,不是对象
export default [
  index
]

可以发现,tslint已经不报错了。

2.改写./src/pages/index/config.ts
export default {
  getLists: '/japi/toh', // 获取历史上的今天接口
}
3.在./src/config/requestConfig.ts引入上面配置的接口,如下修改:
import index from '../pages/index/config' // index接口
/** 
 * 请求的公共参数
 */
export const commonParame = {}

/**
 * 请求映射文件
 */
export const requestConfig = {
  loginUrl: '/api/user/wechat-auth', // 微信登录接口
  ...index
}
4.改写./src/pages/index/service.ts,如下:
import Api from '../../utils/request'

export const getLists = (data) => {
  return Api.getLists(data)
}
5.改写./src/pages/index/index.interface.ts,如下:

/**
 * index.state 参数类型
 *
 * @export
 * @interface IndexState
 */
export interface IndexState {
  month: number
  day: number
}

/**
 * index.props 参数类型
 *
 * @export
 * @interface IndexProps
 */
export interface IndexProps {
  dispatch?: any,
  data?: Array<DataInterface>
}

export interface DataInterface {
  day: number
  des: string
  lunar: string
  month: number
  pic: string
  title: string
  year: number
  _id: string
}
6.改写./src/pages/index/model.ts,如下:
// import Taro from '@tarojs/taro';
import * as indexApi from './service';

export default {
  namespace: 'index',
  state: {
    data: [],
    key: '72eed010c976e448971655b56fc2324e',
    v: '1.0'
  },

  effects: {
    * getLists({ payload }, { select, call, put }) {
      const { key, v } = yield select(state => state.index)
      const { error, result } = yield call(indexApi.getLists, {
        key,
        v,
        ...payload
      })
      if (!error) {
        yield put({
          type: 'updateState',
          payload: {
            data: result
          }
        })
      }
    }
  },

  reducers: {
    updateState(state, { payload: data }) {
      return { ...state, ...data }
    }
  }

}
7.改写./src/pages/index/index.tsx,如下:

import Taro, { Component, Config } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { connect } from '@tarojs/redux'
// import Api from '../../utils/request'
// import Tips from '../../utils/tips'
import { IndexProps, IndexState } from './index.interface'
import './index.scss'
// import {  } from '../../components'

@connect(({ index }) => ({
  ...index,
}))

class Index extends Component<IndexProps, IndexState> {
  config: Config = {
    navigationBarTitleText: 'Taro + dva demo'
  }
  constructor(props: IndexProps) {
    super(props)
    this.state = {
      month: 0,
      day: 0
    }
  }

  // 获取今日数据
  async getData(month: number, day: number) {
    await this.props.dispatch({
      type: 'index/getLists',
      payload: {
        month: month,
        day: day
      }
    })
  }

  // 获取系统当前时间并请求参数
  getDate() {
    const myDate = new Date()
    const m = myDate.getMonth() + 1
    const d = myDate.getDate()
    this.setState({
      month: m,
      day: d
    })
    this.getData(m, d)
  }

  componentDidMount() {
    this.getDate()
  }

  render() {
    const { month, day } = this.state
    const { data } = this.props
    return (
      <View className='fx-index-wrap'>
        <View className='index-topbar'>
          <Text>{`${month}月${day}日`}</Text>
          <View>历史上的今天都发生了这些大事</View>
        </View>
        <View className='index-list'>
          {
            data && data.map((item, index) => {
              return <View className='index-li' key={index}>
                <View className='index-bg' style={`background-image: url(${item.pic})`}></View>
                <View className='index-info'>
                  <View className='index-title'>{item.title}</View>
                  <View className='index-des'>{item.des}</View>
                </View>
              </View>
            })
          }
        </View>
      </View>
    )
  }
}

export default Index

5.接下来写样式./src/pages/index/index.scss,如下:
.index {
  &-wrap {
    width: 100%;
    min-height: 100vh;
  }

  &-topbar {
    padding: 10rpx 50rpx;
    text-align: center;
    font-weight: bold;

    Text {
      color: rgb(248, 122, 3);
      font-size: 40rpx;
    }

    View {
      color: #333;
      font-size: 30rpx;
    }
  }

  &-list {
    padding: 50rpx;
  }

  &-li {
    box-shadow: 0 4rpx 20rpx rgba($color: #000000, $alpha: 0.1);
    margin-bottom: 50rpx;
    border-radius: 8rpx;
    overflow: hidden;
  }

  &-bg {
    width: 100%;
    height: 300rpx;
    background-repeat: no-repeat;
    background-size: contain;
    background-position: center center;
    background-color: #f5f5f5;
  }

  &-info {
    padding: 15rpx;
  }

  &-title {
    font-size: 30rpx;
    font-weight: bold;
    margin-bottom: 10rpx;
  }

  &-des {
    font-size: 26rpx;
    color: #666;
  }
}

这时候基本结束了,在命令行运行:

cnpm run dev:weapp

如下显示,说明编译成功(tip:以后记得先编译,我是之前写好了的,不然很有可能一堆报错,那时候估计你会绝望的)

编译成功显示图

最后的最后,打开微信开发者工具,选择微信小程序,选择taro-demo文件夹下编译成功的dist,appid就用微信提供给你测试的,名字随便输入一个,点击确定,之前步骤都没问题的话,最后显示的结果如下图:
小程序界面图

最后,恭喜你,配置完了,可以满足基本开发和需求了,如果有什么错误还望指出

-------------------------- END -----------------------

示例GitHub地址: