Skip to content

构建小程序 - CI

发布于:

Table of contents

展开目录

目录

开发辅助

为了让开发者能够通过 Node 来控制小程序的上传、预览等功能,微信官方提供了两种开发辅助:开发者工具(命令行/HTTP)miniprogram-ci

模块命令行HTTPminiprogram-ci
登录工具支持支持不支持
是否登录工具支持支持不支持
预览支持支持支持
上传代码支持支持支持
自动预览支持支持不支持
构建 npm支持支持支持
清除缓存支持支持不支持
启动工具支持支持不支持
打开其他项目支持支持不支持
关闭项目窗口支持支持不支持
关闭工具支持支持不支持

开发者工具(命令行/HTTP) 功能丰富,支持面广,什么都好,就是一点都不好用!

如果你想封装一个 package 提供给他人使用,不通平台的兼容性、安装路径等的初始化就能直接劝退大部分使用者,尤其是略懂前端的测试同事、完全不懂开发的产品和运营同事,会额外增加学习成本。

构建 NPM

小程序引入 npm packages 时,千万小心你的主包大小!

  • 构建 npm 后的 package 都会被计算在主包内
  • 构建 npm 时只会构建 dependencies 中的 package
  • 使用 npm i dayjs --save --only=production 来减少体积

build-npm

package.json
{
  "dependencies": {
    "dayjs": "^1.9.7"
  }
}

结合 packageJsonPath miniprogramNpmDistDir 来自定义 miniprogram_npm 输出目录

project.config.json
{
  "setting": {
    "packNpmRelationList": [
      {
        "packageJsonPath": "./package.json",
        "miniprogramNpmDistDir": "./src/"
      }
    ]
  }
}
.gitignore
  node_modules
 
+ src/miniprogram_npm

目录结构示例如下:

src
 ┣ home
miniprogram_npm
 ┃ ┗ dayjs
 ┣ other subPackage
 ┣ subPackage1
 ┣ _shared
 ┣ app.js
 ┣ app.json
 ┗ app.scss

MINIPROGRAM-CI

前置条件

  • 下载上传秘钥
  • 添加 (公网)IP 白名单

ci-manage

构建思路

ci
 ┣ keys
 ┃ ┣ private.${APPID_DEV}.key
 ┃ ┣ private.${APPID_PROD}.key
 ┃ ┣ private.${APPID_SIT}.key
 ┃ ┗ private.${APPID_UAT}.key
 ┣ build.js
 ┣ constants.js
 ┣ context.js
 ┣ miniprogramCI.js
 ┣ preview.js
 ┣ upload.js
 ┗ utils.js

注入 env

通过不同的 command 注入四套环境的 env

package.json
{
  "scripts": {
    "start:dev" : "gulp --env=dev",
    "start:sit" : "gulp --env=sit",
    "start:uat" : "gulp --env=uat",
    "start:prod": "gulp --env=prod",
 
-   "build:dev" : "gulp build --env=dev --output=./build --ignoreLocal",
+   "build:dev": "node ./scripts/ci/build --env=dev",
 
-   "build:sit" : "gulp build --env=sit --output=./build --ignoreLocal",
+   "build:sit": "node ./scripts/ci/build --env=sit",
 
-   "build:uat" : "gulp build --env=uat --output=./build --ignoreLocal",
+   "build:uat": "node ./scripts/ci/build --env=uat",
 
-   "build:prod": "gulp build --env=prod --output=./build --ignoreLocal"
+   "build:prod": "node ./scripts/ci/build --env=prod",
 
+   "preview:dev": "node ./scripts/ci/preview --env=dev",
+   "preview:sit": "node ./scripts/ci/preview --env=sit",
+   "preview:uat": "node ./scripts/ci/preview --env=uat",
+   "preview:prod": "node ./scripts/ci/preview --env=prod",
 
+   "upload:dev": "node ./scripts/ci/upload --env=dev",
+   "upload:sit": "node ./scripts/ci/upload --env=sit",
+   "upload:uat": "node ./scripts/ci/upload --env=uat",
+   "upload:prod": "node ./scripts/ci/upload --env=prod"
  }
}

初始化 workspace

在脚本中,设计了三种 scripts,它们的前置条件,都需要将 workspace 准备好:

前置条件
下载仓库 => 切换 env-branch => 安装依赖 => 构建项目
  1. build:env => build.js => 迁移 zip 构建产物
  2. preview:env => preview.js => 调用 miniprogramCI.preview
  3. upload:env => upload.js => 调用 miniprogramCI.upload

