简单但有用的微信小程序脚手架

1,803 阅读8分钟

如题,这是一个简单但有用的微信小程序脚手架。主要作用是在命令行上传代码、打印预览二维码、切换环境、创建页面或组件。

npm 包地址。写这篇文章的时候,包的版本是0.0.10

主要使用微信小程序提供的命令行调用方法,再结合实际需求,封装了一些命令行调用方式。

使用方法

安装

(1)全局安装:

npm install sparrow-miniapp-cli -g

(2)也可以只在项目中安装:

npm install sparrow-miniapp-cli --save-dev

package.jsonscripts属性下加上:

  "scripts": {
    ...
    "cli": "cli "
  },

在使用的时候不能像全局安装那样直接使用cli了,需要在指令前面加上npx。比如,全局安装时上传代码用的是cli -u,只在项目中安装时需要使用npx cli -u

使用

1.使用命令行工具的前提条件是,在微信开发者工具中把服务端口打开。

设置 -> 安全设置 -> 服务端口

2.执行cli指令初始化之后,会生成一个cli.js文件,文件内容是这样的:

module.exports = {
  "environment": "DEV",
  "version": "版本号",
  "description": "描述文本",
  "devToolPath": "微信开发者工具的命令行工具的安装路径",
  "projectRoot": "电脑里面项目的路径",
  "needCheckNpmPackages": ["需要检查的包的名字"]
};

在项目代码中引入这个文件的数据,主要是前三个属性,来进行一些处理。

3.指令使用方式:cli <command>

<command> 是以下选项中的一个:

选项简写作用
--upload-u上传代码
--preview-p预览
--switch-s切换环境
--create <type>-c创建页面模板或组件模板
<type>的值是pagecomponent
--build-npm-bnpm构建
--login-l登录开发者工具
--open-o打开开发者工具
--quit-q关掉开发者工具
--close-cl关闭项目窗口
--help-h描述如何使用

比如,上传代码:

cli --upload

4.注意:

  • node版本需大于等于v14.9.0。

因为我本地的node版本是v14.9.0,并且这个脚手架主要只是小程序端小组内几个成员用的,所以简单粗暴地直接写上node版本大于等于v14.9.0,不去处理使用其他版本的node可能会出现的问题。

具体实现

流程图如下:

主函数main.js:

#!/usr/bin/env node

const { program } = require('commander');

const WeCli = require('./WeCli');

// 设置命令行的选项
program
  .option('-u, --upload', '上传代码')
  .option('-p, --preview', '预览')
  .option('-s, --switch', '切换环境')
  .option('-c, --create <type>', '创建页面模板或组件模板')
  .option('-b, --build-npm', 'npm构建')
  .option('-l, --login', '登录开发者工具')
  .option('-o, --open', '打开开发者工具')
  .option('-q, --quit', '关掉开发者工具')
  .option('-cl, --close', '关闭项目窗口')
  .option('-h, --help', '描述如何使用')

program.parse(process.argv); // 处理传入的参数

main();

async function main () {
  const weCli = new WeCli();

  // 等待实例初始化完成
  await weCli.init();

  switch (true) {
    case Boolean(program.upload): // 上传
      weCli.upload();
      break;
    case Boolean(program.preview): // 预览
      weCli.preview();
      break;
    case Boolean(program.switch): // 切换项目环境
      weCli.switch();
      return;
    case Boolean(program.create): // 创建模板
      const templateType = program.create;
      weCli.create(templateType);
      break;
    case Boolean(program.buildNpm): // npm构建
      weCli.buildNpm();
      break;
    case Boolean(program.close): // 关闭项目窗口
      weCli.close();
      break;
    case Boolean(program.login): // 登录开发者工具
      weCli.login();
      break;
    case Boolean(program.open): // 打开开发者工具
      weCli.open();
      break;
    case Boolean(program.quit): // 关掉开发者工具
      weCli.quit();
      break;
    case Boolean(program.help): // 查看帮助
      weCli.help();
      break;
    default: // 默认是查看帮助
      weCli.help();
      break;
  }
}

