阅读 412
「脚手架cli」技术揭秘

「脚手架cli」技术揭秘

我将之前搭建的一个掘金:vue3+ts企业级开发环境写成了一个脚手架,原本设想用这个脚手架快速搭建公司各个项目的开发环境,正在一点点集成。先来看下效果:

demo.gif

【文章目标】:

  • 介绍一些工具包以及运用场景
  • 脚手架原理(开发思路)
  • 实现一个脚手架

为了更好的理解和更有效率的学习,建议先下载这个项目lu-cli

以下是脚手架中通常会用到的一些工具包,在后面的文章内容中我会给大家讲解一下这些包的主要用途,先让我们来看看有哪些包:

还有更多有趣好玩的包等你挖掘。

硬核知识点集锦(工具包介绍以及运用场景)

其中关于包的细节应用我就不过多描述了,建议大家先npm init初始化一个开发项目,可以先调用一下这些包看看都是用来做什么的,效果是什么样子,便于后面的理解,官网地址都已经在上面给大家列出来了。

1. 创建命令

commander包是一套完成的命令行解决方案,用来创建脚手架命令,如lucli create app

// index.js

#!/usr/bin/env node

const program = require("commander");

program
.version("0.0.1", "-v, --version") // 定义版本
.command("create app") // 命令名称
.description("create an application") // 描述
.action((source,destination)=>{ // 执行回调
  console.log('1',source);
  console.log('2',destination);
  // 执行一些逻辑,例如以下的交互逻辑
})

//解析命令行
program.parse();
复制代码

注意:一定要执行program.parse();解析命令,否则在你可能会直接傻掉,脚手架大业未始而崩殂。

在开发环境下通过如下命令进行测试:

node ./bin/index.js create app
复制代码

当我们的脚手架开发完成后,在package.jsonbin字段中进行映射。例如:

// package.json

"bin":{
  "lucli":"./index.js"
}
复制代码

发布在npm,当用户在全局安装完我们的脚手架工具之后,index.js将会被映射到lucli对应的全局 bins中,这样就可以在命令行中执行了:

lucli create app
复制代码

当然,在没发布之前,我们还可以通过npm link命令手动进行映射。

注意: 命令行权限问题,windows用户建议管理员模式运行。mac用户建议用sudo执行

npm link 

or

sudo npm install
复制代码

执行完成后我们也可以执行lucli create app命令了。

2. 收集用户交互信息

通过inquirer工具进行与用户交互。

const inquirer = require("inquirer")

 inquirer.prompt([
    {
      name:"name", 
      message:"the name of project: ", // 项目名称
      type:"input",// 字符类型
      default:"lucli-demo", // 默认名称
      validate: (name) => { // 验证名称是否正确
        return validProjectName(name).errMessage || true;
      }
    },
    {
      name:"framework", 
      message:"project framework", // 项目框架
      type:"list",
      choices:[ // 选项
        {
          name: "vue + ts",
          value: "vue"
        },
        {
          name: "react",
          value: "react"
        }
      ]
    }
  ])
  .then((answers) => {
    console.log('结果:',answers);
  })
  .catch((error) => {
    if (error.isTtyError) {
      // Prompt couldn't be rendered in the current environment
    } else {
      // Something else went wrong
    }
  });
复制代码

3. 确定目标工程路径

我们在以上代码中获取到了用户创建的工程名称,我们通过path确定工程路径。

const path = require("path");

// 记住这个targetDir
const targetDir = path.join(process.cwd(), '工程名称');
复制代码

targetDir是你目标工程在本地的绝对地址,例如:

 /Users/lucas/repository/study/lu-cli
复制代码

4. 匹配目录

在脚手架中通过globby用来读取我们的模版目录。

const globby = require("globby");

const files = await globby(["**/*"], { cwd: './template', dot: true })

console.log(files)
复制代码

结果是一个基于你指定目录下所有文件的路径数组。

例如是这样一个目录:

├─template
│  ├─src
│  │  ├─index.js
│  │  ├─router
│  │  │  ├─index.ts

结果如下:
files: [ "src/index.js" , "src/router/index.ts" ]
复制代码

以文件为最小单元。 这个工具的具体作用在实战中比较明显,接着往后看。

5. 读写文件

fs-extrafs模块的加强版,在原有功能的基础上新增了一些api。