前置条件 其提取,单独封装成 context.js 模块,共享给三种脚本:

constants.js
// 克隆 git 仓库至本地时,指定 REPO_DOWNLOAD_FOLDER 作为存储目录
const REPO_DOWNLOAD_FOLDER = ".mp-repos";
 
const ENV_BRANCH_MAPS = {
  // 环境名 : git branch-name
  dev: "dev",
  sit: "test",
  uat: "pre-release",
  prod: "master",
};
 
module.exports = {
  REPO_DOWNLOAD_FOLDER,
  ENV_BRANCH_MAPS,
};
utils.js
// 根据仓库地址解析仓库名称
// http://gitlab.example.com/mp-example-repo.git
// =>
// mp-example-repo
function getRepoName(repo) {
  const arr = repo.split("/");
 
  return arr[arr.length - 1].replace(".git", "");
}
.npmrc
# 可以通过各种方式获取到仓库地址
# 1. 直接在 process.cwd() 路径下调用 git remote -v
# 2. 读取 package.json 中的 repository.url
# 3. 或者像我这样,在 .npmrc 里面硬编码
repo=http://gitlab.example.com/mp-example-repo.git
context.js
const path = require("path");
const { homedir } = require("os");
const yargs = require("yargs/yargs");
const args = yargs(yargs.hideBin(process.argv)).argv;
 
const { REPO_DOWNLOAD_FOLDER, ENV_BRANCH_MAPS } = require("./constants");
const { getRepoName } = require("./utils");
 
// 解构出脚本接收到的环境参数 --env=sit => sit
const { env } = args;
 
// 根据映射关系,获取 sit 对应的分支 test
const branch = ENV_BRANCH_MAPS[env];
 
// 读取 .npmrc 中的 repo 值,仓库地址 => http://gitlab.example.com/mp-example-repo.git
const repo = process.env.npm_config_repo;
 
// 获取到仓库名称 http://gitlab.example.com/mp-example-repo.git => mp-example-repo
// mp-example-repo 是拼接最终 workspace 目录中的一环
const repoName = getRepoName(repo);
 
// 拼接目录结构 => ~/.mp-repos/mp-example-repo/test/
// dev  =>  ~/.mp-repos/mp-example-repo/dev/
// sit  =>  ~/.mp-repos/mp-example-repo/test/
// uat  =>  ~/.mp-repos/mp-example-repo/pre-release/
// prod =>  ~/.mp-repos/mp-example-repo/master/
const repoPath = path.join(homedir(), REPO_DOWNLOAD_FOLDER, repoName, branch);
 
// 约定输出目录 ./build,它将作为 miniprogram-ci 所读取的工作目录
// ~/.mp-repos/mp-example-repo/test/build
const workspace = path.join(repoPath, `./build`);
 
// 克隆代码并切换环境分支
const cloneCommand = `git clone ${repo} ${repoPath} && cd ${repoPath} && git checkout ${branch}`;
 
// 安装依赖,为了让安装更快,使用 npm install --only=production
const installCommand = `cd ${repoPath} && npm install --only=production`;
 
// 构建命令,--output 与 workspace 相互对应
// 当然,这里也可以通过额外的 args 参数注入变成动态的
const buildCommand = `cd ${repoPath} && gulp build --env=${env} --output=./build --ignoreLocal`;
 
module.exports = {
  env,
  branch,
  repo,
  repoName,
  repoPath,
  workspace,
  cloneCommand,
  installCommand,
  buildCommand,
};

构建步骤

新增一个 runExec() 用于异步的执行 command 并在终端输出日志

utils.js
const runExec = (command, options) =>
  new Promise((resolve, reject) => {
    try {
      const result = execSync(command, {
        stdio: "inherit",
        cwd: process.cwd(),
        ...options,
      });
 
      resolve(result);
    } catch (error) {
      reject(error);
    }
  });

规划出每次脚本运行所执行的步骤

  1. 清空 workspace 确保目录干净、代码依赖都是最新的;
  2. 执行 cloneCommand;
  3. 执行 installCommand;
  4. 执行 buildCommand;
  5. 执行 zip preview upload;
步骤[1,2,3,4]
const clc = require("cli-color");
const fse = require("fs-extra");
const path = require("path");
const context = require("./context");
const { runExec, getBuildName } = require("./utils");
 
