likes
comments
collection
share

小程序自动化构建与发布实践

作者站长头像
站长
· 阅读数 41

1. 前言

如果你开发过小程序,那么一定知道小程序文件结构、发布方式与普通的h5有很大的不同。

  • 在开发上,由于小程序固定的页面、组件结构和特有的组件标签,使得我们需要单独维护它,而且无法支持js的新语法,更无法进行编译时处理,比如Eslint、Prettier进行代码及格式化校验
  • 在发布上,需要我们去手动的上传代码及生成二维码,整个过程都很繁琐、费时。

那么,为了解决开发方面的问题,保证与开发web页面的一致性,我们大多会选择使用框架,比如使用比较多的Taro,以React技术框架为原型,实现跨端构建(支持转换到 H5、ReactNative 以及任意小程序平台),目前也支持了基于VueVue3等框架开发,实现了跨栈开发。

为了解决发布、预览的问题,基于Taro的项目提供了taro-plugin-mini-ci,接入后即可实现一行命令发布小程序。

2. 学习目标

  1. 学会使用 @tarojs/plugin-mini-ci上传小程序代码
  2. 学会使用 miniprogram-ci 工具
  3. 如何利用 release-it 提升版本号,自动打 tag,生成 changelog 等
  4. 学习taro-cli的实现,从头开发一个简易的脚手架工具,实现项目预览和发布等功能

3.如何使用 @tarojs/plugin-mini-ci

基于taro脚手架创建的项目

参考文档

功能包含:

  • 构建完毕后自动打开小程序开发者工具
  • 上传体验版
  • 生成预览二维码

使用步骤大致分为三步:

  1. 安装 npm i @tarojs/plugin-mini-ci -D
  2. 修改配置项

小程序自动化构建与发布实践 4. package.json中添加scripts

小程序自动化构建与发布实践

其中,要注意的是微信小程序的privateKeyPath的获取,找了老半天才找到。生成的密钥是key文件,要下载到项目中使用。如图:(参考)

小程序自动化构建与发布实践

4. Taro-cli 做了什么

源码

taro-cli 包位于 Taro 工程的 Packages 目录下,通过 npm install -g @tarojs/cli 全局安装后,将会生成一个 Taro 命令。主要负责项目初始化、编译、构建等。主要命令如下:

小程序自动化构建与发布实践

taro-cli作为taro项目体系包的一部分,是和其他体系包统一管理的,采用monorepo的管理方式,之前是采用的lerna进行管理,现在由于pnpm的流行,主要是好用,又快又省内存,而pnpm有支持monorepo的管理方式,所以目前是采用pnpm workspace进行管理。可以参考CONTRIBUTING了解pnpm的使用。

学习指南:

taro体系大概的结构如下图:

. 
├── CONTRIBUTING.md
├── LICENSE 
├── README.md 
├── examples  
├── types
├── tsconfig.root.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml // pnpm 配置文件 代替lerna.json
├── package.json 
├── packages
│   ├── taro
│   ├── taro-cli
│   ├── taro-plugin-mini-ci
│   ├── taro-components
│   ├── taro-components-react
│   ├── taro-components-rn
│   ├── taro-runtime
│   ├── taro-h5
│   ├── taro-router
│   ├── ...

taro-cli 包的目录结构如下:

./ 
├── bin // 命令行 
│   ├── taro // taro 命令 
├── scripts
│   ├── prepublish.js
├── src 
│   ├── cli.js // 命令行入口 不同命令执行不同的行为
│   ├── index.js // 提供给外部使用的函数
│   ├── __tests__
│   ├── util
│   ├── presets
│   ├── doctor
│   ├── convertor
│   ├── create
│   ├── commands
│   ├── config 
│   │   ├── babylon.js // JavaScript 解析器 babylon 配置 
│   │   ├── index.js // 目录名及入口文件名相关配置 
├── templates/default // 初始化文件及目录,copy模版等 
│   ├── appjs 
│   ├── config 
│   │   ├── dev 
│   │   ├── index 
│   │   └── prod 
│   ├── src 
│   ├── types 
│   ├── editorconfig 
│   ├── eslintrc 
│   ├── gitignore 
│   ├── babel.config.js
│   ├── package.json.tmpl
│   ├── project.config.json
│   ├── project.tt.json
│   ├── template_creator.js 
│   └── tsconfig.json
├── index.js
├── package.js 
├── jest.config.json 
├── tsconfig.json
├── README.md
├── node_modules 

