项目脚手架的开发

706 阅读7分钟

自己完成一个脚手架项目

项目痛点

  • 创建项目+通用代码(埋点、HTTP请求、工具方法、组件局)
  • git操作(创建仓库、代码冲突、远程代码同步、创建版本、发布打tag)
  • 构建+发布上线(依赖安装和构建、资源上传CDN、域名绑定、测试/正式服务器)

痛点分析

  • 创建项目/组件时,存在大量重复代码拷贝:希望能够快速复用已有沉淀
  • 协同开发时,由于git操作不规范,导致分支混乱,操作耗时:制定标准的git操作规范并集成到脚手架
  • 发不上线耗时,而去容易出现各种错误:指定标准的上线流程和规范并集成到脚手架

需求分析

  • 通用的研发脚手架
  • 通用的项目/组件创建能力
    • 模板支持定制,定制后能够发布生效
    • 模板支持快速接入,级低的接入成本
  • 通用的项目/组件发布能力
    • 发布过程自动完成标准的git操作
    • 发布成功后自动删除开发分支并创建tag
    • 发布后自动完成云构建、OSS上传、CDN上传、域名绑定
    • 发布过程支持测试/正式两种模式

技术方案设计

core模块技术方案

  • 准备阶段
  • 命令注册
  • 命令执行

core

  • prepare:检查版本号、检查node版本、检查root启动、检查用户主目录、检查入参、检查环境变量、检查是否为最新版本、提示更新
  • registerCommand:注册init命令、注册publish命令、注册clean命令、支持debug
  • execCommand:

核心库

  • import-local
  • commander

工具库

  • npmlog
  • fs-extra
  • semver
  • colors
  • user-home
  • dotenv
  • root-check

脚手架拆包策略:

  • 核心流程:core
  • 命令:
    • 初始化
    • 发布
    • 清除缓存
  • 模型层:models
    • Command命令
    • Project项目
    • Component组件
    • Npm模块
    • Git仓库
  • 支撑模块:utils
    • Git操作
    • 云构建
    • 工具方法
    • API请求
    • Git API
  • 拆分原则(根据模块的功能拆分)
    • 核心模块
    • 命令模块
    • 模型模块
    • 工具模块

core模块的搭建

搭建core/cli脚手架

脚手架命令:rainbow-cli

  • lerna.json
{
  "packages": ["core/*", "commands/*", "models/*", "utils/*"],
  "version": "0.0.0"
}

core/cli 脚手架的包

  • npm i npmlog 处理日志打印
  • npm i import-local 检查如果本地的node_modules有脚手架的包则直接使用本地的,没有才使用全局的

遇到报错:core/core/node_modules没有写入权限

解决:把node_modules删掉

image.png

image.png

定义项目目录结构

  • core
    • cli(脚手架项目)
      • bin
      • lib
  • utils(工具类包)
    • utils
    • lib
  • command
  • models image.png

调试到能正常运行:

image.png

  • 使用import-local

core/cli/bin/index.js文件:测试import-local

#!/usr/bin/env node
"use strict";

const importLocal = require("import-local");

if (importLocal(__filename)) {
  require("npmlog").info("cli", "正在使用rainbow-cli本地的脚手架~");
} else {
  require("../lib")(); //正常是执行了else里面的语句
}

image.png

测试rainbow-cli脚手架项目的import_local库是否生效:

  • rainbow-lerna-cli最外层npm i @rainbow-lerna-cli/core
  • 执行了import-local里面的内容,说明生效了

image.png

新建utils/log模块: 用户定义日志输出类型

  • 模块名称:@rainbow-lerna-cli/log

新建lerna create log utils

