学习笔记-开发脚手架及封装自动化构建工作流

脚手架工具

常用的脚手架工具:

  • React.js 项目 - create-react-app
  • Vue.js 项目 - vue-cli
  • Angular 项目 - angular-cli
  • Yeoman
  • Plop

Yeoman

Yeoman 是一款比较通用的脚手架,可以搭配不同的 generator 生成不同的脚手架。

基础使用

  • 在全局范围安装 yo
    yarn global add yo
  • 安装对应的 generator,这里安装的是 node 对应的 generator
    yarn global add generator-node
  • 生成项目结构
mkdir my-module
cd my-module
yo node
yarn

Sub Generator

有的时候我们并不需要创建完整的项目结构,只是需要在已有项目基础上创建特定文件,比如 readme,eslint等,这些文件都有特定的格式,如果自己手动写很容易写错,可以通过生成器自动生成,这个时候可以使用 Yeoman 提供的 Sub Generator 来实现。

具体就是通过运行 Sub Generator 的命令来实现。我们这里可以用 generator-node 里边所提供的的一个子集的生成器 cli 帮我们生成一个 cli 应用所需要的的文件。

运行 sub generator 的方式就是在原有的 generator 名字后面跟上 :,跟上 sub generator 的名字,在这里就是 node:cli

yo node:cli

将模块作为全局的命令行模块去使用

yarn link

运行my-module --help

这就是 Generator 的子集 Sub Generator 的特性。

并不是每个 Generator 都提供子集的生成器,可以通过 Generator 的官方文档查看它有哪些自己生成器。

常规使用步骤

  1. 明确你的需求
  2. 找到合适的 Generator
  3. 全局范围安装找到的 Generator
  4. 通过 Yo 运行对应的 Generator
  5. 通过命令行交互填写选项
  6. 生成你所需要的项目结构

自定义 Generator

Generator 本质上就是一个NPM模块。

简单实现

  1. 首先创建生成器文件夹,初始化 package.json 文件
mkdir generator-sample
cd generator-sample
yarn init

注意: yarn init 在 git bash 不生效,可以在 cmd 中执行。

image.png
  1. 安装依赖 yeoman-generator@4.0.1
yarn add yeoman-generator@4.0.1
  1. 在根目录下创建文件 generators/app/index.js
image.png
  1. 在 index.js 文件中实现一个文件写入的功能
// 此文件作为 Generator 的核心入口
// 需要导出一个继承自 Yeoman Generator 的类型
// Yeoman Generator 在工作时会自动调用在此类型中定义的一些生命周期方法
// 我们在这些方法中可以通过调用父类提供的一些工具方法实现一些功能,比如文件写入

const Generator = require('yeoman-generator')

module.exports = class extends Generator {
  write() {
    // Yeoman 在生成文件时调用此方法
    // 这里给项目中写入一个文件
    this.fs.write(
      this.destinationPath('tmp.txt'),
      Math.random().toString()
    )
  }
}
  1. 使用 yarn link 将这个模块链接到全局范围,使它成为一个全局模块包。
  2. 创建一个新的项目 my-proj,在 my-proj 中运行 yo sample 就会在 my-proj 项目下生成一个 tmp.txt 文件。

根据模板创建文件

在 app 文件夹下创建 templates 目录,将要生成的文件都放入 templates 目录作为模板,模板中是完全遵循 EJS 语法。

image.png

这个时候我们写入文件就不需要借助 fs 的 write 方法了,而是借助 fs 的一个专门使用模板引擎的方法 copyTpl。

image.png
// 模板文件路径
const tmpl = this.templatePath('foo.txt')
// 输出目标路径
const output = this.destinationPath('foo.txt')
// 模板数据上下文
const context = { title: 'Hello wl~', success: false }

this.fs.copyTpl(tmpl, output, context)

然后进入命令行,在 my-proj 项目下运行 yo sample,会在项目下生成文件 foo.txt。

image.png

相对于手动创建每一个文件,模板的方式大大提高了效率。

接受用户输入数据

对于项目的动态数据,例如标题,名称,这样的数据一般通过命令行交互的方式询问我们的使用者从而得到。 在 Generator 中想要实现命令行交互可以用 Generator 中的 prompting 方法。