const { env, repoName, repoPath, cloneCommand, installCommand, buildCommand } =
  context;
 
run();
 
async function run() {
  try {
    // 1. 清空 `workspace`
    console.log(
      `[${clc.green("")}] [${clc.blue(
        clc.bold(repoName)
      )}] 清理工作空间: ${repoPath}`
    );
    await fse.emptyDir(repoPath);
 
    // 2. 执行 `cloneCommand`
    console.log(
      `[${clc.green("")}] [${clc.blue(clc.bold(repoName))}] 开始克隆仓库...`
    );
    console.log();
    await runExec(cloneCommand);
 
    // 3. 执行 `installCommand`
    console.log(
      `[${clc.green("")}] [${clc.blue(clc.bold(repoName))}] 开始安装依赖...`
    );
    console.log();
    await runExec(installCommand);
 
    // 4. 执行 `buildCommand`
    console.log(
      `[${clc.green("")}] [${clc.blue(
        clc.bold(repoName)
      )}] 开始构建应用: ${env}`
    );
    console.log();
    await runExec(buildCommand);
    console.log(
      `[${clc.green("")}] [${clc.blue(clc.bold(repoName))}] 构建成功`
    );
 
    // 5. 执行 `zip` `preview` `upload`;
  } catch (error) {
    console.log(
      `[${clc.red("")}] [${clc.blue(clc.bold(repoName))}] ${clc.red(
        clc.bold("请检查网络设置或者应用配置")
      )}`
    );
 
    console.log(error.toString().trim().split(/\r?\n/));
  }
}
build.js
 
   run() {
      // 5. 执行 `zip`;
 
+    // 拷贝输出文件至 process.cwd()
+    const fileName = getBuildName(repoPath)
 
+    console.log(
+      `[${clc.green('→')}] [${clc.blue(
+        clc.bold(repoName)
+      )}] 开始拷贝文件: ${fileName}`
+    )
 
+    await fse.copy(
+      path.join(repoPath, fileName),
+      path.join(process.cwd(), fileName)
+    )
 
+    // 移除工作目录
+    await fse.emptyDir(repoPath)
+    console.log(
+      `[${clc.green('✓')}] [${clc.blue(clc.bold(repoName))}] 拷贝完成`
+    )
   }
preview.js
+  const CI = require('./miniprogramCI')
 
   run() {
    // 5. 执行 `preview`;
 
+    new CI(workspace).preview({ qrcodeFormat: 'terminal' })
   }
upload.js
+  const CI = require('./miniprogramCI')
 
   run() {
    // 5. 执行 `preview`;
 
+    new CI(workspace).upload()
   }

miniprogramCI

cloneCommand installCommand buildCommand 结束后,便可得到 workspace

# 开发环境
~/.mp-repos/mp-example-repo/dev/build/**/*
# 测试环境
~/.mp-repos/mp-example-repo/test/build/**/*
# 预发布环境
~/.mp-repos/mp-example-repo/pre-release/build/**/*
# 生产环境
~/.mp-repos/mp-example-repo/master/build/**/*
~
 ┗ .mp-repos
 ┃ ┗ .mp-example-repo
 ┃ ┃ ┣ dev
 ┃ ┃ ┣ master
 ┃ ┃ ┣ pre-release
 ┃ ┃ ┗ test
miniprogramCI.js
const fse = require('fs-extra')
const path = require('path')
const clc = require('cli-color')
const { preview, Project, upload } = require('miniprogram-ci')
const Table = require('cli-table3')
const { getPackageName, getFormatFileSize } = require('./utils')
const logger = require('../lib/logger')
 
class MiniProgramCI {
  constructor(workspace) {
    this.workspace = workspace
 
    // 加载配置而后初始化项目对象
    // https://developers.weixin.qq.com/miniprogram/dev/devtools/ci.html#项目对象
    if (this.loadProjectConfig(workspace)) {
      this.project = new Project({
        appid: this.appid,
        type: this.compileType,
        projectPath: this.workspace,
        privateKeyPath: this.privateKeyPath
      })
    }
  }
 