以下语句表示使用node来执行此文件。

#!/usr/bin/env node

设置命令行的指令选项,在执行不同的选项的时候,进行不同的处理。具体看代码中注释。

最主要的是前四个指令选项,其他的基本上就是从微信小程序提供的命令行调用方法中直接搬运过来的。这篇文章只说初始化和前四个方法,即上传、预览、切换项目环境、创建模板。

初始化

  1. init方法
async init () {
  this.cliFileExisted = await this.checkCliFileCreated();
  if (!this.cliFileExisted) { // 没有创建cli.js文件,就需要创建
    this.version = this.createVersion(); // 初始化版本号
    const createCliFileSuccess = await this.createCliFile();
    if (!createCliFileSuccess) return; // cli.js文件没有创建成功直接返回

    const installSuccess = await this.installPackages(); // 安装依赖
    if (!installSuccess) return; // 依赖没有安装成功直接返回

    const buildSuccess = await this.buildNpm(); // 构建npm包
    if (!buildSuccess) return; // 没有构建成功直接返回

    this.printSuccess('初始化完成');
  } else { // 已经初始化过,直接读取cli.js文件中的数据
    this.readCliFile();
  }
}

如果cli.js文件不存在,就创建cli.js文件,文件中版本号是createVersion方法创建的版本号。

  1. createCliFile方法

向用户提问,根据用户的回答创建cli.js文件:

// 创建cli.js文件
async createCliFile () {
  const configInfo = await this.askUsersInitQuestions();
  if (!configInfo) return;
  await this.writeCliFile(configInfo);
  return true;
}
  1. 提问用户的问题askUsersInitQuestions

其中微信开发者工具的命令行工具的安装路径默认为安装在mac应用程序中的路径,'/Applications/wechatwebdevtools.app/Contents/MacOS/cli';项目根路径默认为指令执行时的路径。

// 提问用户初始化相关的问题
async askUsersInitQuestions () {
  return new Promise((resolve) => {
    inquirer
    .prompt([
      {
        name: 'devToolPath',
        type: 'input',
        default: this.devToolPath,
        message: '微信开发者工具的命令行工具的安装路径:'
      },
      {
        name: 'environment',
        type: 'list',
        default: commonConstants.environment.DEV,
        message: '项目环境:',
        choices: [commonConstants.environment.DEV, commonConstants.environment.PRO],
      },
      {
        name: 'version',
        type: 'input',
        default: this.version,
        message: '项目版本:',
      },
      {
        name: 'description',
        type: 'input',
        default: this.description,
        message: '项目描述:',
      },
      {
        name: 'projectRoot',
        type: 'input',
        default: this.projectRoot,
        message: '项目根路径:'
      },
      {
        name: 'needCheckNpmPackages',
        type: 'input',
        default: '',
        message: '需要检查更新的组件(以,分隔):',
      },
    ])
    .then(answers => {
      const { devToolPath, projectRoot } = answers;
      if (!devToolPath || !projectRoot) {
        console.log(chalk.red('初始化失败'), `${chalk.yellow('微信开发者工具的命令行工具的安装路径')}${chalk.yellow('项目根路径')}是必填项!`);
        resolve(false);
        return;
      }
      resolve(answers);
    })
    .catch(error => {
      console.log(`回答初始化相关问题中出现的错误,error:`, error);
      resolve(false);
    })
  });
}

创建好文件之后,执行npm install安装依赖,并且使用微信开发者工具命令行指令进行npm构建。

  1. 安装依赖installPackages