使用到的核心库:

  • minimist - 用于解析命令行参数
  • fs-extra - 用来代替node:fs模块 但是使用更加简单 并且进行了promise包装
  • chalk - 可以用于控制终端输出字符串的样式
  • Inquirer.js - Node.js 命令行交互工具,通用的命令行用户界面集合,可以和用户进行交互。
  • ora - 实现加载中的状态是一个 Loading 加前面转起来的小圈圈,成功了是一个 Success 加前面一个小钩钩
  • mem-fs-editor - 提供一系列 API,方便操作模板文件
  • shelljs - ShellJS 是 Node.js 扩展,用于实现 Unix shell 命令执行

4.1 Taro init

4.1.1 命令入口

如何使taro命令生效? 只要在package.json中定义bin字段即可

"bin": { "taro": "bin/taro" },

此时运行taro命令执行的就是bin下的taro文件。NPM 会寻找这个文件并在 [prefix]/bin 目录下建立符号链接,所以我们可以直接通过命令来调用这些脚本。

查看prefix可通过如下命令:

npm config get prefix

通过源码可知,taro实际执行的是src/index.js暴露的ClI类的run方法。方法主体内容如下:

class CLI {
run () {
    this.parseArgs()
  }
parseArgs () {
    const args = minimist(process.argv.slice(2), {
      alias: {
        version: ['v'],
        help: ['h'],
        port: ['p']
      },
      boolean: ['version', 'help']
    })
    const _ = args._
    const command = _[0]
    if (command) {
         switch (command) {
            case 'inspect':
            case 'build': {
                ...
                break
            }
            case 'init': {
              customCommand(command, kernel, {
                _,
                appPath,
                projectName: _[1] || args.name,
                description: args.description,
                typescript: args.typescript,
                templateSource: args['template-source'],
                clone: !!args.clone,
                template: args.template,
                css: args.css,
                h: args.h
              })
              break
            }
             default:
              customCommand(command, kernel, args)
              break
        }
    }
}
}

command为解析的命令,例如:

node example/parse.js -x 3 -y 4 -n5 -abc --beep=boop foo bar baz
{ _: [ 'foo', 'bar', 'baz' ],
  x: 3,
  y: 4,
  n: 5,
  a: true,
  b: true,
  c: true,
  beep: 'boop' }

所以我们最主要的命令就是switch下的几个,包括initbuild,那么init命令到底做了啥呢?

这里主要通过customCommand函数以传参的方式去执行命令

function customCommand (
  command: string,
  kernel: Kernel,
  args: { _: string[], [key: string]: any }
) {
  if (typeof command === 'string') {
    const options: any = {}
    const excludeKeys = ['_', 'version', 'v', 'help', 'h']
    Object.keys(args).forEach(key => {
      if (!excludeKeys.includes(key)) {
        options[key] = args[key]
      }
    })

    kernel.run({
      name: command,
      opts: {
        _: args._,
        options,
        isHelp: args.h
      }
    })
  }
}

通过代码可知customCommand主要做了两件事

  1. 整理命令行参数
  2. 调用kernel.run方法并将参数传入

接下来看kernel是什么? run方法执行了什么操作?

kernel是什么

从代码可得:Kernel实际是继承自EventEmitter的类,在生成实例对象时会执行constructor方法,初始化命令行运行时需要的配置项