const fs = require("fs-extra")

// 读取文件内容
const content = fs.readFileSync('文件路径', 'utf-8'); 

// 写成文件
fs.writeFileSync('文件路径','文件内容')
复制代码

通常用来渲染文件,除了以上方式,模版文件还可以通过以下方式从代码仓库下载。

6. 下载仓库代码

通过download-git-repo从代码仓库git clone代码。

const download = require("download-git-repo");

download('https://www.xxx..git', 'test/tmp', function (err) {
  console.log(err)
})
复制代码

7. 模版渲染

通过ejs进行通常在模版中需要根据不同的条件进行渲染。例如:

  • package.json中根据用户是否需要安装babel的添加关于babel的一些配置。
  • main.js的文件中根据功能,渲染不同代码。
  • xx.vue的模版中动态设置css预编译。
  • ...
const ejs = require('ejs')

// demo-01
const template = (
  `<%_ if (isTrue) { _%>`
  + '内容'
  + `<%_ } _%>`
)

const newContent = ejs.render(template, {
  isTrue: true,
})

//demo-02
const template = (
  `<style lang="<%= cssPre %>">`
  +`.redColor{`
  +`color:red`
  +`}`
  + `</style>`
)

const newContent = ejs.render(template, {
  cssPre: 'less',
})
复制代码

8. 子进程执行管理器

通过execa执行终端命令,比如npm install等命令,这个工具还可以设置安装源。

const executeCommand = (command, args, cwd) => {
  return new Promise((resolve, reject) => {
    const child = execa(command, args, {
      cwd,
      stdio: ['inherit', 'pipe', 'inherit'],
    })

    child.stdout.on('data', buffer => {
      const str = buffer.toString()
      if (/warning/.test(str)) {
        return
      }
      process.stdout.write(buffer)
    })

    child.on('close', code => {
      if (code !== 0) {
        reject(new Error(`command failed: ${command}`))
        return
      }
      resolve()
    })
  })
}

// 这里的targetDir是上面我们的目标工程路径,在这个路径下执行npm install
await executeCommand('npm', ['install'], targetDir)
复制代码

9. 终端字符串样式。

通过chalk实现在终端中展示不同样式的字符串。

const chalk = require('chalk');

console.log(chalk.blue('Hello world!'));
复制代码

10. 文件内容转换成AST

vue-cli中通过vue-codemod将文件内容转换成AST,从而实现在文件中注入代码的功能,返回文件内容字符串。

const { runTransformation } = require("vue-codemod")

const fileInfo = {
  path: "src/main.js", 
  source: "文件内容"
}

// 对代码进行解析得到 AST,再将参数 imports 中的语句插入
const injectImports = (fileInfo, api, { imports }) => {
  const j = api.jscodeshift
  const root = j(fileInfo.source)

  const toImportAST = i => j(`${i}\n`).nodes()[0].program.body[0]
  const toImportHash = node => JSON.stringify({
    specifiers: node.specifiers.map(s => s.local.name),
    source: node.source.raw,
  })

  const declarations = root.find(j.ImportDeclaration)
  const importSet = new Set(declarations.nodes().map(toImportHash))
  const nonDuplicates = node => !importSet.has(toImportHash(node))
  const importASTNodes = imports.map(toImportAST).filter(nonDuplicates)

  if (declarations.length) {
    declarations
      .at(-1)
      // a tricky way to avoid blank line after the previous import
      .forEach(({ node }) => delete node.loc)
      .insertAfter(importASTNodes)
  } else {
    // no pre-existing import declarations
    root.get().node.program.body.unshift(...importASTNodes)
  }

  return root.toSource()
}

const params = { 
  imports: [ "import { store, key } from './store';" ]
}

const newContent = runTransformation(fileInfo, transformation, params)
复制代码

脚手架原理(开发思路)

总结

脚手架原理就是通过收集到用户交互信息后,根据不同定制化的需求,去读取我们提前准备好的模版信息,然后对个别文件做一些差异化的更新,更新的核心就是如何修改文件的内容,有三种方式:

  • 利用vue-codemod
  • 模版渲染
  • 正则匹配

这一块会在后面详细说明,最后将内容对应的都写入到我们的目标工程下面。在执行安装命令。