image.png
image.png
// 此文件作为 Generator 的核心入口
// 需要导出一个继承自 Yeoman Generator 的类型
// Yeoman Generator 在工作时会自动调用在此类型中定义的一些生命周期方法
// 我们在这些方法中可以通过调用父类提供的一些工具方法实现一些功能,比如文件写入

const Generator = require('yeoman-generator')

module.exports = class extends Generator {
  prompting() {
    // Yeoman 在询问用户环节会自动调用此方法
    // 在此方法中可以调用父类的 prompt() 方法发出对用户的命令行询问
    // this.prompt 方法接收一个数组作为参数,数组的每一项都是一个问题对象
    // 这个问题对象中 type 表示用什么方式接受信息, name 为得到结果的键
    // message 是给用户的提示,也就是问题,default: this.appname 拿到的是当前生成项目的文件夹的名字
    // answers 就是拿到的结果,键就是数组中每一项设置的 name,值就是用户的输入。
    // 将结果挂载到 this.answers 上,以便后续使用。
    return this.prompt([
      {
        type: 'input',
        name: 'projectName',
        message: 'Your project name',
        default: this.appname // appname 为项目生成目录名称
      }
    ])
      .then(answers => {
        // answers => { projectName: 'user input value'}
        this.answers = answers
      })
  }
  write() {
    // Yeoman 自动在生成文件时调用此方法
    // 这里给项目中写入一个文件
    // this.fs.write(
    //   this.destinationPath('tmp.txt'),
    //   Math.random().toString()
    // )


    // 通过模板方式写入文件到目标目录
    // 模板文件路径
    const tmpl = this.templatePath('bar.html')
    // 输出目标路径
    const output = this.destinationPath('bar.html')
    // 模板数据上下文
    const context = this.answers
    this.fs.copyTpl(tmpl, output, context)
  }
}

然后在 my-proj 项目下执行 yo sample,就创建了 bar.html 文件,并且拿到了项目名称。

image.png
image.png

Vue Generator 案例

mkdir generator-vue-wl
cd generator-vue-wl
yarn init
yarn add yeoman-generator@4.0.1

在项目中添加文件 generators/app/index.js

在项目中添加文件夹 templates,将准备好的 vue 的模板放入这个文件夹中,将模板中名字的不用使用<%= name %>替代,eg:

image.png

在 index.js 文件中写入代码:

const Generator = require('yeoman-generator')

module.exports = class extends Generator {
  prompting() {
    return this.prompt([
      {
        type: 'input',
        name: 'name',
        message: 'Your project name',
        default: this.appname
      }
    ])
      .then(answers => {
        this.answers = answers
      })
  }
  writing() {
    // 把每一个文件都通过模板转换到目标路径

    const templates = [
      '.browserslistrc',
      '.editorconfig',
      '.env.development',
      '.env.production',
      '.eslintrc.js',
      '.gitignore',
      'babel.config.js',
      'package.json',
      'postcss.config.js',
      'README.md',
      'public/favicon.ico',
      'public/index.html',
      'src/App.vue',
      'src/main.js',
      'src/router.js',
      'src/assets/logo.png',
      'src/components/HelloWorld.vue',
      'src/store/actions.js',
      'src/store/getters.js',
      'src/store/index.js',
      'src/store/mutations.js',
      'src/store/state.js',
      'src/utils/request.js',
      'src/views/About.vue',
      'src/views/Home.vue'
    ]

    // 通过遍历所有的文件路径,为每个模板生成对应文件
    templates.forEach(item => {
      // item => 每个文件路径
      this.fs.copyTpl(
        this.templatePath(item),
        this.destinationPath(item),
        this.answer
      )
    })
  }
}

在命令行执行 yarn link。然后定位到全新的目录,创建新项目。

mkdir vue-demo
cd vue-demo
yo vue-wl

发布 Generator

发布 Generator 就是发布一个公开的 npm。

  1. 将自己的 Generator 项目提交到远程仓库
  2. 在项目下使用 npm publishyarn publish

Plop

除了 Yeoman 这样大型的脚手架工具外,还有一些小型的脚手架工具也很好用,比如 Plop。它是用于在项目中创建特定类型文件的小工具,类似于 Yeoman 中的 Sub Generator。不过它一般不会独立去使用,都是集成到项目中,用来自动化的创建同类型的项目文件。