class Kernel extends EventEmitter {
    constructor (options: IKernelOptions) {
    super()
    this.debugger = process.env.DEBUG === 'Taro:Kernel' ? createDebug('Taro:Kernel') : function () {}
    this.appPath = options.appPath || process.cwd()
    this.optsPresets = options.presets
    this.optsPlugins = options.plugins
    this.hooks = new Map()
    this.methods = new Map()
    this.commands = new Map()
    this.platforms = new Map()
    this.initHelper()
    this.initConfig()
    this.initPaths()
  }

}
new Kernel做了啥

先看一下运行taro init命令传过去的kernel

const kernel = new Kernel({
        appPath,
        presets: [
          path.resolve(__dirname, '.', 'presets', 'index.js')
        ],
        plugins: []
      })
      kernel.optsPlugins ||= []

      // 针对不同的内置命令注册对应的命令插件
      // 如果执行taro init,那么这里的targetPlugin就是init.js 即以命令命名的js文件
      // commandPlugins则是内置的预定义的插件集合
      if (commandPlugins.includes(targetPlugin)) {
        kernel.optsPlugins.push(path.resolve(commandsPath, targetPlugin))
      }

这里仅涉及以下四个参数或属性:

  • appPath 项目路径

  • presets 插件集

    包含编译过程中的钩子函数注册文件写入并输出到指定目录生成ProjectConfig和框架信息(框架版本,生成时间等) 通过 registerMethod 注册方法

// presets/index.ts

export default () => {
  return {
    plugins: [
      // hooks
      path.resolve(__dirname, 'hooks', 'build.js'),

      // 兼容其他平台小程序插件
      path.resolve(__dirname, 'files', 'writeFileToDist.js'),
      path.resolve(__dirname, 'files', 'generateProjectConfig.js'),
      path.resolve(__dirname, 'files', 'generateFrameworkInfo.js')
    ]
  }
}
  • plugins 插件
  • 设置optsPlugins 添加指定命令的插件

我们执行taro init时,这里的kernel.optsPlugins实际是添加了插件presets/commands/init.ts

插件的上下文可参考:IPluginContext接口定义

通过 registerCommand 方法注册命令,参数包括:

  • name 命令名称
  • optionsMap 定义可选择的命令参数
  • fn 注册命令需要执行的操作

代码如下:

export default (ctx: IPluginContext) => {
  ctx.registerCommand({
    name: 'init',
    optionsMap: {
      '--name [name]': '项目名称',
      '--description [description]': '项目介绍',
      '--typescript': '使用TypeScript',
      '--npm [npm]': '包管理工具',
      '--template-source [templateSource]': '项目模板源',
      '--clone [clone]': '拉取远程模板时使用git clone',
      '--template [template]': '项目模板',
      '--css [css]': 'CSS预处理器(sass/less/stylus/none)',
      '-h, --help': 'output usage information'
    },
    async fn (opts) {
      // init project
      const { appPath } = ctx.paths
      const { options } = opts
      const { projectName, templateSource, clone, template, description, typescript, css, npm } = options
      const Project = require('../../create/project').default
      const project = new Project({
        projectName,
        projectDir: appPath,
        npm,
        templateSource,
        clone,
        template,
        description,
        typescript,
        css
      })

      project.create()
    }
  })
}

我们主要关注project.create方法就好了 具体可查看 project.js 以下为部分核心代码:

class Project extends Creator { //基于Creator类
async create () {
    try {
      const answers = await this.ask() // 执行用户交互询问操作
      const date = new Date()
      this.conf = Object.assign(this.conf, answers)
      this.conf.date = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
      this.write() // 根据用户交互结果生成相应的目录文件
    } catch (error) {
      console.log(chalk.red('创建项目失败: ', error))
    }
  }
  