info cli using local version of lerna
lerna notice cli v4.0.0
package name: (log) @rainbow-lerna-cli/log
version: (1.1.2) 0.0.1
description: @rainbow-lerna-cli log
keywords: log
homepage: (https://gitee.com/rainbowdiary/rainbow-lerna-cli/utils/log) 
license: (ISC) 
entry point: (lib/log.js) lib/index.js
git repository: (https://gitee.com/rainbowdiary/rainbow-lerna-cli) 
About to write to /Users/rainbow/Documents/前端/脚手架开发/rainbow-lerna-cli/utils/log/package.json:

{
  "name": "@rainbow-lerna-cli/log",
  "version": "0.0.1",
  "description": "@rainbow-lerna-cli log",
  "keywords": [
    "log"
  ],
  "author": "rainbowdiary <zengcai666@gmail.com>",
  "homepage": "https://gitee.com/rainbowdiary/rainbow-lerna-cli/utils/log",
  "license": "ISC",
  "main": "lib/index.js",
  "directories": {
    "lib": "lib",
    "test": "__tests__"
  },
  "files": [
    "lib"
  ],
  "publishConfig": {
    "registry": "https://registry.npmjs.org"
  },
  "repository": {
    "type": "git",
    "url": "https://gitee.com/rainbowdiary/rainbow-lerna-cli"
  },
  "scripts": {
    "test": "echo \"Error: run tests from root\" && exit 1"
  }
}


Is this OK? (yes) yes
lerna success create New package @rainbow-lerna-cli/log created at ./../../utils/log
  • lerna exec --scope @rainbow-lerna-cli/log npm i npmlog

npmlog库的基础上自定义日志级别

image.png

添加core/cli/package.json本地依赖@rainbow-lerna-cli/log

  • lerna link
//完成本地`@rainbow-lerna-cli/log`的调试
 "dependencies": {
    "@rainbow-lerna-cli/log": "^0.0.1",
  }

image.png

根据环境变量自定义log等级

npmlog库的功能:(查看npmlog源码或文档)

  • log.level
    • log.level定义log的等级。默认是info,info等级值之前的都不会生效
    • 使用process.env.LOG_LEVEL 可以判断环境变量中的debug模式 - log.addLevel('success',2000,{fg: 'green', bold: true}) 自定义log
  • log.headingStyle log加上前缀

utils/log/lib/index.js

"use strict";

const log = require("npmlog");

module.exports = log;

log.level = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : "info"; //根据环境变量定义log等级
log.addLevel("success", 2000, { fg: "green", bold: true }); //自定义log等级
log.heading = "rainbow"; //添加log前缀
log.headingStyle = { fg: "blue", bg: "black" }; //log前缀样式

image.png

本地调试依赖的写法

不然后面npm i会报错

"dependencies": {
    "@rainbow-lerna-cli/log": "file:../../utils/log",
    "colors": "^1.4.0",
    "import-local": "^3.0.3",
    "npmlog": "^5.0.1",
    "root-check": "^2.0.0",
    "semver": "^7.3.5"
  }

检查版本号: checkPkgVersion

思路:拿到pakcage.json文件中的当前的version,和最低版本号lowestPkgVersion进行对比。如果当前版本号低于lowestPkgVersion则提示安装。

function checkPkgVersion() {
  const currentPkgVersion = pkgVersion.version;
  const lowestPkgVersion = constants.LOWEST_PKG_VERSION;
  // 版本大于等于
  if (!semver.gte(currentPkgVersion, lowestPkgVersion)) {
    throw new Error(
      `rainbow-cli suggest version higher than v${lowestPkgVersion}, Please update your version of rainbow-cli`
    );
  } else {
    log.info("cli", currentPkgVersion);
  }
}

定义core/cli/const.js文件,定义lowestPkgVersion:

module.exports = {
  LOWEST_PKG_VERSION: "1.0.0",
};
  • 小知识点: require支持什么文件
    • .js/.json/.node
    • .json文件,会JSON.parse输出
    • .js文件则直接加载
    • .其他文件,如果是js的代码,也是默认使用js解析器进行解析
      • 如果不是js代码则会报错

检查node版本: checkNodeVersion

  • 使用semver
    • lerna add semver --scope @rainbow-lerna-cli/core
  • colors库
    • 打印不同颜色
// 检查node版本号
function checkNodeVersion() {
 const currentVersion = process.version;
 const lowestVersion = constants.LOWEST_VERSION;
   if (!semver.gte(currentVersion, lowestVersion)) {
     throw new Error(
       colors.red(
         `rainbow-cli requires Node higher than v${lowestVersion}, Please update your version of Node`
       )
     );
   }
 } 
}

查看终端效果: image.png

看下lerna报错提示时库的使用:

image.png

检查root版本: checkRoot

  • root-check@v1.0.0
function checkRoot() {
  //如果一个文件是root账户创建的,那么普通用户是无法操作的,所以会报错各种权限问题
  //修改uid,账户降级
  const rootCheck = require("root-check");
  rootCheck();
}

image.png

使用root-check之后使用sudo则会被降级处理 image.png

检查用户主目录: checkUserHome

  • user-home@v2.0.0
  • path-exists 直接调用会返回用户目录路径,需要判断这个路径是否存在


// 检查用户主目录
function checkUserHome() {
  const userHome = require("user-home");
  const pathExists = require("path-exists").sync;
  if (!userHome || pathExists(userHome)) {
    throw new Error(colors.red("当前登录用户主目录不存在!"));
  }
}

检查入参: checkInputArgv

  • minimist:拿到所有的argv;存入变量argvs 查看是否为debug模式rainbow-cli --debug
let argvs = {};
// 检查入参
function checkInputArgv() {
  const minimist = require("minimist");
  argvs = minimist(process.argv.slice(2));
}

检查是否有debug参数

// 如果为debug模式则设置log等级为verb
function checkArgv() {
  if (argvs.debug) {
    process.env.LOG_LEVEL = "verbose";
  } else {
    process.env.LOG_LEVEL = "info";
  }
  log.level = process.env.LOG_LEVEL;
  // log.verbose("debug", "test debug"); //debug日志就可以打印出来了
}

检查变量: checkEnv

  • dotenv
  • 将环境变量所在文件设置为用户主目录 userHome+.rainbow-cli-env
/**
 * 定义常量
 */
module.exports = {
  LOWEST_VERSION: "12.0.0",
  LOWEST_PKG_VERSION: "1.0.0",
  DEFAULT_CLI_HOME: ".rainbow-cli-env",
};

// 检查环境变量有哪些配置
function checkEnv() {
  // 默认查看当前目录下的.env文件
  const dotenv = require("dotenv");
  const dotEnvPath = path.resolve(userHome, ".env");
  let config = {};
  if (pathExists(dotEnvPath)) {
    dotenv.config({
      path: dotEnvPath,
    });
  }
  config = createDefaultCliPath();
  log.verbose("环境变量", process.env.CLI_HOME_PATH);
}

function createDefaultCliPath() {
  const cliConfig = {
    home: userHome,
  };
  if (process.env.CLI_HOME) {
    cliConfig["cliHome"] = path.join(userHome, rocess.env.CLI_HOME);
  } else {
    cliConfig["cliHome"] = path.join(userHome, DEFAULT_CLI_HOME);
  }
  process.env.CLI_HOME_PATH = cliConfig.cliHome;
}

检查版本更新: checkGlobalUpdate

  • utils/npm-info 封装npm信息获取方法getNpmSemverVersions
  • 如果当前脚手架在npm平台发布了新版本,但是用户用的仍然是旧版本,需要提示更新新版本
async function checkGlobalUpdate() {
  //使用npm接口拿到所有的版本号 https://registry.npmjs.org/@rainbow-lerna-cli/core
  // 比对版本号,提示更新最新的版本号
  const npmName = pkgVersion.name;
  const currentPkgVersion = pkgVersion.version;
  const lastVersion = await getNpmSemverVersions(currentPkgVersion, npmName);
  if (lastVersion && semver.gt(lastVersion, currentPkgVersion)) {
    log.warn(
      colors.yellow(
        `请手动更新 ${npmName},当前版本:${currentPkgVersion}, 最新版本:${lastVersion}
        更新命令: npm install -g @rainbow-lerna-cli/core
        `
      )
    );
  }
}

image.png

脚手架命令注册

yargs/commander

node支持ES Module

如何让node支持ES MODULE

模块化

  • CMD/AMD/require.js
  • CommonJS
    • 加载: require()
    • 输出module.exports / exports.x
  • ES Module
    • 加载:import
    • 输出:export default / export function

模块化方案

commonJs nodejs实现了commonJs规范

  • 特点
  • 一个文件就是一个模块,拥有独立作用域
  • 提供require引入模块,提供export导出模块属性方法,exports代表模块本身
  • 暴露和加载模块
    • module.exports和exports
    • require(),用于加载模块

AMD Asynchronous Modeule Definition 异步模块加载机制

  • 代表有RequireJs

  • 特点

    • 所有模块都进异步加载,模块加载不影响后面语句运行
    • 所有依赖某些模块的语句均放在回调函数中
    • 提供全局define函数定义模块,require引入模块,export导出模块
    • define([module-name?],[array-of-dependencies?],[module-factory-or-object]); 其中: module-name: 模块标识,可以省略 array-of-dependencies: 所依赖的模块,可以省略 module-factory-or-object: 模块的实现,或者一个js对象
  • define是异步的

    • 首先会异步的调用第二个参数中列出的依赖模块
    • 所有模块被载入完成后,如果第三个参数是一个回调函数则执行

CMD规范 Common Module Definition通用模块定义

  • seajs
  • define(function(require,exports,module){...})
    • factory是一个函数,提供equire, exports, module三个参数:     1、require 是一个方法,接受模块标识作为唯一参数,用来获取其他模块提供的接口:require(id)     2、exports 是一个对象,用来向外提供模块接口     3、module 是一个对象,上面存储了与当前模块相关联的一些属性和方法

ES6 import

rainbow-test项目中新建lib/utils.js

//使用nodeJS的方式输出
module.exports = function (){
  console.log('hello utils');
}
  • 新建项目执行文件index.js
  • 正常只能使用CommonJS的方式加载
#!/usr/bin/env node
const utils = require('./lib/utils');
utils();

但是希望支持ES MODULE如果不支持则会报错

#!/usr/bin/env node

import utils from './lib/utils';
utils();

image.png

所以需要将下面这段代码给webpack编译成node能识别的代码

import utils from './lib/utils';
utils();

方法1:使用webpack实现

将ES MODULE使用import方式加载的代码,转换为webpack打包后的代码,webpack编译后能让低版本的node也能识别编译后的代码

  • npm i webpack webpack-cli
  • webpack.config.js
const path = require("path")
module.exports = {
  entry: "./bin/core.js",
  output: {
    path: path.join(__dirname, "/dist"),
    filename: "core.js",
  },
  mode: "development",
};

core.js

import utils from '../lib/utils';
utils();

bin/index.js

#!/usr/bin/env node
require("../dist/core")

package.json

 "bin": {
    "rainbow-test": "bin/index.js"
  },
 "scripts": {
    "build": "webpack",
    "dev": "webpack -w"
  }

问题

问题1:webpack无法编译nodeJs内置的方法。 image.png 解决: 修改webpack.config.js中的target配置,默认是‘web’ image.png 问题2:复杂代码无法编译,比如async代码,webpack编译后,低版本node无法识别 image.png 查看dist/core.jsasync相关代码都没有被编译 image.png **解决:**使用babel-loader npm i -D babel-loader @babel/core @babel/preset-env

image.png webpack.config.js

module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env"],
          },
        },
      },
    ],
  },

