前端工程化-制作一个公司的脚手架

成效

demo.gif

引言

随着前端工程化的理念不断深入,越来越多的人选择使用脚手架从零到一搭建自己的项目。其中大家最熟悉的就是create-react-appvue-cli,它们可以帮助我们初始化配置生成项目结构、自动安装依赖,最后我们一行指令可运行项目开始开发,或者进行项目构建(build)。

这些脚手架提供的都是普遍意义上的最佳实践,但是实际开发中发现,随着业务的不断发展,必然会出现需要针对业务开发的实际情况来进行调整。例如:

  • 通过调整插件与配置实现Webpack打包性能优化后

  • 项目架构调整

  • 编码风格

  • 用户权限控制

  • 融合公司的基建

总而言之,随着业务发展,我们往往会沉淀出一套更“个性化”的业务方案。这时候我们最直接的做法就是开发出一个该方案的脚手架来,以便今后能复用这些最佳实践与方案。

1. 脚手架怎么工作?

功能丰富程度不同的脚手架,复杂程度自然也不太一样。但是总体来说,脚手架的工作大体都会包含几个步骤:

  • 初始化,一般在这个时候会进行环境的初始化,做一些前置的检查,如版本更新。
  • 用户输入,进行交互,例如用vue-cli的时候,它会“问”你很多的配置选项
  • 生成模板
  • 生成配置文件
  • 安装依赖
  • 清理,校验等收尾工作

在企业中一般我们只是想轻量级,快速的创建一个特定场景的脚手架(可能并不需要像vue-cli那么完备)。所有下面将演示制作的基本流程,从制作简单的demo调试,到复杂的功能开发。

2.脚手架整体架构设计与基本流程

前端脚手架.png

基本流程

image.png

3. 开发脚手架我们需要用到的三方库

库名 描述
commander 处理控制台命令
chalk 五彩斑斓的控制台
latest-version 活动最新的npm包
inquirer 控制台询问
download-git-repo git远程仓库拉取
figlet 粉笔字
glob 匹配指定路径文件
ora 命令行环境的loading效果
clear 清除控制台输出的信息
log-symbols 各种日记级别的彩色符号
metalsmith 处理模板

4.项目实战

  • 目录结构
    |-- .gitignore
    |-- .npmignore
    |-- package-lock.json
    |-- package.json
    |-- README.md
    |-- bin
    |   |-- cunw
    |   |-- cunw-init
    |   |-- cunw-list
    |-- conf
    |   |-- const.js
    |   |-- index.js
    |   |-- template.json
    |-- lib
    |   |-- download.js
    |   |-- generator.js
    |   |-- projectConstruction.js
    |-- utils
        |-- log.js

复制代码
1. 项目初始化

新建一个 cunw文件夹,初始化项目npm init --y,并安装所需依赖

{
  "name": "cunw-cli",
  "version": "0.0.1",
  "description": "快速生成项目的脚手架",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "forestxiecode",
  "license": "ISC",
  "dependencies": {
    "chalk": "^4.1.1",
    "clear": "^0.1.0",
    "commander": "^6.1.0",
    "download-git-repo": "^3.0.2",
    "ejs": "^2.7.4",
    "figlet": "^1.5.0",
    "git-clone": "^0.1.0",
    "glob": "^7.1.6",
    "handlebars": "^4.7.6",
    "inquirer": "^6.5.2",
    "latest-version": "^5.1.0",
    "log-symbols": "^4.0.0",
    "metalsmith": "^2.3.0",
    "minimatch": "^3.0.4",
    "ora": "^5.1.0",
    "rimraf": "^3.0.2",
    "wrap-ansi": "^7.0.0"
  },
  "devDependencies": {},
}
复制代码
2. 测试环境

在根目录下lib文件夹下,新建cunw-init.js文件,编写测试代码。

+ #! /usr/bin/env node
+ console.log('测试')
复制代码

并且修改pack.json文件,全局link指令,测试环境,调试代码,如。