  async ask () {
    let prompts: Record<string, unknown>[] = []
    const conf = this.conf

    this.askProjectName(conf, prompts) // 请输入项目名称
    this.askDescription(conf, prompts) // 请输入项目介绍
    this.askFramework(conf, prompts) // 请选择框架 vue/react/vue3
    this.askTypescript(conf, prompts) // 是否使用ts
    this.askCSS(conf, prompts) // 请选择 CSS 预处理器(Sass/Less/Stylus
    this.askCompiler(conf, prompts) // 请选择编译工具 webpack4/webpack5
    this.askNpm(conf, prompts) // 请选择包管理工具
    await this.askTemplateSource(conf, prompts) // 请选择模板源 gitee/github/cli内置等 可自定义
    
    // 第一步:先询问生成项目的基本信息 生成询问项数组 然后执行inquirer.prompt
    const answers = await inquirer.prompt(prompts)
    
    // 第二步:根据询问结果返回模版选项
    prompts = []
    const templates = await this.fetchTemplates(answers) // 根据前一步询问结果的框架、模版源信息去远程拉取模版
    await this.askTemplate(conf, prompts, templates) // 让用户选择可用的模版
    const templateChoiceAnswer = await inquirer.prompt(prompts)

    // 返回所有询问项的结果
    return {
      ...answers,
      ...templateChoiceAnswer
    }
  }
  
  write (cb?: () => void) {
    this.conf.src = SOURCE_DIR
    createApp(this, this.conf, cb).catch(err => console.log(err))
  }
}

那接下来就是生成项目了,也就是执行createApp,具体代码参考:create/init.ts createApp函数主要包含以下内容

function createApp (creator: Creator, params: IProjectConf, cb) {
  const { projectName, projectDir, template, autoInstall = true, framework, npm } = params
  const logs: string[] = []
  // path
  const projectPath = path.join(projectDir, projectName)
  const templatePath = creator.templatePath(template)

  // npm & yarn
  const version = getPkgVersion()

  // 遍历出模板中所有文件
  const files = await getAllFilesInFolder(templatePath, doNotCopyFiles)

  // 引入模板编写者的自定义逻辑
  const handlerPath = path.join(templatePath, TEMPLATE_CREATOR)
  const handler = fs.existsSync(handlerPath) ? require(handlerPath).handler : null

  // 为所有文件进行创建
  logs.push(
    ...createFiles(creator, files, handler, {
      ...params,
      framework,
      version,
      templatePath,
      projectPath,
      pageName: 'index',
      period: 'createApp'
    })
  )

  // fs commit
  creator.fs.commit(async () => {
    // git init
    const gitInitSpinner = ora(`cd ${chalk.cyan.bold(projectName)}, 执行 ${chalk.cyan.bold('git init')}`).start()
    process.chdir(projectPath)
    const gitInit = exec('git init')
   
    const callSuccess = () => {
      console.log(chalk.green(`创建项目 ${chalk.green.bold(projectName)} 成功!`))
      console.log(chalk.green(`请进入项目目录 ${chalk.green.bold(projectName)} 开始工作吧!😝`))
      if (typeof cb === 'function') {
        cb()
      }
    }
    if (autoInstall) {
      // packages install
      const command: string = packagesManagement[npm].command
      const installSpinner = ora(`执行安装项目依赖 ${chalk.cyan.bold(command)}, 需要一会儿...`).start()
      
      // 实际是根据选择的包管理工具 执行install操作
      const child = exec(command, (error) => {
        if (error) {
          installSpinner.fail(chalk.red('安装项目依赖失败,请自行重新安装!'))
        } else {
          installSpinner.succeed('安装成功')
        }
        callSuccess()
      })
    } else {
      callSuccess()
    }
  })
}

其中创建文件的操作是createFiles,核心代码如下:

function createFiles (
  creator: Creator,
  files: string[],
  handler,
  options: (IProjectConf | IPageConf) & {
    templatePath: string
    projectPath: string
    pageName: string
    period: string
    version?: string
  }
): string[] {
  
  const logs: string[] = []
  // 模板库模板,直接创建,不需要改后缀
  const globalChangeExt = Boolean(handler)
  const currentStyleExt = styleExtMap[css] || 'css'

  files.forEach(async file => {
    // fileRePath startsWith '/'
    const fileRePath = file.replace(templatePath, '').replace(new RegExp(`\\${path.sep}`, 'g'), '/')

    let destRePath = fileRePath
    
    // createPage 创建页面模式
    // 处理 .js 和 .css 的后缀
   ...

    // 创建
    creator.template(template, fileRePath, path.join(projectPath, destRePath), config)

    const destinationPath = creator.destinationPath(path.join(projectPath, destRePath))

    logs.push(`${chalk.green('✔ ')}${chalk.grey(`创建文件: ${destinationPath}`)}`)
  })
  return logs
}