重新npm run build查看dist/core.js 已经将async进行babel处理了。

但是运行还是报错:ReferenceError: regeneratorRuntime is not defined image.png

原因:编译async await形成的代码,是以垫片的形式加入,垫片代码没有引入 npm i -D @babel/plugin-transform-runtime

  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env"],
            plugins: ["@babel/plugin-transform-runtime"],
          },
        },
      },
    ]
  }

重新编译后:一直报错: image.png

原因: 一直以为是webpack配置问题,最后查明是有行代码没有使用分号结尾。

import path from 'path'; //使用node内置方法
import {exists} from '../lib/utils';

// 加上这行就报错: TypeError: _lib_utils__WEBPACK_IMPORTED_MODULE_5___default(...).exists(...) is not a function
utils.exists(path.resolve('./')) 
(async ()=>{
  await new Promise((resolve) => setTimeout(resolve, 1000));
  console.log('ok');
})()

image.png

原因: JS解析问题。 通常,如果语句以(、[、 /、+、-、`开头时,就有可能被解释为上一行语句的一部分。 所以webpack编译的时候也将他们编译成为一体。

// 看下webpack解析结果
(0,_lib_utils__WEBPACK_IMPORTED_MODULE_6__.exists)(path__WEBPACK_IMPORTED_MODULE_5___default().resolve('./'))( /*#__PURE__*/(0,_babel_runtime_corejs3_helpers_esm_asyncToGenerator__WEBPACK_IMPORTED_MODULE_0__[\"default\"])

// 报错:
TypeError: (0 , _lib_utils__WEBPACK_IMPORTED_MODULE_6__.exists)(...) is not a function

举个🌰:以下代码会被解析为let hey = "hey"["liu", "liuxing"].forEach(console.log)

let hey = "hey"
["liu", "liuxing"].forEach(console.log);

//webpack解析报错:
TypeError: Cannot read property 'forEach' of undefined

// 看下webpack解析结果:
(_context2 = \"hey\"[(\"liu\", \"liuxing\")]).call(_context2, console.log);

方法2:使用NodeJS原生方法实现

  • .js文件改为.mjs文件
  • 全部代码使用ES Module的方式加载和输出
  • node14版本以下使用node --experimental-modules bin/index.mjs运行
    • node14版本及以上已经支持,可以正常使用node bin/index.mjs运行

    新建core/exec: 动态加载

@rainbow-lerna-cli/exec

新建models/package: 动态加载

@rainbow-lerna-cli/package 提供给exec使用 提供一个实例化一个pakcage的类

class Package {
// 获取Pakcage最新版本
// 检查Package是否存在
// 安装Package
// Package更新
// 获取Pakcage入口文件
}

utils/format-path

识别Package的入口文件路径,兼容mac和windows系统

新建module/command

创建子命令init的父类Command