步骤:

  • 将 plop 模块作为项目开发依赖安装
  • 在项目根目录下创建一个 plopfile.js 文件
  • 在 plopfile.js 文件中定义脚手架任务
  • 编写用于生成特定类型文件的模板
  • 通过 Plop 提供的 CLI 运行脚手架任务

demo:https://gitee.com/sun_wl/plop-demo.git

通过 nodejs 开发一个小型脚手架

脚手架工具就是一个 node cli 应用。

mkdir node-scaffold
cd node-scaffold
yarn init

在 package.json 中添加一个 bin 字段,用于指定 cli 应用的入口文件,然后创建这个文件。

image.png

自动化构建

常见自动化构建工具介绍

  • Grunt 最早的前端构建系统,插件生态非常完善。但是工作过程基于临时文件,所以构建速度相对较慢。例如使用它区完成项目中 sass 文件的构建,先对文件进行编译,再去自动添加一些私有属性的前缀,最后压缩代码。每一步都会有磁盘操作,sass 文件编译完成,会将结果写入一个临时文件,然后下一个插件在读取临时文件进行下一步,处理的环节越多,读取磁盘次数越多,大型项目就会特别慢。
  • Gulp 很好的解决了Grunt 构建速度慢的问题,它处理文件都是在内存中完成的,相对于磁盘读写快了很多,而且它默认支持同时执行多个任务,使用方式也简单易懂,生态系统也很多,是目前市面上最流行的前端构建系统。
  • FIS 百度开源的构建系统。相对于前两个微内核的特点,FIS更像捆绑套餐,把项目中典型的需求都集成在内部了。

webpack 是一个模块打包工具。

Grunt

首先在项目中安装 grunt 依赖,在根目录创建 gruntfile.js 文件:

// Grunt 的入口文件
// 用于定义一些需要 Grunt 自动执行的任务
// 需要导出一个函数
// 此函数接受一个 grunt 的形参,内部提供一些创建任务时可以用到的 API