调用creator.template方法创建模版,参照如下代码:

import * as memFs from 'mem-fs'
import * as editor from 'mem-fs-editor'

class Creator {
  fs: IMemFsEditor
  protected _rootPath: string
  private _destinationRoot: string

  constructor (sourceRoot?: string) {
    const store = memFs.create()
    this.fs = editor.create(store)
    this.sourceRoot(sourceRoot || path.join(getRootPath()))
    this.init()
  }
// creator
    template (template: string, source: string, dest: string, data?: Record<any, any>, options?) {
    if (typeof dest !== 'string') {
      options = data
      data = dest
      dest = source
    }

    const src = this.templatePath(template, source)
    if (!fs.existsSync(src)) return
    // 这里的this.fs在创建Project实例时已经被初始化为mem-fs-editor库的文件编辑类
    this.fs.copyTpl(src, this.destinationPath(dest), Object.assign({ _ }, this, data), options)
    return this
  }
}

由代码可知,文件操作是基于mem-fsmem-fs-editor这两个库的,调用mem-fs-editorcopyTpl方法创建模版,如果不是二进制文件,则以ejs的方式解析内容。

至此,taro init命令的行为已经被确定好了,那么它是如何被调用的呢?

kernel.run()
async run (args: string | { name: string, opts?: any }) {
    let name
    let opts
    // 初始化预设插件集和插件 
    // 会调用initPluginCtx方法初始化插件的上下文 比如applyPlugins等方法 后续即可直接调用applyPlugins方法执行某个钩子函数
    // 设置this.plugins的值
    this.initPresetsAndPlugins()
    // 执行onReady钩子
    await this.applyPlugins('onReady')
    // 执行onStart钩子
    await this.applyPlugins('onStart')
    // 执行平台相关的钩子
    if (opts?.options?.platform) {
      opts.config = this.runWithPlatform(opts.options.platform)
      await this.applyPlugins({
        name: 'modifyRunnerOpts',
        opts: {
          opts: opts?.config
        }
      })
    }
    // 这里开始真正执行init钩子函数
    await this.applyPlugins({
      name,
      opts
    })
  }

initPluginCtx代码如下:

 initPluginCtx ({ id, path, ctx }: { id: string, path: string, ctx: Kernel }) {
    const pluginCtx = new Plugin({ id, path, ctx })
    const internalMethods = ['onReady', 'onStart']
    const kernelApis = [
      'appPath',
      'plugins',
      'platforms',
      'paths',
      'helper',
      'runOpts',
      'initialConfig',
      'applyPlugins'
    ]
    internalMethods.forEach(name => {
      if (!this.methods.has(name)) {
        pluginCtx.registerMethod(name)
      }
    })
    return new Proxy(pluginCtx, {
      get: (target, name: string) => {
        if (this.methods.has(name)) {
          const method = this.methods.get(name)
          if (Array.isArray(method)) {
            return (...arg) => {
              method.forEach(item => {
                item.apply(this, arg)
              })
            }
          }
          return method
        }
        if (kernelApis.includes(name)) {
          return typeof this[name] === 'function' ? this[name].bind(this) : this[name]
        }
        return target[name]
      }
    })
  }

applyPlugins方法的核心代码如下:

async applyPlugins (args: string | { name: string, initialVal?: any, opts?: any }) {
    const hooks = this.hooks.get(name) || []
    if (!hooks.length) {
      return await initialVal
    }
    const waterfall = new AsyncSeriesWaterfallHook(['arg'])
    if (hooks.length) {
      const resArr: any[] = []
      for (const hook of hooks) {
        waterfall.tapPromise({
          name: hook.plugin!,
          stage: hook.stage || 0,
          // @ts-ignore
          before: hook.before
        }, async arg => {
        // 执行钩子函数定义的fn函数
          const res = await hook.fn(opts, arg)
          if (IS_MODIFY_HOOK.test(name) && IS_EVENT_HOOK.test(name)) {
            return res
          }
          if (IS_ADD_HOOK.test(name)) {
            resArr.push(res)
            return resArr
          }
          return null
        })
      }
    }
    return await waterfall.promise(initialVal)
  }

观察applyPlugins函数会发现,钩子函数的fn函数被执行,也就是前面的project.create() 被执行。至此,taro init命令的执行就完成了闭环。

5. 实现简易小程序脚手架

功能包括

  • 命令行在微信开发工具中打开项目
  • 快速创建自定义页面或组件模板
  • 命令式预览、发布小程序

代码参考 源码

5.1 初始化npm并安装依赖包

先初始化项目包 npm init

安装用到的npm包 npm i minimist inquirer chalk miniprogram-ci

5.2 解析命令行参数

#!/usr/bin/env babel-node
import fs from 'fs'
import minimist from 'minimist'
import path from 'path'
import chalk from 'chalk'
const packagePath = path.resolve(path.join(Config.root, './package.json'))
const packageJson = JSON.parse(fs.readFileSync(packagePath))


const args = minimist(process.argv.slice(2), {
    alias: {
      version: ['v'],
      help: ['h'],
    },
    boolean: ['version', 'help']
})
const _ = args._

if (args.version) {
    console.log(chalk.green(packageJson.version))
}
if (args.help) {
    console.log(chalk.green('mini-wechat-cli create 创建页面或组件模版'))
    console.log(chalk.green('mini-wechat-cli open 在微信开发工具打开项目'))
    console.log(chalk.green('mini-wechat-cli preview 生成预览二维码 会显示在小程序助手'))
    console.log(chalk.green('mini-wechat-cli publish 上传代码并发布版本'))
}
const command = _[0]
switch (command) {
    case 'create':
        createProgram(await getUserConf())
        break;
    case 'open':
        openWeApp(await getUserConf())
    break;
    case 'preview':
        previewWeApp(await getUserConf())
    break;
    case 'publish':
        publishWeApp(await getUserConf())
        break;
}

由代码可知,我们支持的所有命令,都是比较常用的功能。

这里以上传代码为例,梳理一下实现流程

5.3 增加配置文件

首先在本地新建一个配置文件,定义一些默认的path等,如下所示:

import path from 'path'

import { fileURLToPath } from 'url'
// import.meta.url为当前文件的绝对 `file:` URL
// fileURLToPath 将`file:` URL转换为绝对path
const __filenameNew = fileURLToPath(import.meta.url)
const __dirnameNew = path.dirname(__filenameNew)
export default {
    // 被执行js文件的绝对路径
    root: __dirnameNew,
    // 命令行运行时的工作目录
    dir_root: process.cwd(),

    // 小程序项目路径
    entry: './',

    // 项目编译输出文件夹
    output: './',

    // 小程序模版目录
    template: __dirnameNew + '/template'
}

这里需要搞清楚rootdir_root的区别:

  • root 实际就是当前这个config文件的绝对path
  • dir_root 值为process.cwd(), 指命令行运行时的工作目录 比如我在自己的项目中使用这个命令,那么dir_root就是我的项目目录

5.4 用户自定义配置

那我们如果想支持用户自定义配置的话,就读取用户项目的配置信息,所以我们定义一个名为ci.config.js的配置文件,以下为用到的可配置下: (⚠️可能你的项目解析js的方式不是modlue,此时会有报错,那么你可以以mjs为后缀命名此文件。)