读取模版的方式有多种,你还可以简单粗暴的直接用download-git-repo的方式从远程仓库代码下载。这种方式看你怎么利用了,如果下载完整的项目代码都不用做配置,那要准备的模版可能比较多,相对死板,你也可以将通用模版放在仓库中通过这种方式下载,而不放在项目中从而减少项目包大小。甚至涉及到版本更新的问题,各有利弊。通常我们将模版放在项目中,方便维护。

核心原理都一样,更多需要你费心神可能是想想怎么组织好你的代码更合理了。

以下我更多通过代码结构的方式帮助大家去梳理具体要怎么开发脚手架的一个思路。

开发思路

新建一个js文件用于创建脚手架命令,可以通过node或者在package.json中用npm link的方式进行调试。

在命令执行完毕的回调中我们创建用户交互,等待用户交互完成后,假设拿到了我们这些信息:

{
    name:"lucli-demo", // 项目名称
    framework:"vue", // vue框架
    funcList:["router","vuex","less"] // 功能列表
}
复制代码

现在我们的目标就明确了,构建一个集成了router,vuex,lessvue开发环境。接下来我们大致思路就是下载模版,然后在对应的文件中注入代码,比如在package.json中添加对应的依赖。

我们将模版根据功能进行划分,vue框架模版目录如下:

vue-plugins/default/  // 默认模版
vue-plugins/router/ // 路由模版
vue-plugins/vuex/ // vuex模版
复制代码

下载模版其实就是读取模版内容然后生成一个文件:

// 写成文件
fs.writeFileSync('文件路径','文件内容')
复制代码

那我们生成多个文件是不是可以通过循环遍历一个对象,那基于此我们设想将我们要构建的文件列表都放在一个对象里面,这个数组的结构如下:

{
  'src/main.js':'内容',
  'src/App.vue':'内容',
  'src/theme.less':'内容',
  'package.json':'内容'
  ...
}
复制代码

接下来要做的就是去生成这个对象,首先我们创建一个Generator类,我们将最终要渲染的文件目录放在里面:

// src/Generator.js

class Generator{
  constructor(){
    this.files = {}; // 文件目录
  }
}
复制代码

通过globby去读取我们的模版目录,然后遍历这个对象,通过文件系统(fs-extra)去读取对应的文件内容,我们这边以router模版为例。

// Generator.js

class Generator{
  constructor(){
    this.files = {}; // 文件目录
  }
  
  render(){
    // 读取router功能模板
    const files = await globby(["**/*"], { cwd: './src/vue-plugins/router', dot: true })
    for (const rawPath of files) {
      // 读取文件内容
      const content = getFileContent(rawPath)
      // 更新files对象
      this.files[rawPath] = content; 
    }
  }
}
复制代码

我们在每个模版目录下面都创建一个index.js,通过调用这个render方法将模版信息都保存在files这个对象中。

// src/vue-plugins/router/index.js

module.exports = (generator) => {
  // 渲染模版
  generator.render("vue-plugins/router/template");
}
复制代码

我们在获取到功能列表后,循环这个列表,依次去执行这个render方法。

const generator = new Generator();
// 循环功能加载模版
_funcsList.forEach(funcName => {
  require(`../src/${pluginName}/${funcName}/index.js`)(generator);
});
复制代码

当然,我们也别忘了加载我们的默认模板,毕竟默认模板才是项目架构的主体。

这样我们的files对象就是要渲染成文件的目录了。

剩下就是在特定的文件中做一些差异化的处理了,比如main.js中引入router等。

这就涉及到如何在一个文件中插入代码了,有3种方式:

  • vue-cli中利用了vue-codemod这个包将文件内容转换成AST,然后在AST对应的节点插入内容,再将AST转换成文件内容。
  • 文件内容本质上会被读取成一个字符串,插入代码就是在对应的地方插入字符串而已,我们可以通过正则表达式的方式去匹配位置。
  • 通过模版渲染的方式根据条件去判断要渲染的内容,或者将要插入的代码通过变量的方式直接注入到内容中,这种方式需要你对模版做一些处理,如果你对模版渲染还不太了解的建议先去了解下ejs

关于package.json我们单独做处理,用简单的对象合并做相关的差异化,如果有复杂的配置也可以考虑模版渲染。

思想还是一样的,我们在Generator中创建一个codeInFiles对象用来存放要插入代码的文件以及要插入的内容,pkg对象存放package.json内容。

// src/Generator.js