// 安装依赖
async installPackages (packageNames = []) {
  return new Promise ((resolve) => {
    const installPackagesOra = ora('npm install 安装依赖中...').start();
    // 如果package.json文件存在就安装依赖
    if (fs.existsSync(this.packageJSONPath)) {
      const npmInstall = spawnSync(
        'npm', 
        ['install', ...packageNames],
        {stdio: 'inherit'}
      );
      const { status } = npmInstall;
      const installSuccess = status === 0;  // 根据实验,status为0的时候安装成功,status为1的时候安装失败
      installPackagesOra.stop();
      if (installSuccess) {
        this.printSuccess('依赖安装成功');
      } else {
        this.printFail('依赖安装失败');
      }
      resolve(installSuccess);
    } else {
      installPackagesOra.stop();
      console.log(logSymbols.warning, chalk.red('此项目没有package.json文件'));
      resolve(false);
    }
  });
}
  1. npm构建:
// npm构建
async buildNpm () {
  return new Promise((resolve) => {
    const buildNpmOra = ora('npm构建中...').start();
    spawnSync(
      this.devToolPath, 
      ['build-npm', '--project', this.projectRoot],
      {stdio: 'inherit'}
    );
    // const buildNpm = spawnSync(
    //   this.devToolPath, 
    //   ['build-npm', '--project', this.projectRoot],
    //   {stdio: 'inherit'}
    // );
    // const { status } = buildNpm;
    // const buildNpmSuccess = status === 0; // 这个地方不能通过status === 0来判断是否构建成功,所以只有“npm 构建完成”
    buildNpmOra.stop();
    this.printSuccess('npm 构建完成');
    resolve(true);
  });
}
  1. 如果cli.js文件已经存在,直接读取文件信息对类实例属性赋值

readCliFile

// 读取cli.js文件的信息
readCliFile () {
  const cliFileData = require(this.cliFilePath);
  this.setConfigInfo(cliFileData);
}

setConfigInfo

// 设置实例的配置信息
setConfigInfo (data = {}) {
  const {
    environment,
    version,
    description,
    devToolPath,
    projectRoot,
    needCheckNpmPackages
  } = data;

  // 给相应属性赋值
  this.environment = environment;
  this.version = version;
  this.description = description;
  this.devToolPath = devToolPath;
  this.projectRoot = projectRoot;
  this.needCheckNpmPackages = needCheckNpmPackages;
}

上传

在初始化的时候我们设置了需要被检查的包的名字,在上传、预览之前,都需要检查package.json中,这些包的版本和实际本地安装的版本是否一致,如果不一致的话,就要对不一致的包进行重新安装,安装好之后再npm构建。重新安装和构建成功之后再执行微信小程序提供的upload命令对代码进行上传。

注意: 这个检查是非常重要的,假设有一个npm包名为npm-package,A将这个包的版本更新为1.0.2,B从远程拉下代码合到本地了,package.json中这个包的版本也变为了1.0.2,但B没有注意到这个版本变更,所以还是用的1.0.0版本的npm包以及npm包构建后的代码,检查下自己写的项目没有问题于是发布线上了,导致A写的部分因为npm-package的版本是1.0.0出现了问题。为了避免上述情况发生,需要进行这个检查。

upload

// 上传代码
async upload () {
  const installAndBuildSuccess = await this.installAndBuild(); // 检查安装的版本和package.json文件中的版本是否一致,不一致的话要重新安装和构建
  if (!installAndBuildSuccess) return;
  const answers = await this.showSimpleCliConfig();
  if (!answers) return;
  spawnSync(
    this.devToolPath, 
    ['upload', '--project', this.projectRoot, '-v', this.version,  '-d', this.description],
    {stdio: 'inherit'}
  );
  console.log(`上传成功后可至微信小程序管理后台${chalk.blue('https://mp.weixin.qq.com/wxamp/wacodepage/getcodepage')}将上传的版本选为体验版`);
}

检查package.json中包的版本和实际安装的版本是否一致,不一致重新进行安装和构建:

// 安装和进行npm构建
async installAndBuild () {
  const checkedPackagesCount = this.needCheckNpmPackages.length;
  if (checkedPackagesCount === 0) return true; // 没有设置要检查的包
  const checkConsistentSpinner = ora('检查package.json文件中包的版本与实际安装的版本是否一致...').start();
  const inconsistentPackages = await this.checkNpmVersionConsistent(); // 检查npm包的版本是否一致
  checkConsistentSpinner.stop();
  if (inconsistentPackages.length < 1) {
    console.log('package.json文件中包的版本与实际安装的版本一致', chalk.magenta('不必重新构建'));
    return true;
  }

  const inconsistentPackagesStr = inconsistentPackages.join(',');

  // package.json中包的版本和实际安装的版本不一致时,重新进行npm install 和 npm 构建
  console.log(`package.json文件中`, chalk.magenta(`${inconsistentPackagesStr}`), `包的版本与实际安装的版本不同,`, chalk.magenta(`重新进行npm安装和npm构建中,请稍候...`));
  const installSuccess = await this.installPackages(inconsistentPackages);
  if (!installSuccess) return; // 依赖没有安装成功直接返回
  const buildNpmSuccess = await this.buildNpm();
  return buildNpmSuccess;
}

package.json中拿到包的版本,再根据指令npm ls packageName拿到的本地安装的包版本号作对比:

// 检查npm包的版本是否一直
checkNpmVersionConsistent () {
  return new Promise((resolve) => {
    const checkedPackagesCount = this.needCheckNpmPackages.length;

    const packageJSONUrl = path.join(this.projectRoot, '/package.json');
    const packageJSON = require(packageJSONUrl);

    let inconsistentPackages = []; // 版本不一致的包
    let count = 0;

    if (checkedPackagesCount < 1) {
      resolve(inconsistentPackages);
      return;
    }

    for (let i = 0; i < checkedPackagesCount; i++) {
      const packageName = this.needCheckNpmPackages[i];
      if (packageName) {
        let installedPackageVersion = spawnSync('npm', ['ls', packageName]); // 检查这个包本地安装的版本
        installedPackageVersion = installedPackageVersion.stdout.toString().trim();
        const packageNameIndex = installedPackageVersion.indexOf(packageName);
        if (packageNameIndex === -1) {
          installedPackageVersion = '';
        } else {
          installedPackageVersion = installedPackageVersion.slice(packageNameIndex + packageName.length + 1);
        }

        const packageVersionInPackageJSON = packageJSON.dependencies[packageName]; // 本地package.json文件中包的版本
        let packageLocalVersion = packageVersionInPackageJSON;
        if (packageVersionInPackageJSON.indexOf('^') > -1) { // 如果版本号包含^,需要把^去掉
          packageLocalVersion = packageVersionInPackageJSON.slice(1);
        }

        if (installedPackageVersion !== packageLocalVersion) {
          inconsistentPackages.push(packageName + `@${packageLocalVersion}`);
        }
      }
      count++;
      if (count === checkedPackagesCount) {
        resolve(inconsistentPackages);
      }
    }
  });
}

比如,执行npm ls ora打印出本地安装的ora包的信息是这样的:

预览

预览的时候可以切换环境为DEVPRO。和上传前一样,都需要进行包的版本的检查。

// 预览
async preview () {
  const installAndBuildSuccess = await this.installAndBuild(); // 检查安装的版本和package.json文件中的版本是否一致,不一致的话要重新安装和构建
  if (!installAndBuildSuccess) return;
  const answers = await this.showPreviewSimpleCliConfig();
  if (!answers) return;
  spawnSync(
    this.devToolPath, 
    ['preview', '--project', this.projectRoot],
    {stdio: 'inherit'},
  );
}

切换环境

简单地根据用户选择的内容重新设置下cli.js文件导出的对象的environment的值,以及WeCli类的实例的environment属性的值。

// 切换项目环境
switch () {
  inquirer
  .prompt([
    {
      name: 'environment',
      type: 'list',
      default: this.environment,
      message: '项目环境:',
      choices: [commonConstants.environment.DEV, commonConstants.environment.PRO],
    },
  ])
  .then(async (answers) => {
    const { environment } = answers;
    const writeFileSuccess = await this.writeCliFile(answers);
    if (writeFileSuccess) {
      this.printSuccess(`环境已成功切换为${environment}`);
    }
  })
  .catch(error => {
    this.printFail(`${error}`);
  });
}

