自研前端脚手架(二)项目模板制作和管理

314 阅读6分钟

目的

本章节主要目的是和大家分享项目模板制作过程。

这是脚手架系列文章的第一小节,因为模板是脚手架的“原料”,并且内容相对独立,所以放在第一小节里进行分享。

下一节重点介绍如何设计一个脚手架,利用本节的模板创建项目。

为什么需要项目模板

  1. 创建完项目可以直接进行业务开发,降低因为经验和能力不一带来的产出质量差距;
  2. 统一项目结构、工作流程、依赖项管理;
  3. 避免重复建设,节约人力成本;
  4. 我们希望把一些约定,沉淀的经验转化到模板,新项目可以直接复用;

怎么进行模板管理

现在主流进行模板管理有两种方案进行选择:

  1. 采用Git仓库进行管理;
  2. 采用Npm包的方式进行管理;

我们项目采用的是Npm的方式进行管理,所以接下的讲述的模板制作都是依托于Npm。

个人感觉两种方式实际区别不大,决定用Npm方式,主要考虑版本管理更好控制。

如何制作模板

模板制作流程

  1. 提取通用项目;
  2. 将通用项目转化成EJS模板;
  3. 将转化后的模板发布到内网NPM仓库;
  4. 把模板的NPM包注册到项目模板库;
  5. 即可使用demo-cli init进行项目创建;

jietu.jpg

一个通用项目应该包含什么内容

  1. 规范的目录结构;
  2. 代码规范配置;
  3. 工具配置如:babel等等;
  4. 打包配置;
  5. mock Api支持,用以模拟数据;
  6. 开发调试配置;
  7. 通用逻辑实现,一个项目的最小实现(通用逻辑比如:单点登录,权限实现等等,通用工具如:axios封装等等);
  8. ...

每个团队都会针对某个技术栈实现自己的项目模板,这里说的项目模板和VUE-CLI生成的项目模板有一些不一样,这里更强调团队的定制,它应该是一个项目的最小实现,当我们使用当前模板生成了新项目,开发人员甚至可以不改动任何可以直接进行业务代码的开发。

以下是VUE项目的一个目录结构示意:

jietu1.jpg 如何设计和实现一个合理的项目模板并非是本章节的重点,这里根据每个团队的需求进行定制。

把通用项目转化成模板

我们已经有了一个通用的项目实现,那么现在我需要考虑如何将需要根据实际项目情况进行调整的部分转化成模板。

我们的实现方式原理很简单,把需要动态调整的脚本文件转化成ejs模板

如果你不了解如何EJS,你可以ejs.co/查看它的用法。 当然你可以选择其他的模板引擎使用

@demo-cli/vue-template-pkg为例进行说明如何进行模板化。

比如,创建项目时候项目名称和版本获取的是用户命令行输入的值,那么我们要改造package.json

改造如下:

{
  "private": true,
  "name": "<%= pkgName %>",
  "version": "<%= version %>",
  "description": "VUE2 通用项目模板",
  ...
}

在脚手架执行初始化项目时候,会遍历所有需要编译的文件,然后把输入的项目名称和版本传入进行编译。