export default  {
  // 小程序路径(可选,默认当前目录)
  entry: './src',
  // 小程序输出文件(可选,默认等于entry)
  // 使用gulp,webpack等打包工具开发会导致开发者编辑文件目录和微信编辑器使用目录不同,需要手动进行指定
  output: './dist',
  // 打开微信开发者工具的路径 默认为当前目录
  open: '',
  // 模版文件夹目录(可选,默认使用cli默认模版,使用默认模版情况下false即可)
  template: '',
  // 发布体验版功能的钩子
  publishHook: {
      // 发布之前(注:必须返回一个Promise对象)
      // 参数answer 为之前回答一系列问题的结果
      before(answer) {
          this.log('publish before', answer)
          // this.spawnSync('gulp', [`--env=${answer.isRelease ?'online' : 'stage'}`]);
          return Promise.resolve();
      },
      // 发布之后(注:必须返回一个Promise对象)
      async after(answer) {
          this.log('publish after')
          // 是否提交git commit
        let res = await inquirerGitCommit.call(this);
          // 当为正式版本时进行的操作
          if (!!answer.isRelease) {
              // 提交git log
            if (!!res.isCommitGitLog) {
              await commitGitLog.call(this);
            }
          }
          return Promise.resolve();
      }
  }
};

除了基础的path配置外,我们还自定义了发布版本的钩子函数,在上传发布版本前、后会执行。

5.5 上传小程序代码

小程序上传代码 miniprogram-ci

这里有几点需要注意的地方:

  1. 需要将上传代码的key放到项目中,并添加到ci.version.json
  2. 把调试的ip加到白名单(如果不加 在执行上传时会提示加到白名单的ip)
  3. 另外一个常见的报错是,上传时的编译配置与本地开发配置不同 比如提示小程序大小超限,可以配置minify:true

在上传的时候,我们是需要版本号描述信息的,由于使用ci上传,那么默认的上传者是机器人1,所以我们还需要可以指定机器人,便于区分提交者。 那么,我们就可以确定交互询问的问题了。如下:

function getQuestions({ version, versiondesc } = {}) {
    return [
        {
            type: 'confirm',
            name: 'isRelease',
            message: '是否为正式发布版本',
            default: true
        },
        {
            type: 'list',
            name: 'version',
            message: `设置上传的版本号:(当前版本${version})`,
            default: 1,
            choices: getVersionChoices(version),
            filter(opts) {
                if (opts === 'no change') {
                    return version
                }
                return opts.split(': ')[1]
            },
            when(answer) {
                return !!answer.isRelease
            }
        },
        {
            type: 'input',
            name: 'versiondesc',
            message: '改动描述',
            default: versiondesc
        },
        {
            type: 'input',
            name: 'robot',
            message: '输入发布的机器人(可选值:1~30)',
            default: 1
        }
    ]
}

得到询问结果后就可以上传代码啦!

export default async function (userConf) {
    // 版本配置文件路径    
    const versionConfPath = Config.dir_root + '/ci.version.json' 
    const versionConf = JSON.parse(fs.readFileSync(versionConfPath))
    // 获取版本配置
    console.log(versionConfPath, versionConf.version)
    // 交互式命令
    const answer = await inquirer.prompt(getQuestions(versionConf))
    console.log(answer)
    // 根据答案重写版本信息
    versionConf.version = answer.version || versionConf.version
    versionConf.versiondesc = answer.versiondesc

    // 上传
    const project = new ci.Project({
        appid: versionConf.appid,
        type: 'miniProgram',
        projectPath: `${Config.dir_root}`,
        privateKeyPath: versionConf.privateKeyPath,
        ignores: ['node_modules/**/*'],
    })
    const uploadResult = await ci.upload({
        project,
        version: versionConf.version,
        desc: versionConf.versiondesc,
        setting: {
            // es6: true,
            minify: true
        },
        robot: answer.robot,
        onProgressUpdate: console.log,
    })
    console.log(uploadResult)
}

5.6 重写版本配置文件