class Generator{
  constructor(){
    this.pkg = {
      name,
      version: "1.0.0",
      description: "",
      scripts: {
        dev: "vite --mode development",
        build: "vue-tsc --noEmit && vite build",
        prebuild: "vue-tsc --noEmit && vite build --mode staging",
        serve: "vite preview",
      },
    }; // package.json
    this.files = {}; // 文件目录
    this.codeInFiles = { // 要插入代码的对象
      '路径':new Set()
    };
  }
  
  // 更新要插入代码的codeInFiles对象
  injectImports(path, source) {
    const _imports = this.codeInFiles[path] || (this.codeInFiles[path] = new Set());
    (Array.isArray(source) ? source : [source]).forEach(item => {
      _imports.add(item)
    })
  }
}
复制代码

router功能为例,在router功能模版的index.js中调用:

// src/vue-plugins/router/index.js

module.exports = (generator) => {

  // 渲染模版
  generator.render("vue-plugins/router/template");
  
   // 添加依赖
  generator.extendPackage({
    "dependencies": {
      "vue-router": "^4.0.10",
    }
  })
  
   // 注入代码
  generator.injectImports("src/main.ts", "import router from './router';");
}
复制代码

循环遍历codeInFiles这个对象来进行插入代码,将新的文件内容用来更新files对象,我这边以vue-codemod为例子:

// Generator.js

  // 处理package对象
  extendPackage(obj) {
    for (const key in obj) {
      const value = obj[key];
      if (isObject(value) && (key === 'dependencies' || key === 'devDependencies' || key === 'scripts')) {
        this.pkg[key] = Object.assign(this.pkg[key] || {}, value);
      } else {
        this.pkg[key] = value;
      }
    }
  }
  
  // 往files中插入代码
  injectImports(){
    Object.keys(_codeInFiles).forEach(file => {
      const imports = _codeInFiles[file] instanceof Set ? Array.from(_codeInFiles[file]) : [];
      if (imports && imports.length) {
       // 将新插入代码后的文件内容更新files对象
        _files[file] = runTransformation(
          { path: file, source: _files[file] },
          injectImports,
          { imports },
        )
      }
    })
}
复制代码

然后根据文件目录files依次生成文件即可。

// 生成package,json文件
fs.writeFileSync('package.json', JSON.stringify(this.pkg, null, 2))
// 生成其他文件
Object.keys(files).forEach((name) => {
  fs.writeFileSync(filePath, files[name])
})
复制代码

这样我们的项目架构就基本搭建好了。

最后通过execa安装依赖即可。

// 执行npm install安装依赖包
await executeCommand('npm', ['install'], targetDir)
复制代码

脚手架实战

提醒:实战过程中,作者不会说的特别细,代码贴的不会很完整,为了更好的学习效率:

建议先大家下载这个项目lu-cli,在实战的时候可以做为参考,以及查找代码。

初始化项目

通过npm init初始化我们的脚手架项目,完善下目录结构:

├─cli-project // 项目名称
│  ├─bin 
│  │  ├─index.js // 命令文件
│  ├─src // 源码
│  ├─package.json // 配置文件
│  ├─README.md 
复制代码

创建命令

// bin/index.js

#!/usr/bin/env node
const program = require("commander");
const handlePrompts = require("../src/create");

program
  .version("0.0.1", "-v, --version")
  .command("create app")
  .description("create an application")
  .action(() => {
    // 处理交互
    handlePrompts();
  })

//解析命令行
program.parse();
复制代码

运行调试

node ./bin/index.js create app
复制代码

创建交互

我们在根目录下创建src/create.js用来处理交互逻辑,这一块我就不过多赘述了,你可以根据自己的想法设计交互方案,这块的代码我就不完整贴了:

// src/create.js

const inquirer = require("inquirer")
const boxen = require('boxen');
const chalk = require('chalk');
const path = require("path");
const { promptTypes } = require("./enum");
const getPromptsByType = require("./getPrompts");
const Generator = require("./Generator");

const {
  executeCommand,
  validProjectName
} = require("./utils/index");

