likes
comments
collection
share

从零实现属于自己的前端脚手架

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

前端脚手架

是什么?

网络文库对脚手架的定义:

为了保证各施工过程顺利进行而搭设的工作平台

对于前端开发,前端脚手架是伴随前端工程化发展而产生的,通过选择几个选项快速搭建项目基础代码的工具。它可以有效避免我们ctrl + c/v

为什么

目前前端常见的脚手架: Vue CLI、Create-React-App、vite等等。

这些都是社区通用的脚手架解决方案。

假如我们需要定制化的脚手架,例如企业内部的脚手架,那社区通用脚手架很难满足我们的需求;例如:

  1. 内置公司内部工具依赖包
  2. 定制化npm run命令

所以,有必要了解脚手架的实现。

怎么办

接下来,我们以Vue框架为例,从零搭建属于自己的脚手架。

任务

  1. 解析命令行参数
  2. 提供可视化选项
  3. 提供多种模板: 页面工程、组件工程

命令行参数解析工具 minimist

minimist 是一个用来解析命令行选项的库。

轻巧美观人性化的命令行交互库prompts

const prompts = require('prompts');
(async () => {
    const result = await prompts([
        {
            name: 'age',
            type: 'text',
            message: '今年贵庚?',
            initial: '99'
        },
        {
            name: 'name',
            type: 'text',
            message: '尊姓大名?',
            initial: '鸡鸡鸡坤'
        },
    ])
})();

从零实现属于自己的前端脚手架

定制化模板

提供多种模板,通过命令行交互界面,让用户自定义初始化项目,是非常有必要的。通常前端切图仔工程分为两种: 页面工程、组件工程。 前端工程一般都是 JS框架 + UI框架 + 工具 + 打包构建工具,例如:

  • Vue + ElementUI + Axios... + webpack(vite)
  • React + Antd + Axios... + webpack(vite)

接下来我以vue为例,来创建页面工程模板和组件工程模板(为了省去webpack的配置,我用VueCLI来创建工程模板)

页面工程模板

使用VueCLI创建页面工程,配置好.browserslistrc、和相关的环境变量文件envenv.*等各种定制化配置。

从零实现属于自己的前端脚手架

"scripts": {
    "dev": "vue-cli-service serve",
    "build:dev": "vue-cli-service build --mode develop --no-module",
    "build:test": "vue-cli-service build --mode release --no-module",
    "build:pro": "vue-cli-service build --mode production --no-module",
    "build:report": "vue-cli-service build --mode production --no-module --report",
    "test:unit": "vue-cli-service test:unit",
    "lint": "vue-cli-service lint"
},
组件工程模板

同样使用VueCLI创建工程,但是需要改造一下目录结构和打包方式(这里不赘述)

从零实现属于自己的前端脚手架

组装

有了命令行解析、命令行交互和工程模板,接下来我们就将它们组装起来,做成脚手架。

初始化脚手架工程

npm init -y

初始化package.json

{
  "name": "@ikun/create-project",
  "version": "0.0.1",
  "description": "鸡鸡鸡! 搞一个自己的脚手架工程",
  "bin": {
    "create-project": "index.js"
  },
  "scripts": {
    "dev": "node index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "kolorist": "^1.6.0",
    "minimist": "^1.2.6",
    "prompts": "^2.4.2"
  },
  "devDependencies": {
    "@types/node": "^18.7.18"
  }
}
工程名称

我们的工程名:@ikun/create-project,取create前缀是有讲究的:

后续使用脚手架时,我们希望和vite、create-react-app类似。

npm init react-app my-project
# or
npm init vite my-project
# or
npm create vite my-project

create 其实是init的别名

从零实现属于自己的前端脚手架

npm init 命令除了可以用来创建 package.json 文件,还可以用来执行一个包的命令;它后面还可以接一个 <initializer> 参数。

参数initializer是名为create-<initializer>的 npm 包 ( 例如 create-vite ),执行npm init <initializer>将会被转换为相应的npm exec操作,即会使用npm exec命令来运行create-<initializer>包中对应命令create-<initializer>package.jsonbin字段指定),例如:

npm init vite my-project
# 等同于
npm exec create-vite my-project

我们的脚手架最终的使用形式应该如下:

npm init @ikun/project my-project
模板