module.exports = grunt => {
  // registerTask: 用于注册任务
  // 注册一个 foo 任务
  // 使用 yarn grunt foo 执行
  grunt.registerTask('foo', () => {
    console.log('hello grunt~')
  })

  // 第二个参数如果是字符串,就是这个任务的描述
  // 可以通过 yarn grunt --help 看到描述信息
  grunt.registerTask('bar', '任务描述', () => {
    console.log('other task~')
  })

  // 如果在构建任务的逻辑代码中发生错误,例如文件找不见,可以将任务标记为失败的任务
  // 实现方式是在函数中 return false
  grunt.registerTask('bad', () => {
    console.log('bad working')
    return false
  })

  // 执行 yarn grunt 自动调用 default
  // grunt.registerTask('default', () => {
  //   console.log('default task')
  // })

  // default 任务的第二个参数可以传入一个数组作为映射
  // 自动执行数组中的任务
  // grunt.registerTask('default', ['foo', 'bar'])

  // 如果失败的任务在任务列表中,会导致后续的任务不会再执行
  // 如果想要失败任务后的任务仍然能够执行,可以执行 yarn grunt --force
  grunt.registerTask('default', ['foo', 'bad', 'bar'])

  // grunt 默认支持同步模式,所以这里的 log 不会被打印
  // grunt.registerTask('async-task', () => {
  //   setTimeout(() => {
  //     console.log('async task working')
  //   }, 1000)
  // })

  // 想要执行异步任务,需要使用 this.async() 得到一个回调函数
  // 在异步操作完成后调用这个回调函数,标识一下这个任务已经完成
  // 要使用 this 就不能使用箭头函数了
  grunt.registerTask('async-task', function () {
    const done = this.async()
    setTimeout(() => {
      console.log('async task working')
      done()
    }, 1000)
  })

  // 异步任务无法通过 return false 标记失败,可以通过给回调函数传递一个 false
  grunt.registerTask('bad-async', function () {
    const done = this.async()
    setTimeout(() => {
      console.log('bad-async')
      done(false)
    }, 1000)
  })





  // initConfig:用于添加配置选项的 API
  // 例如grunt需要对文件进行压缩时,可以通过这个配置需要压缩的文件路径
  // 接受一个参数为对象,对象的键和任务的名称保持一致,值可以是任意类型的数据
  // grunt.config() 可以获取配置的值
  // 值也可以是个对象,在使用 grunt.config() 获取时使用 . 的形式
  grunt.initConfig({
    wl: 'bar'
    // wl:{
    //   bar:123
    // }
  })
  grunt.registerTask('wl', () => {
    console.log(grunt.config('wl'))  // bar
    // console.log(grunt.config('wl.bar')) // 123
  })



  // 多目标任务
  // 使用 grunt.registerMultiTask 注册多目标任务
  // 多目标任务必须通过 initConfig 来配置这个任务和它的多目标
  // 执行 yarn grunt build 会执行 build 中配置的多个任务
  // 要运行指定目标可以通过 yarn grunt build:js 来执行
  // 在任务的函数中可以通过 this.target 拿到任务的目标,通过 this.data 拿到数据
  // 在 build 中指定的属性的每一个键都会成为一个目标,除了 options
  // options 是任务的配置选项,可以在任务执行的函数中通过 this.options() 拿到
  // 在子目标中也可以配置 options 选项,会覆盖对象中的 options
  grunt.initConfig({
    build: {
      options: {
        foo: 'bar'
      },
      css: {
        options: {
          foo: 'baz'
        }
      },
      // css: 1,
      js: 2
    }
  })

  // registerMultiTask: 多目标任务
  grunt.registerMultiTask('build', function () {
    console.log('options', this.options())
    console.log(`target:${this.target}, data:${this.data}`)
  })







  // 插件的使用
  // eg: grunt-contrib-clean   用来清除临时文件
  // 通过 grunt.loadNPmTasks() 方法加载插件中提供的任务
  // grunt 的插件的命名方式都是 `grunt-contrib-${taskName}`
  // 直接运行 yarn grunt clean 发现报错提示 No "clean" targets found.
  // 说明 clean 任务是一个多目标任务,需要配置 initConfig
  // grunt.loadNpmTasks('grunt-contrib-clean')

  // 给 clean 任务配置一个 temp 任务,值为需要清除的文件目录
  // 可以先创建一个这样的文件用来测试
  // 执行 yarn grunt clean 后,会发现这个文件被清除了
  // temp 文件路径可以使用通配符
  grunt.initConfig({
    clean: {
      // temp: 'temp/app.js'
      // temp: 'temp/*.txt'
      temp: 'temp/**'   // temp/** 表示 temp 下的所有文件
    }
  })
  grunt.loadNpmTasks('grunt-contrib-clean')





  // 常用插件
  // 随着引入模块越多,loadNpmTasks 的使用也会越多
  // 有一个依赖,可以减少 loadNpmTasks 的使用:load-grunt-tasks
  const loadGruntTasks = require('load-grunt-tasks')
  // yarn add grunt-sass sass --dev
  // yarn add grunt-babel @babel/core @babel/preset-env --dev
  // yarn add grunt-contrib-watch --dev
  const sass = require('sass')
  grunt.initConfig({
    sass: {
      options: {
        sourceMap: true,
        // 用来指定使用哪个模块来进行 sass 的编译
        implementation: sass
      },
      main: {
        files: {
          // 输出文件路径: 输入文件源路径
          'dist/css/main.css': 'src/sass/main.scss'
        }
      }
    },
    babel: {
      options: {
        sourceMap: true,
        presets: ['@babel/preset-env']  // 将最新的 ECMAScript 加载进来
      },
      main: {
        files: {
          'dist/js/app.js': 'src/js/app.js'
        }
      }
    },
    watch: {
      js: {
        files: ['src/js/*.js'],
        tasks: ['babel']
      },
      css: {
        files: ['src/scss/*.scss'],
        tasks: ['sass']
      }
    }
  })
  // grunt.loadNpmTasks('grunt-sass')
  // 自动加载所有的 grunt 插件中的任务
  loadGruntTasks(grunt)

  // watch 是监听文件变化才会执行 babel 和 sass,第一次变异并不会执行
  // 所以需要给任务做个映射,在启动的时候先执行一次
  grunt.registerTask('default', ['babel', 'sass'])




}

Gulp

基本使用

安装依赖: yarn add gulp --dev

在根目录创建 gulpfile.js 文件:

// gulp 的入口文件

// 基本使用
// 因为文件运行在 nodejs 环境中,可以使用 commonJS 的规范
// 定义构建任务的方式就是通过导出函数成员的方式
// 在最新的 gulp 中取消了同步代码模式,每个任务都必须是异步任务
// 任务执行完成需要通过调用回调函数来标记完成
// 函数的形参就是一个函数,调用这个形参的函数就是标识任务结束
// 执行 yarn gulp foo
exports.foo = done => {
  console.log('foo')
  done() // 标识任务完成
}