创建模板

首先写好模板文件,在创建页面/组件的时候,将其复制到相应的文件位置就可以了。

模板文件目录如下:

└── templates
    ├── component
    │   ├── component.js
    │   ├── component.json
    │   ├── component.wxml
    │   └── component.wxss
    └── page
        ├── page.js
        ├── page.json
        ├── page.wxml
        └── page.wxss

将模板文件复制到指定的位置:

// 创建模板
create(templateType = 'component') {
  let defaultDestinationRelativePath = 'components/'; // 默认的目标文件的放置的相对路径
  let sourcePath = path.join(__dirname, 'templates/component/component');

  if (templateType === 'page') {
    defaultDestinationRelativePath = 'pages/';
    sourcePath = path.join(__dirname, 'templates/page/page');
  }

  const TypeNameMap = {
    component: '组件',
    page: '页面',
  };

  const typeName = TypeNameMap[templateType];

  inquirer
  .prompt([
    {
      name: 'name',
      type: 'input',
      default: '',
      message: `${typeName}名称:`,
    },
    {
      name: 'location',
      type: 'input',
      default: defaultDestinationRelativePath,
      message: `${typeName}位置:`,
      suffix: '(相对项目根目录,且路径以/结尾)',
    },
  ])
  .then(answers => {
    const {
      name,
      location,
    } = answers;

    if (!name) {
      this.printWarnning('没有输入名称,请重新执行指令');
      return;
    }
    if (!location) {
      this.printWarnning('没有模板放置的位置,请重新执行指令');
      return;
    }

    let sourceArr = [ sourcePath + '.js', sourcePath + '.json', sourcePath + '.wxml', sourcePath + '.wxss' ];
    let destinationFilePath = `${location}${name}/`;
    let destinationPath = path.join(this.projectRoot, destinationFilePath);
    let destinationArr = [ destinationPath + name + '.js', destinationPath + name + '.json', destinationPath + name + '.wxml', destinationPath + name + '.wxss'];

    // 目标文件夹不存在的时候创建文件夹
    const fileExisted = fs.existsSync(destinationPath);
    if (!fileExisted) {
      fs.mkdirSync(destinationPath, { recursive: true }, (err) => {
        if (err) throw err;
      });
    }

    // 复制文件到文件夹中
    sourceArr.forEach((item, index) => {
      const source = item;
      const destination = destinationArr[index];
      this.copyTemplateFile(source, destination);
    });
  })
  .catch(error => {
    this.printFail(`${error}`);
  });
}

问题

  1. 使用如下方式执行预览指令时,输出的预览二维码是半个(奇怪的是同样的代码偶尔能输出整个二维码):
  const preview = spawn(
    this.devToolPath, 
    ['preview', '--project', this.projectRoot]
    );
  let stdout = '';
  preview.stdout.on('data', (data) => {
    stdout += data.toString();
  });
  preview.stderr.on('data', (data) => {
    // stdout += data.toString();
  });
  preview.stdout.on('close', (code) => {
    console.log(stdout);
  });

问了邦邦老师这个问题后,邦邦老师找到了解决方法:使用{stdio: 'inherit'}直接将子进程的输出打印到命令行。

spawnSync(
  this.devToolPath, 
  ['preview', '--project', this.projectRoot],
  {stdio: 'inherit'},
);

虽然这种方法无法拿到子进程输出的数据,但实际上目前也没办法根据执行微信开发者工具提供的指令输出的数据进行一些处理(比如不能通过close时的code0判断预览成功),所以本身就不需要拿数据的。

  1. 执行上传指令的时候,将上传代码选为体验版,扫描体验版二维码的时候,偶尔会出现打开是一片白的情况。

遇到这种情况退出微信开发者工具(注意是退出微信开发者工具,不是退出项目),重新打开,再执行一次指令就能正常上传了。