在根目录下创建templates文件夹,用于存放我们的工程模板,供脚手架执行的时候拷贝到创建的工程文件夹中;这里我们将上面创建的页面工程模板和组件工程模板拷贝进来。

从零实现属于自己的前端脚手架

可执行文件

bin属性的官方解释

许多软件包都具有一个或多个要安装到PATH中的可执行文件。

bin字段是命令名到本地文件名的映射。在安装时npm会将文件符号链接到prefix/bin以进行全局安装或./node_modules/.bin/本地安装。

当我们使用npm或者yarn命令安装包时,如果该包的package.json文件有bin字段,就会在node_modules文件夹下面的.bin目录中复制了bin字段链接的执行文件。我们在调用执行文件时,可以不带路径,直接使用命令名来执行相对应的执行文件。

在根目录下创建index.js可执行文件。其功能是集成minimistprompts, 然后生成工程模板。

注意: 可执行文件需要在文件第一行开头写下 #!/usr/bin/env node(不赘述,实现原理我也不懂,看解释)

#!/usr/bin/env node

const fs = require("fs");
const path = require("path");

// 命令行参数解析
const minimist = require("minimist");
// 命令行交互
const prompts = require("prompts");

我们分析一下 npm init @ikun/project my-project --force的流程

  1. 执行@ikun/create-project的index.js
  2. 解析参数: my-projectforce; 文件夹名: my-project 、 是否覆盖已有文件夹:force
  3. 显示命令行交互:
  • (1) 工程名称
    
  • (2)是否覆盖已有的文件夹
    
  • (3)定义packageName
    
  • (4)选择工程模板
    
  1. 生成工程目录文件

上述我们已经完成了第一步,创建了可执行文件index.js; 接下来需要在文件内实现解析参数:

// index.js

// ...省略代码

async function init() {
  // --------- 解析参数start ----------
  const argv = minimist(process.argv.slice(2), {
    // 一些配置别名,本文档不涉及
    alias: {
      typescript: ["ts"],
      "with-tests": ["tests"],
      router: ["vue-router"]
    },
    boolean: true
  });

  // 获取要创建的文件夹名称
  let targetDir = argv._[0];
  
  // 不存在的话,默认'vue-project'
  const defaultProjectName = !targetDir ? "vue-project" : targetDir;
  // 是否强制覆盖当前重名的文件夹
  const forceOverwrite = argv.force;
  
  // --------- 解析参数 end ----------
  
}

获取了命令行参数之后,我们需要进行第3步,显示命令行交互界面了:

// ...省略代码

// 更改命令行文字颜色的插件
const { red, green, bold } = require("kolorist");

function getOption(name) {
  const options = {
    projectName: {
      name: "projectName",
      type: targetDir ? null : "text",
      message: "工程名称:",
      initial: defaultProjectName,
      onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName)
    },
    //是否覆盖已有的文件夹
    shouldOverwrite: {
      name: "shouldOverwrite",
      // 判断目录是否为空, canSkipEmptying(下面实现)
      type: () => (canSkipEmptying(targetDir) || forceOverwrite ? null : "confirm"),
      message: () => {
        const dirForPrompt =
          targetDir === "." ? "Current directory" : `Target directory "${targetDir}"`;

        return `${dirForPrompt} 已存在。 是否删除?`;
      }
    },
    packageName: {
      name: "packageName",
      // isValidPackageName 判断package.name名称是否符合规范 (下面实现)
      type: () => (isValidPackageName(targetDir) ? null : "text"),
      message: "package name:",
      // 默认值: 将文件夹名称转为可用的package.name; toValidPackageName(下面实现)
      initial: () => toValidPackageName(targetDir),
      validate: (dir) => isValidPackageName(dir) || "无效的package name"
    },
    projectType: {
      type: "select",
      name: "projectType",
      message: "选择工程类型",
      choices: [
        {
          title: "组件工程",
          description: "以npm包/[微组件](篇幅有限,后续再开一篇新文件讲解微组件)的方式提供给业务侧使用",
          value: "component"
        },
        {
          title: "vue2单页工程",
          description: "vue2单页应用",
          value: "page"
        }
      ],
      initial: 0
    }
  };
  return options[name]
}