  // 1. 加载 project.config.json 并初始化 appid compileType
  // 2. 加载 package.json 并初始化 version
  // 3. 获取 上传秘钥 路径
  loadProjectConfig(workspace) {
    const projectConfigPath = path.join(workspace, 'project.config.json')
    const packagePath = path.join(workspace, '../package.json')
 
    if (
      fse.pathExistsSync(projectConfigPath) &&
      fse.pathExistsSync(packagePath)
    ) {
      try {
        const {
          setting,
          appid,
          compileType,
          projectname
        } = fse.readJSONSync(projectConfigPath)
        const { version } = fse.readJSONSync(packagePath)
 
        this.appid = appid
        this.setting = setting
        this.compileType = compileType
        this.desc = decodeURIComponent(projectname)
        this.version = version
 
        const privateKeyPath = path.join(
          workspace,
          `../scripts/ci/keys/private.${appid}.key`
        )
 
        if (fse.pathExistsSync(privateKeyPath)) {
          this.privateKeyPath = privateKeyPath
 
          return true
        } else {
          console.log(
            `[${clc.red('')}] ${clc.red(clc.bold('上传秘钥不存在'))}`
          )
 
          return false
        }
      } catch (error) {
        console.log(`[${clc.red('')}] ${clc.red(clc.bold('读取文件失败'))}`)
 
        return false
      }
    } else {
      console.log(`[${clc.red('')}] ${clc.red(clc.bold('工程文件不存在'))}`)
 
      return false
    }
  }
 
  // 优化打印输出
  // https://developers.weixin.qq.com/miniprogram/dev/devtools/ci.html#返回
  printResult(result) {
    const { subPackageInfo = [], pluginInfo = [], devPluginId = '' } = result
 
    const table = new Table({
      head: ['时间', '版本号', '项目备注']
    })
 
    table.push([new Date().toLocaleString(), this.version, this.desc])
 
    console.log(table.toString())
 
    console.log('包信息')
 
    const packageTable = new Table({
      head: ['类型', '大小']
    })
 
    subPackageInfo.forEach(packageInfo => {
      const formatSize = getFormatFileSize(packageInfo.size)
 
      packageTable.push([
        getPackageName(packageInfo.name),
        formatSize.size + formatSize.measure
      ])
    })
 
    console.log(packageTable.toString())
 
    if (pluginInfo && pluginInfo.length) {
      console.log('插件信息')
 
      const pluginTable = new Table({
        head: ['appid', '版本', '大小', 'devPluginId']
      })
 
      pluginInfo.forEach(pluginInfo => {
        const formatSize = getFormatFileSize(pluginInfo.size)
 
        pluginTable.push([
          pluginInfo.pluginProviderAppid,
          pluginInfo.version,
          formatSize.size + formatSize.measure,
          devPluginId
        ])
      })
 
      console.log(pluginTable.toString())
    }
  }
 
  relsoveQrPath(qrcodeFormat, qrcodeOutputDest) {
    if (qrcodeFormat === 'base64' || qrcodeFormat === 'image') {
      return path.join(this.workspace, qrcodeOutputDest || 'preview.png')
    }
 
    return ''
  }
 
  // ci 机器人编号
  get robot() {
    return Math.floor(Math.random() * 31)
  }
 
  // miniprogram-ci upload
  // https://developers.weixin.qq.com/miniprogram/dev/devtools/ci.html#上传
  async upload() {
    if (this.project) {
      try {
        console.log(`[${clc.green('')}] 开始上传...`)
 
        const uploadResult = await upload({
          project: this.project,
          version: this.version,
          desc: this.desc,
          setting: this.setting,
          onProgressUpdate() {},
          robot: this.robot
        })
 
        console.log(`[${clc.green('')}] 上传成功`)
 
        this.printResult(uploadResult)
      } catch (error) {
        logger.fatal(error)
      }
    }
  }
 
  // miniprogram-ci preview
  // https://developers.weixin.qq.com/miniprogram/dev/devtools/ci.html#预览
  async preview(opts = {}) {
    const { qrcodeFormat = 'image', qrcodeDest } = opts
 
    if (this.project) {
      try {
        console.log(`[${clc.green('')}] 开始预览...`)
 
        const previewResult = await preview({
          project: this.project,
          version: this.version,
          desc: this.desc,
          setting: this.setting,
          qrcodeFormat,
          qrcodeOutputDest: this.relsoveQrPath(qrcodeFormat, qrcodeDest),
          onProgressUpdate() {},
          robot: this.robot
        })
 
        console.log(`[${clc.green('')}] 预览成功`)
 
        this.printResult(previewResult)
      } catch (error) {
        logger.fatal(error)
      }
    }
  }
}
 
module.exports = MiniProgramCI

ci-build

ci-preview

ci-upload