const ejs = require('ejs');
ejs.renderFile(filepath, {
   pkgName: 'vue2-demo',
   version: '1.0.0'
}, (err, result) => {
...
}

因此,任何你需要定制的逻辑都可以用这种方式进行改造,比如是否启用eslint等等。

一个项目模板目录结构如下:

jietu123.jpg

如何进行模板调试

当我们模板改造完成后,我们面临一个大问题,因为模板需要我们把脚本文件改成了ejs模板文件,项目没办法运行,我们要怎么调试呢?

我们需要自己实现以下功能:

  1. 保证改造后的模板项目能够正常运行;
  2. 运行过程中修改的内容能够热更新;

我们分析下原因,是因为修改成ejs模板造成了项目无法运行,

那么只要将模板执行一次编译就可以转化成可运行项目,

同时监听源文件变化,如果源文件发生改变则执行一次模板编译,即可实现热更新。

完美解决!

jietu1233.jpg 我们借助gulp实现以上功能,代码实现如下:

package.json

{
  "name": "@demo-cli/vue-template-pkg",
  "version": "1.0.0",
  "description": "dome-cli vue2 standard template",
  "main": "index.js",
  "scripts": {
    "cp": "gulp",// 编译ejs模板到cache
    "watchTask": "gulp watchTask", // 监听文件变化,然后重新编译ejs模板到cache
    "bootstrap": "gulp && node ./build/execCommand yarn", // 执行文件复制和依赖安装
    "dev": "gulp && node ./build/execCommand dev",// 启动开发命令
  },
  ...
}

build/ejs.js

const path = require('path');
const glob = require('glob');
const ejs = require('ejs');
const fse = require('fs-extra');
const get = require('lodash/get');

// 遍历需要编译的模板文件进行ejs模板编译
module.exports = function (dir, options = {}, extraOptions = {}, diableFormatDotFile = false) {
  const ignore = get(extraOptions, 'ignore');
  console.info('ignore', ignore);
  return new Promise((resolve, reject) => {
    glob('**', {
      cwd: dir,
      nodir: true,
      ignore: ignore || '**/node_modules/**',
    }, (err, files) => {
      if (err) {
        return reject(err);
      }

      console.info('render files:', files);

      Promise.all(files.map((file) => {
        const filepath = path.join(dir, file);
        return renderFile(filepath, options, diableFormatDotFile);
      })).then(() => {
        resolve();
      }).catch((err) => {
        reject(err);
      });
    });
  });
};

function renderFile(filepath, options, diableFormatDotFile) {
  let filename = path.basename(filepath);

  if (/.(png|jpg|jpeg|gif)$/g.test(filename)) {
    // console.log('renderFile:', filename);
    return Promise.resolve();
  }

  return new Promise((resolve, reject) => {
    ejs.renderFile(filepath, options, (err, result) => {
      if (err) {
        return reject(err);
      }
      
      if (/\.ejs$/.test(filepath)) {
        filename = filename.replace(/\.ejs$/, '');
        fse.removeSync(filepath);
      }

      if (!diableFormatDotFile && /^_/.test(filename)) {
        filename = filename.replace(/^_/, '.');
        fse.removeSync(filepath);
      }

      const newFilepath = path.join(filepath, '../', filename);
      fse.writeFileSync(newFilepath, result);
      resolve(newFilepath);
    });
  });
}
// 单文件发生改变时候执行此函数进行编译
module.exports.renderSingleFile = function (oldPath, dir, options = {}, extraOptions = {}, diableFormatDotFile = false) {
  const ignore = get(extraOptions, 'ignore');
  return new Promise((resolve, reject) => {
    glob('**', {
      cwd: dir,
      nodir: true,
      ignore: ignore || '**/node_modules/**',
    }, (err, files) => {
      if (err) {
        return reject(err);
      }
      if (files.indexOf(oldPath.replace(/\\/g, '/')) < 0 && files.indexOf(oldPath.replace(/\//g, '\\')) < 0) {
        resolve();
        return
      }
      const filepath = path.join(dir, oldPath);
      renderFile(filepath, options, diableFormatDotFile).then(() => resolve()).catch(e => reject(e));
    })
  });
}

build/execCommand.js

因为真实运行的目录是在cache中,所以此文件的主要作用就是修改命令运行cwd目录为cache目录


const konwnOptions = {
    string: ['env', 'commad'],
    default: {
        commad: '',
        env: 'develop',
    },
};
const argv = process.argv.slice(2);
const commad = argv[0];
console.log(argv)
// const argv = require('minimist')(process.argv.slice(2), konwnOptions);
if (argv.length < 1) {
    throw new Error("缺少待执行命令,执行中止!");
}
const targetPath = './cache';
function exec(command, args, options) {
    const win32 = process.platform === 'win32';

    const cmd = win32 ? 'cmd' : command;
    const cmdArgs = win32 ? ['/c'].concat(command, args) : args;

    return require('child_process').spawn(cmd, cmdArgs, options || {});
}

async function execCommand(startCommand) {
    return new Promise((resolve, reject) => {
        const p = exec(startCommand[0], startCommand.slice(1), { stdio: 'inherit', cwd: targetPath });
        p.on('error', e => {
            reject(e);
        });
        p.on('exit', c => {
            resolve(c);
        });
    });
}

async function npminstall() {
    return new Promise((resolve, reject) => {
        const p = exec('yarn', [], { stdio: 'inherit', cwd: targetPath });
        p.on('error', e => {
            reject(e);
        });
        p.on('exit', c => {
            resolve(c);
        });
    });
}

async function runDev() {
    return new Promise((resolve, reject) => {
        const p = exec('npm', ['run', 'dev'], { stdio: 'inherit', cwd: targetPath });
        p.on('error', e => {
            reject(e);
        });
        p.on('exit', c => {
            resolve(c);
        });
        const w = exec('npm', ['run', 'watchTask'], { stdio: 'inherit' });
        w.on('error', e => {
            reject(e);
        });
        w.on('exit', c => {
            resolve(c);
        });
    });
}

switch (commad) {
    case 'bootstrap':
        npminstall()
        break;
    case 'dev':
        runDev()
        break;
    default:
        execCommand(argv)
        break;
}

gulpfile.js

'use strict';

const { series, src, dest, watch } = require('gulp');
const ejs = require('./build/ejs');
const { renderSingleFile } = ejs;
const glob = require('glob');
const fse = require('fs-extra');
const { join, relative } = require("path");

const ejsIgnoreFiles = ["**/public/**", "**/assets/**", '**/node_modules/**'];
const sourceDir = './template';
const targetDir = './cache';
const testProjectName = 'dev-demo';
const ejsData = {
  pkgName:'dev-demo',
  name: 'DevDemo',
  className: testProjectName,
  version: '1.0.0'
}
// ejs模板编译task
async function ejsCompile(cb) {
  const res = await ejs(targetDir, ejsData, {
    ignore: ejsIgnoreFiles,
  })
  cb('')
  return Promise.resolve('');
}

function getFiles() {
  return new Promise((resolve, reject) => {
    glob('**', {
      cwd: targetDir,
      nodir: true,
      dot: true,// 需要设置dot为true否则glob默认忽略以.开头的文件
      ignore: '**/node_modules/**',
    }, (err, files) => {
      if (err) {
        return reject(err);
      }
      resolve(files)
    });
  });
}
// 删除文件task
async function delRemoveFiles(cb) {
  const files = await getFiles();
  for (let index = 0; index < files.length; index++) {
    const fileSourcePath = join(sourceDir, files[index]);
    const filePath = join(targetDir, files[index]);
    // console.log(fileSourcePath, filePath)
    if (!fse.existsSync(fileSourcePath)) {
      fse.removeSync(filePath);
    }
  }
  cb('')
  return Promise.resolve('');
}
// 复制文件task
function copyTemplate() {
  return src(['./template/**', '!node_modules/**/*', '!node_modules'], {
    dot: true
  })
    .pipe(dest(targetDir));
}
// 监听文件变化执行重新渲染task
function watchTask() {
  const watcher = watch(['./template/**', '!node_modules/**/*', '!node_modules'], delRemoveFiles)
  watcher.on('change', function (path, stats) {
    const relativePath = relative(sourceDir, path);
    const filePath = join(targetDir, relativePath);
    console.log(path, filePath, targetDir);
    // 复制文件
    fse.copyFileSync(path, filePath);
    // 单个文件ejs模板处理
    renderSingleFile(relativePath, targetDir, ejsData, {
      ignore: ejsIgnoreFiles,
    });
    console.log(`File ${path} was changed`);
  });

  watcher.on('add', function (path, stats) {
    const relativePath = relative(sourceDir, path);
    const filePath = join(targetDir, relativePath);
    // 复制文件
    fse.copyFileSync(path, filePath);
    // 单个文件ejs模板处理
    renderSingleFile(relativePath, targetDir, ejsData, {
      ignore: ejsIgnoreFiles,
    });
    console.log(`File ${path} was added`);
  });

  watcher.on('unlink', function (path, stats) {
    console.log(`File ${path} was removed`);
  });
  return watcher;
}

exports.default = series(copyTemplate, delRemoveFiles, ejsCompile);

exports.watchTask = watchTask;

如上,即可丝滑的调试模板。

如何通过模板创建项目

以上,我们开发调试完我们的项目模板,然后通过 npm publish发布到NPM仓库(建议搭建自己公司的内网仓库),这里我们模板制作就完成,然后我们怎么通过模板创建项目呢?

jietu12332.jpg

核心实现如下:

我可以用npminstall库把模板下载到缓存目录,

const npminstall = require('npminstall');
...
   npminstall({
      root: 'C:\\Users\imbigd\\.demo-cli\\cache',// 模板缓存目录
      registry: 'xxxx',//  你私有NPM仓库
      pkgs: [{
        name: '@demo-cli/vue-template-pkg',// 模板包名
        version: '1.0.0',// 模板版本
      }],
    });
...

然后拷贝文件到目标文件夹并且进行模板编译,

const fse = require('fs-extra');
...
  // 模板编译数据定义,
  const ejsData = {
    pkgName:'dev-demo',
    name: 'DevDemo',
    version: '1.0.0'
  };
  const sourceDir = 'C:\\Users\imbigd\\.demo-cli\\cache\\@demo-cli\\vue-template-pkg\\template';// 注意模板是在包的template目录下
  const targetDir = 'C:\\Users\imbigd\\my-app-dest';// 项目目录,一般来自命令行输入
  fse.ensureDirSync(sourceDir);
  fse.ensureDirSync(targetDir);
  fse.copySync(sourceDir, targetDir);// 拷贝模板文件到目标文件夹
  // ejs 模板渲染忽略的文件夹和文件
  const ejsIgnoreFiles = [
    '**/node_modules/**',
    '**/.git/**',
    '**/.vscode/**',
    '**/.DS_Store',
    ...
  ];
  // 进行模板编译
  await ejsRender(targetDir, ejsData, {
    ignore: ejsIgnoreFiles,
  });
...

以上就是模板创建项目的流程的简单说明,

下一章节会重点讲解如何开发一个脚手架,完整的演示如何利用模板创建项目的实现。