asnyc function init() {

  // --------- 解析参数start ----------
  // ... 省略代码
  // --------- 解析参数 end ----------

  // --------- 命令行交互 start ---------
  // result 用于存放用户的交互结果
  let result = {};
  try {
      result = await prompts([
          getOption('projectName'), // 交互命令的工程名称配置
          getOption('shouldOverwrite'), // 是否覆盖的配置
          getOption('packageName'), // packageName的配置
          getOption('projectType'), // 工程类型的配置
      ],
      {
        onCancel: () => {
          throw new Error(red("✖") + " 操作已推出");
        }
      }) 
  } catch(cancelled) {
    console.log(cancelled.message);
    process.exit(1);
  }
  // --------- 命令行交互 end ---------
}

上面我们通过prompts创建了命令行交互界面,名提供了四个交互选项:

  • 输入工程名称
  • (非必要显示项)是否覆盖已有目录
  • 输入packageName
  • 选择创建的工程类型

其中我们会用到校验package.name的方法isValidPackageName和工程名转package.name的方法toValidPackageName

// index.js

// 简单实现, 若想完整校验,可使用validate-npm-package-name库来检测
function isValidPackageName(projectName) {
  return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(projectName);
}

function toValidPackageName(projectName) {
  return projectName
    .trim()
    .toLowerCase()
    .replace(/\s+/g, "-")
    .replace(/^[._]/, "")
    .replace(/[^a-z0-9-~]+/g, "-");
}

另外还有一个canSkipEmptying方法, 判断工程目录是否为空:

// index.js
function canSkipEmptying(dir) {
  if (!fs.existsSync(dir)) {
    return true;
  }

  const files = fs.readdirSync(dir);
  if (files.length === 0) {
    return true;
  }
  if (files.length === 1 && files[0] === ".git") {
    return true;
  }

  return false;
}

完成上面的代码之后,我们就可以拿到用户最终想要生成模板的参数对象result了,接下来我们实现第4步,生成工程文件:

// index.js

// ... 省略代码

async function init() {
  // ... 省略代码
  
  
  const {
    projectName,
    packageName = projectName ?? defaultProjectName,
    shouldOverwrite = argv.force,
    projectType = "component"
  } = result;
  
  const cwd = process.cwd();
  // 获取要创建工程的绝对路径
  const root = path.join(cwd, targetDir);

  // 这里是真正判断是否要覆盖文件夹
  if (fs.existsSync(root) && shouldOverwrite) {
    emptyDir(root); // emptyDir清空文件夹后面实现
  } else if (!fs.existsSync(root)) {
    fs.mkdirSync(root);
  }
  
  // 提示一下
  console.log(`\n正在搭建工程 ${root}...`);
  
  const pkg = { name: "@ikun/" + packageName, version: "0.0.0" };
  fs.writeFileSync(path.resolve(root, "package.json"), JSON.stringify(pkg, null, 2));
  
  // 生成对应模板
  const templateRoot = path.resolve(__dirname, "templates");
  const render = function render(templateName) {
    // templateDir 是脚手架工程中的模板路径
    const templateDir = path.resolve(templateRoot, templateName);
    // 将脚手架工程中的模板复制到创建的工程目录中
    renderTemplate(templateDir, root); // 生成模板文件夹及文件操作,下面实现
  };
  
  // 生成结束,良好地提示一下用户该怎么启动工程
  const templateName = projectType;
  render(templateName);
  
  console.log(`\nDone. Now run:\n`);
  if (root !== cwd) {
    console.log(`  ${bold(green(`cd ${path.relative(cwd, root)}`))}`);
    console.log(`  ${bold(green(`npm i`))}`);
    console.log(`  ${bold(green(`npm run dev`))}`);
  }
}

// 执行初始化方法
init().catch((e) => {
  console.error(e);
});

上面这段代码,就是将模板拷贝到目标目录上,其中我们调用了emptyDir来清空目标目录,调用了renderTemplate来将模板拷贝到目标目录上。接下来我们就来看下emptyDirrenderTemplate是如何实现的。

  • emptyDir

调用emptyDir传入参数是绝对路径,我们需要先判断文件夹是否存在,存在的话,再清除文件夹内的文件和文件夹。

/**
 * 清空文件夹
 * @param dir 目标文件夹路径
 */
function emptyDir(dir) {
  if (!fs.existsSync(dir)) {
    return;
  }

  // 处理目录下的文件和文件夹
  postOrderDirectoryTraverse(
    dir,
    (dir) => fs.rmdirSync(dir),
    (file) => fs.unlinkSync(file)
  );
}