import jsonFormat from "json-format"
function rewriteLocalVersion(filepath, versionConf) {
    return new Promise((resolve, reject) => {
        fs.writeFile(filepath, jsonFormat(versionConf), err => {
            if(err){
                Log.err(err);
                process.exit(1);
            }else {
                resolve();
            }
        })
    }) 
} 

所以在代码上传后调用rewriteLocalVersion方法去更新本地的项目版本和描述。

完整代码如下:

// publishWeApp.js
export default async function (userConf) {
    // 版本配置文件路径    
    const versionConfPath = Config.dir_root + '/ci.version.json' 
    const versionConf = JSON.parse(fs.readFileSync(versionConfPath))
    // 获取版本配置
    console.log(versionConfPath, versionConf.version)
    // 交互式命令
    const answer = await inquirer.prompt(getQuestions(versionConf))
    console.log(answer)
    // 根据答案重写版本信息
    versionConf.version = answer.version || versionConf.version
    versionConf.versiondesc = answer.versiondesc
    // 发布前置钩子
    await userConf?.publishHook?.before.call(originPrototype, answer).catch(() => process.exit(1))

    // 上传体验版
    const project = new ci.Project({
        appid: versionConf.appid,
        type: 'miniProgram',
        projectPath: `${Config.dir_root}`,
        privateKeyPath: versionConf.privateKeyPath,
        ignores: ['node_modules/**/*'],
    })
    const uploadResult = await ci.upload({
        project,
        version: versionConf.version,
        desc: versionConf.versiondesc,
        setting: {
            // es6: true,
            minify: true
        },
        robot: answer.robot,
        onProgressUpdate: console.log,
    })
    console.log(uploadResult)

    // 修改本地版本文件(为发行版时)
    !!answer.isRelease && await rewriteLocalVersion(versionConfPath, versionConf)

    // 发布后置钩子
    await userConf?.publishHook?.after.call(originPrototype, answer).catch(() => process.exit(1))
    Log.success(`上传体验版成功, 登录微信公众平台 https://mp.weixin.qq.com 获取体验版二维码`);
}

6. release-it的使用

参考文档

6.1 介绍

release-it包含的主要功能:

  • 选择发布版本 (也可自定义)
  • 自动打tag并推送到仓库
  • 生成Releases版本
  • 自动发布包到npm
  • 支持添加changelog.md文件
  • 可以自定义钩子函数 发布生命周期内执行其他操作 比如:单元测试等

安装:

npm install -D release-it

直接运行release-it会报错,因为命令安装在本地的node_module下,将命令添加到package.json的是scripts中:

"scripts": {
    "release": "release-it"
  },

执行npm run release,效果类似下图:

小程序自动化构建与发布实践

如果不想执行发版操作,仅查看发版信息,可以增加 --dry参数,即运行执行npm run release --dry命令

实际执行完,项目中是没有log文件的,如果想把每次的修改生成changelog.md改怎么操作呢?

6.2 生成CHANGELOG.md

生成changelog

可以在本地新建一个release-it的配置文件.release-it.json

我们使用auto-changelog的方式生成log,配置方式如下:

{
    "hooks": {
        "after:bump": "npx auto-changelog -p"
      },
    "git": {
      "commitMessage": "chore: release v${version}",
      "changelog": "npx auto-changelog --stdout --commit-limit false -u --template https://raw.githubusercontent.com/release-it/release-it/master/templates/changelog-compact.hbs"
    },
}

6.3 推送GitHub Releases

配置参考

这里采用的是无需GITHUB_TOKENManual方式,配置代码如下:

{
"github": {
      "release": true,
      "web": true,
      "releaseName": "v${version}"
    }
}

以上就是有关release-it的使用介绍。

7. 总结

这篇内容比较多,断断续续的写了很久,好在中途没有放弃,这个过程中学习到了很多,因为要输出,那么,就会促使你学习得更深入。慢慢积累,就会有不一样的收获。加油加油! 争取后面有更多的输出!😊

转载自:https://juejin.cn/post/7155442291325763620
评论
请登录