+  "bin": {
+      "cunw-init": "bin/cunw-init"
+   }
复制代码
  • 在根目录 全局link下 npm link
  • 指令测试

image.png

3.编写程序代码

根据输入,获取项目名称

// 根据输入,获取项目名称
let projectName = program.args[0];
if (!projectName) {
  // 相当于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项
  program.help();
  return;
}
// 返回 Node.js 进程的当前工作目录
let rootName = path.basename(process.cwd());
复制代码

执行主函数,在这使用figlet工具打印大写的粉笔字体

    const data = await figlet('WELCOM   CUNW   CLI')
    console.log(chalk.green(data))
复制代码

进入版本检查,获取本地package.json文件下的版本号,用latest-version模块获取最后一次版本号进行对比

function checkVersion() {
  return new Promise(async (resolve, reject) => {
    const spinner = ora(`检测版本....`);
    spinner.start();
    let webVersion = await latestVersion(`${CONST.CLI_NAME}`);
    let localVersion = require("../package.json").version;
    spinner.succeed();
    console.log(`本地版本${localVersion}, 最新版本${webVersion}\n`);
    let webVersionArr = webVersion.split(".");
    let localVersionArr = localVersion.split(".");
    let isNew = webVersionArr.some((item, index) => {
      return Number(item) > Number(localVersionArr[index]);
    });
    if (isNew) {
      log.warn(`检查已存在更新版本,请执行指令 npm install @cunw/cunw-cli -g 更新版本\n`)
      setTimeout(() => {
        resolve(isNew);
      }, 2000)
    } else {
      setTimeout(() => {
        resolve(isNew);
      }, 1000)
    }
  });
}
复制代码

路径检查,判断当前是否已经存在该文件夹,否则创建该文件

// 路径检查
function checkDir() {
  return new Promise(async (resolve, reject) => {
    const list = glob.sync("*"); // 遍历当前目录
    if (list.length) {
      if (
        list.filter(name => {
          const fileName = path.resolve(process.cwd(), path.join(".", name));
          const isDir = fs.statSync(fileName).isDirectory();
          return name.indexOf(projectName) !== -1 && isDir;
        }).length !== 0
      ) {
        log.error(`项目${projectName}已经存在`)
        reject(`项目${projectName}已经存在`);
      }
      resolve(projectName);
    } else if (rootName === projectName) {
      let answer = await inquirer.prompt([
        {
          name: "buildInCurrent",
          message:
            "当前目录为空,且目录名称和项目名称相同,是否直接在当前目录下创建新项目",
          type: "confirm",
          default: true
        }
      ]);
      resolve(answer.buildInCurrent ? "." : projectName);
    } else {
      resolve(projectName);
    }
  });
}
// 创建该文件
function makeDir(projectRoot) {
  if (projectRoot !== ".") {
    fs.mkdirSync(projectName);
  }
}
复制代码

使用inquirer.js处理命令行交互,让用户现在自己需要的模板进行渲染。

function selectTemplate() {
  return new Promise((resolve, reject) => {
    let choices = []
    Object.values(templateConfig).forEach(item => {
      if (item.enable) {
        choices.push({
          name: item.name,
          value: item.value
        });
      }
    });
    let config = {
      // type: 'checkbox',
      type: "list",
      message: "请选择创建项目类型",
      name: "select",
      choices: [new inquirer.Separator("模板类型"), ...choices]
    };
    inquirer.prompt(config).then(data => {
      let { select } = data;
      let { name, git, value } = templateConfig[select];
      resolve({
        git,
        name,
        value
      });
    });
  });
}
复制代码

得到用户的模板地址,使用download-git-repo模块下载模板。

module.exports = function (target, url) {
  const spinner = ora(`正在下载项目模板,源地址:${url}`)
  target = path.join(CONST.TEMPLATE_NAME)
  spinner.start()
  return new Promise((resolve, reject) => {
    download(`direct:${url}`,
      target, { clone: true }, (err) => {
        if (err) {
          spinner.fail()
          console.log(logSymbols.fail, chalk.red("模板下载失败"));
          reject(err)
        } else {
          spinner.succeed()
          console.log(logSymbols.success, chalk.green("模板下载完毕"));
          resolve(target)
        }
      })
  })
}
复制代码