/**
 * 处理目录下的文件和文件夹
 * @param dir 路径
 * @param dirCallback 处理文件夹的操作
 * @param fileCallback 处理文件的操作
 */
function postOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
  for (const filename of fs.readdirSync(dir)) {
    if (filename === ".git") {
      continue;
    }
    // 文件/文件夹路径
    const fullpath = path.resolve(dir, filename);
    if (fs.lstatSync(fullpath).isDirectory()) {
      // 若为文件夹,递归处理
      postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback);
      // 对文件夹进行操作
      dirCallback(fullpath);
      continue;
    }
    // 对文件进行操作
    fileCallback(fullpath);
  }
}
  • renderTemplate

调用renderTemplate传入参数1: 脚手架工程内置的模板路径,传入参数2: 创建的工程目录路径

//复制模板
function renderTemplate(src, dest) {
  const stats = fs.statSync(src);

  /**
   * 若是文件夹,且不是modules目录,则创建文件夹
   * 再遍历文件夹下的内容,再递归处理文件和文件夹
   */
  if (stats.isDirectory()) {
    if (path.basename(src) === "node_modules") {
      return;
    }

    fs.mkdirSync(dest, { recursive: true });
    for (const file of fs.readdirSync(src)) {
      renderTemplate(path.resolve(src, file), path.resolve(dest, file));
    }
    return;
  }

  const filename = path.basename(src);
  
  /**
   * 若是package.json文件已存在,则合并
   */
  if (filename === "package.json" && fs.existsSync(dest)) {
    // merge instead of overwriting
    const existing = JSON.parse(fs.readFileSync(dest, "utf8"));
    const newPackage = JSON.parse(fs.readFileSync(src, "utf8"));
    const pkg = sortDependencies(deepMerge(existing, newPackage));
    fs.writeFileSync(dest, JSON.stringify(pkg, null, 2) + "\n");
    return;
  }
  
  // 有些文件会被git识别,需要特殊处理,例如.gitignore
  if (filename.startsWith("_")) {
    // rename `_file` to `.file`
    dest = path.resolve(path.dirname(dest), filename.replace(/^_/, "."));
  }
  fs.copyFileSync(src, dest);
}

// 合并package文件的逻辑,不赘述,可以按照自己想要的方式实现,也可以不合并,直接覆盖
const isObject = (val) => val && typeof val === "object";
const mergeArrayWithDedupe = (a, b) => Array.from(new Set([...a, ...b]));

function deepMerge(target, obj) {
  for (const key of Object.keys(obj)) {
    const oldVal = target[key];
    const newVal = obj[key];

    if (Array.isArray(oldVal) && Array.isArray(newVal)) {
      target[key] = mergeArrayWithDedupe(oldVal, newVal);
    } else if (isObject(oldVal) && isObject(newVal)) {
      target[key] = deepMerge(oldVal, newVal);
    } else {
      target[key] = newVal;
    }
  }

  return target;
}
function sortDependencies(packageJson) {
  const sorted = {};

  const depTypes = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];

  for (const depType of depTypes) {
    if (packageJson[depType]) {
      sorted[depType] = {};

      Object.keys(packageJson[depType])
        .sort()
        .forEach((name) => {
          sorted[depType][name] = packageJson[depType][name];
        });
    }
  }

  return {
    ...packageJson,
    ...sorted
  };
}

至此,一个脚手架基本完成,其实是缝合怪~~ , 让我们看看效果

没有输入工程名称的时候:

node .

从零实现属于自己的前端脚手架

从零实现属于自己的前端脚手架

从零实现属于自己的前端脚手架

当有输入工程名称的时候

node . my-project

从零实现属于自己的前端脚手架

当启用强制覆盖的时候

从零实现属于自己的前端脚手架

结语

以上算是一个简单脚手架该做的事,总结一下:处理参数 => 用户交互结果 => 拷贝对应内容

针对模板内容,篇幅原因,本文只是简单提及,后续可扩展例如多页配置、内置指令、利用githooks结合standard-version进行push的时候生成CHANGELOG和发布npm包版本自动化、包括组件库模板预览README.md、通过README.md生成类似ElementUI组件文档等等。

觉得有用的切图哥哥们,请给只因弟弟一个赞~谢谢;

下一篇应该会讲如何将利用组件README.md生成组件文档。期待一波~~~

若本文有哪里不对,请批评指正。