module.exports = () => {
  // 打印我们的欢迎信息
  console.log(chalk.green(boxen("欢迎使用 lucli ~", { borderStyle: 'classic', padding: 1, margin: 1 })));
    
  inquirer.prompt([
    {
      name: "name",
      message: "the name of project: ", // 项目名称
      type: "input",// 字符类型
      default: "lucli-demo", // 默认名称
      validate: (name) => {
        return validProjectName(name).errMessage || true;
      }
    },
    {
      name: "framework",
      message: "project framework", // 项目框架
      type: "list",
      choices: [
        {
          name: "vue + ts",
          value: promptTypes.VUE
        },
        {
          name: "react",
          value: promptTypes.REACT
        }
      ]
    }
  ]).then(answers=>{
    // 根据框架选择prompts
    const prompts = getPromptsByType(answers.framework);
    if (prompts.length) {
      // 选择功能
      inquirer.prompt(prompts).then(async (funcs) => {
        // 逻辑处理 will code
      })
    } else {
      console.log('抱歉,正在开发中,敬请期待!');
    }
  })
 }
复制代码

创建Generator类

处理完交互了,我们拿到了对应的信息:

{
    name:"lucli-demo", // 项目名称
    framework:"vue", // vue框架
    funcList:["router","vuex","less"] // 功能列表
}
复制代码

接下来就是生成files文件了,首先创建Generator类,初始化我们的filescodeInFilespkg等对象以及一些处理函数。

这个类其实是在开发中一点点的完善起来的,作者为了偷懒就直接贴出来了。你们也可以自己尝试去写一下。

// src/Generator.js

const path = require("path");
const ejs = require('ejs');
const fs = require("fs-extra");
const { runTransformation } = require("vue-codemod")
const {
  writeFileTree,
  injectImports,
  injectOptions,
  isObject
} = require("../src/utils/index")
const {
  isBinaryFileSync
} = require('isbinaryfile');

class Generator {

  constructor({ name, targetDir }) {
    this.targetDir = targetDir;
    this.pkg = { // package.json
      name,
      version: "1.0.0",
      description: "",
      scripts: {
        dev: "vite --mode development",
        build: "vue-tsc --noEmit && vite build",
        prebuild: "vue-tsc --noEmit && vite build --mode staging",
        serve: "vite preview",
      },
    };
    this.files = {}; // 文件目录
    this.codeInFiles = {}; // 要插入代码的文件
    this.optionInFiles = {}; // 注入项
    this.middlewareFuns = []; // 处理文件目录的函数列表
  }

  // 处理package对象
  extendPackage(obj) {
    for (const key in obj) {
      const value = obj[key];
      if (isObject(value) && (key === 'dependencies' || key === 'devDependencies' || key === 'scripts')) {
        this.pkg[key] = Object.assign(this.pkg[key] || {}, value);
      } else {
        this.pkg[key] = value;
      }
    }
  }

  // 更新要插入代码的对象
  injectImports(path, source) {
    const _imports = this.codeInFiles[path] || (this.codeInFiles[path] = new Set());
    (Array.isArray(source) ? source : [source]).forEach(item => {
      _imports.add(item)
    })
  }

  // 更新要插入的选项的对象
  injectOptions(path, source) {
    const _options = this.optionInFiles[path] || (this.optionInFiles[path] = new Set());
    (Array.isArray(source) ? source : [source]).forEach(item => {
      _options.add(item)
    })
  }

  // 解析文件内容
  resolveFile(sourcePath) {
    // 如果二进制文件则直接返回
    if (isBinaryFileSync(sourcePath)) {
      return fs.readFileSync(sourcePath);
    }
    const template = fs.readFileSync(sourcePath, 'utf-8');
    // 这边没什么必要,如果你有模版渲染才需要加
    const content = ejs.render(template);
    return content;
  }

  // 渲染方法
  async render(source) {
    this.middlewareFuns.push(async () => {
      const relativePath = `./src/${source}`;
      const globby = require("globby");
      // 获取文件目录
      const files = await globby(["**/*"], { cwd: relativePath, dot: true })
      for (const rawPath of files) {
        // 获取绝对地址用于读取文件
        const sourcePath = path.resolve(relativePath, rawPath)
        const content = this.resolveFile(sourcePath)
        // 有文件内容
        if (Buffer.isBuffer(content) || /[^\s]/.test(content)) {
          this.files[rawPath] = content;
        }
      }
    })
  }