使用metalsmith处理模板

引用官网的介绍:

An extremely simple, pluggable static site generator.

它就是一个静态网站生成器,可以用在批量处理模板的场景,类似的工具包还有WintersmithAssembleHexo。它最大的一个特点就是EVERYTHING IS PLUGIN,所以,metalsmith本质上就是一个胶水框架,通过黏合各种插件来完成生产工作。

给项目模板添加变量占位符。

module.exports = function (config) {
  let { metadata, src, dest } = config;
  if (!src) {
    return Promise.reject(new Error(`无效的source:${src}`));
  }
  // 官方模板
  return new Promise((resolve, reject) => {
    const metalsmith = Metalsmith(process.cwd())
      .metadata(metadata)
      .clean(false)
      .source(src)
      .destination(dest);
    const ignoreFile = path.resolve(process.cwd(), src, CONST.FILE_IGNORE);
    if (fs.existsSync(ignoreFile)) {
      // 定义一个用于移除模板中被忽略文件的metalsmith插件
      metalsmith.use((files, metalsmith, done) => {
        const meta = metalsmith.metadata();
        // 先对ignore文件进行渲染,然后按行切割ignore文件的内容,拿到被忽略清单
        const ignores = ejs
          .render(fs.readFileSync(ignoreFile).toString(), meta)
          .split("\n")
          .filter(item => !!item.length);
        Object.keys(files).forEach(fileName => {
          // 移除被忽略的文件
          ignores.forEach(ignorePattern => {
            if (minimatch(fileName, ignorePattern)) {
              delete files[fileName];
            }
          });
        });
        done();
      });
    }

    metalsmith
      .use((files, metalsmith, done) => {
        const meta = metalsmith.metadata();
        // 编译模板
        Object.keys(files).forEach(fileName => {
          try {
            const t = files[fileName].contents.toString();
            if (/(<%.*%>)/g.test(t)) {
              files[fileName].contents = new Buffer.from(ejs.render(t, meta));
            }
          } catch (err) {
            // console.log("fileName------------", fileName);
            // console.log("er -------------", err);
          }
        });
        done();
      })
      .build(err => {
        rm(src);
        err ? reject(err) : resolve();
      });
  });
};
复制代码

package.jsonnameversiondescription字段的内容被替换成了handlebar语法的占位符,模板中其他地方也做类似的替换,完成后重新提交模板的更新。

调用该函数删除一些无用的文件,做一些清理工作。

function deleteCusomizePrompt(target) {
  // 自定义选项模板路径
  const cusomizePrompt = path.join(process.cwd(), target, CONST.CUSTOMIZE_PROMPT)
  if (fs.existsSync(cusomizePrompt)) {
    rm(cusomizePrompt)
  }
  // 忽略文档路径
  const fileIgnore = path.join(process.cwd(), target, CONST.FILE_IGNORE)
  if (fs.existsSync(fileIgnore)) {
    rm(fileIgnore)
  }
}
复制代码

最后执行结束的回调,初始化项目。

 clear()
  log.succes("创建成功")
  // 初始化项目
  await initProject(projectRoot)
  // 运行项目
  console.log(chalk.green(`====================================\n
          运行项目 ...\n
          cd ./demmo\n
         npm run serve\n
===================================
`))
复制代码

5. 如何发布一个npm包

1. 注册一个npm账号
2. 项目根目录下npm login 登入npm账号,最后执行npm publish 发布

总结

去模仿,去参照,其实实现一个脚手架也不是特别复杂。

  • 通过node可以很好的解决一些工程化上的问题
  • 其实npm存在很好的node模块库如:
    • 通过download-git-repo处理下载
    • 通过inquirer.js处理终端交互
    • 通过metalsmith和模板引擎将交互输入项插入到项目模板中

通过这次开发自己的脚手架中。还想自己存在很多不足,也在模仿,和学习。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享