// 默认任务  yarn gulp
exports.default = done => {
  console.log('default')
  done()
}


// 以下是在 gulp@4.0 以前注册 gulp 任务的方法
// 执行 yarn gulp bar 可以看到结果
// gulp@4.0 以后的版本保留了之前的使用方式,但是不推荐使用了
const gulp = require('gulp')
gulp.task('bar', done => {
  console.log('bar')
  done()
})





// 组合任务:并行任务和串行任务
const { series, parallel } = require('gulp')

const task1 = done => {
  setTimeout(() => {
    console.log('task1')
    done()
  }, 1000)
}
const task2 = done => {
  setTimeout(() => {
    console.log('task2')
    done()
  }, 1000)
}
const task3 = done => {
  setTimeout(() => {
    console.log('task3')
    done()
  }, 1000)
}
// series: 串行任务
// 执行 yarn gulp foo 会发现三个任务按照顺序依次执行
exports.foo = series(task1, task2, task3)

// parallel: 并行任务
// 执行 yarn gulp bar 会发现三个任务同时启动
exports.bar = parallel(task1, task2, task3)







// 异步任务  通知外部任务完成的方式:
// 通过回调的方式解决
// 错误优先,如果有多个任务,其中一个错误,后边的就不会执行了
exports.callback = done => {
  console.log('callback task')
  done()
}

exports.callback_error = done => {
  console.log('callback task')
  done(new Error('task failed'))
}

// Promise 的方式,避免了回调地狱
exports.promise = () => {
  console.log('promise task')
  return Promise.resolve()
}

exports.promise_error = () => {
  console.log('promise')
  return Promise.reject(new Error('task failed'))
}

const timeout = time => {
  return new Promise(resolve => {
    setTimeout(resolve, time)
  })
}
// async await 的方式
exports.async = async () => {
  await timeout(1000)
  console.log('async task')
}

// stream 的方式最为常见,任务函数中返回一个 stream 对象
const fs = require('fs')
exports.stream = () => {
  const readStream = fs.createReadStream('package.json') // 读取文件的文件流
  const writeStream = fs.createWriteStream('temp.txt')  // 写入文件的文件流
  readStream.pipe(writeStream)  // 将 readStream 导到 writeStream 中
  return readStream // 返回 readStream 的文件流,相当于在 readStream 的 end 事件中执行结束任务
}

// exports.stream = done => {
//   const readStream = fs.createReadStream('package.json')
//   const writeStream = fs.createWriteStream('temp.txt')
//   readStream.pipe(writeStream)
//   readStream.on('end', () => {
//     done()
//   })
// }

通过 node 底层API 实现构建过程工作原理

const fs = require('fs')
const { Transform } = require('stream')

exports.default = () => {
  // 文件读取流
  const read = fs.createReadStream('normalize.css')
  // 文件写入流
  const write = fs.createWriteStream('normalize.min.css')
  // 文件转换流
  const transform = new Transform({
    transform: (chunk, encoding, callback) => {
      // 转换流的核心转换过程
      // chunk => 读取流中读取到的内容
      const input = chunk.toString()
      const output = input.replace(/\s+/g, '').replace('/\/\*.+?\*\//g', '')
      callback(null, output)  // 将 output 通过回调函数返回出去,第一个参数为错误参数,没有错误传 null
    }
  })
  // 把读取出来的文件流导入写入文件流
  read
    .pipe(transform)  // 转换
    .pipe(write)      // 写入

  return read

}

Gulp 文件操作 API

Gulp 也提供了文件读取和写入的 API,相比于 node 底层的 API 更容易理解和使用。文件转换是通过插件来完成的。

// yarn add gulp-clean-css --dev
// yarn add gulp-rename --dev
const { src, dest } = require('gulp')
const cleanCss = require('gulp-clean-css')
const rename = require('gulp-rename')

// 通过 src 创建文件读取流
// 通过 pipe 导出到 dest 创建的写入流,写入流只需要指定目录
exports.default = () => {
  return src('src/*.css')
    .pipe(cleanCss())    // 先经过转换
    .pipe(rename({ extname: '.min.css' }))  // 重命名
    .pipe(dest('dist'))
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容