  // 执行函数
  async generator() {
  
    // 设置files的值
    for (const middleawre of this.middlewareFuns) {
      await middleawre();
    }
    
    const _files = this.files;
    const _codeInFiles = this.codeInFiles;
    const _optionsInFiles = this.optionInFiles;

    // 往files中插入代码
    Object.keys(_codeInFiles).forEach(file => {
      const imports = _codeInFiles[file] instanceof Set ? Array.from(_codeInFiles[file]) : [];
      if (imports && imports.length) {
        _files[file] = runTransformation(
          { path: file, source: _files[file] },
          injectImports,
          { imports },
        )
      }
    })

    // 往files中插入代码
    Object.keys(_optionsInFiles).forEach(file => {
      const injections = _optionsInFiles[file] instanceof Set ? Array.from(_optionsInFiles[file]) : [];
      if (injections && injections.length) {
        _files[file] = injectOptions(_files[file], injections);
      }
    })

    await writeFileTree(this.targetDir, this.files)

    // 生成package.json文件
    await writeFileTree(this.targetDir, {
      "package.json": JSON.stringify(this.pkg, null, 2)
    })
  }
}

module.exports = Generator;
复制代码

加载模板

然后我们确定我们的项目路径,实例化一个Generator类,循环遍历我们的功能列表,去加载模版文件,从而构建我们的files对象。具体的模版信息请参考项目中的模版。

// src/create.js

module.exports = () => {
  ...
  inquirer.prompt(prompts).then(async (funcs) => {
    // 逻辑处理 will code
   
   // 项目路径
    const targetDir = path.join(process.cwd(), answers.name);
    // 创建实例
    const generator = new Generator({ name: answers.name, targetDir });
    
    const _funcsList = funcs.funcList;
    // 选择css预编译
    if (_funcsList.includes("precompile")) {
      const result = await inquirer.prompt([
        {
          name: "cssPrecle",
          message: "less or sass ?",
          type: "list",
          choices: [
            {
              name: "less",
              value: "less"
            },
            {
              name: "sass",
              value: "sass"
            }
          ]
        }
      ]);
      _funcsList.pop();
      // 添加预编译依赖
      generator.extendPackage({
        "devDependencies": {
          [result.cssPrecle]: result.cssPrecle === "less" ? "^4.1.1" : "^1.35.2"
        }
      })
    }
    
    let pluginName = '';
    // 确定框架模版
    switch (answers.framework) {
      case promptTypes.VUE:
        pluginName = 'vue-plugins'
        break;
      case promptTypes.REACT:
        pluginName = 'vue-plugins'
        break;
    };

    // 加载默认模版
    require(`../src/${pluginName}/default/index.js`)(generator);

    // 加载功能模版
    _funcsList.forEach(funcName => {
      require(`../src/${pluginName}/${funcName}/index.js`)(generator);
    });
    ...
  })
复制代码
// src/vue-plugins/vuex/index.js

module.exports = (generator) => {
  // 添加依赖
  generator.extendPackage({
    "dependencies": {
      "vuex": "^4.0.2"
    }
  })

  // 注入代码
  generator.injectImports("src/main.ts", "import { store, key } from './store';");

  // 注入选项
  generator.injectOptions("src/main.ts", ".use(store, key)");

  // 渲染模版
  generator.render("vue-plugins/vuex/template");
}
复制代码

在使用inquirer的时候大家灵活的使用,比如关于css预编译的选项,你也可以通过type:expand的方式去做,八仙过海,各显神通。

生成文件

然后执行generator函数将files对象写成文件,最后安装package.json中的依赖包:

//src/create.js

const {
  executeCommand,
  validProjectName
} = require("./utils/index");
...

// 执行渲染生成文件
await generator.generator();

// 执行npm install安装依赖包
await executeCommand('npm', ['install'], targetDir)

console.log(chalk.green(boxen("构建成功", { borderStyle: 'double', padding: 1 })));
复制代码

这样我们一个基础的脚手架就已经开发完毕了。

最后

掌握了该项目基本上你不仅可以开发脚手架这样的工具了,你还可以去写更多类似的工具去提高工作中的效率了。

这个项目其实还有许多可以优化的地方,比如安装依赖包的时候选择安装源,创建项目的时候检测目录下是否已经存在该文件夹了,还有关于一些开发规范的配置等等。有兴趣的伙伴可以去探索一下,以给我提交mr

有时间的话我后面也会接着更新这篇文章。

谢谢。

文章分类
前端