搭建一個通用的腳手架

在16年年底的時候,同事聊起腳手架。由於公司業務的多樣性,前端的靈活性,讓我們不得不思考更通用的腳手架。而不是伴隨着前端技術的發展,不斷的把時間花在配置上。於是chef-cli誕生了。 18年年初,把過往一年的東西整理和總結下,重新增強了原有的腳手架project-next-cli, 不單單滿足我們團隊的需求,也可以滿足其他人的需求。

<!--more-->

project-next-cli

面向的目標用戶:

  • 公司業務雜,但有一定的積累
  • 愛折騰的同學和團隊
  • 藉助github大量開發模板開發

image

發展

前端這幾年(13年-15年)處於高速發展,主要表現:

備註:以下發展過程出現,請不要糾結出現順序 [捂臉]

  • 庫/框架:jQuery, backbone, angular,react,vue
  • 模塊化:commonjs, AMD(CMD), UMD, es module
  • 任務管理器:npm scripts, grunt, gulp
  • 模塊打包工具: r.js, webpack, rollup, browserify
  • css預處理器:Sass, Less, Stylus, Postcss
  • 靜態檢查器:flow/typescript
  • 測試工具:mocha,jasmine,jest,ava
  • 代碼檢測工具:eslint,jslint

開發

當我們真實開發中,會遇到各種各樣的業務需求(場景),根據需求和場景選用不同的技術棧,由於技術的進步和不同瀏覽器運行時的限制,不得不配置對應的環境等,導致我們從而滿足業務需求。

畫了一張圖來表示,業務,配置(環境),技術之間的關係

image

前端配置工程師

於是明見流傳了一個新的職業,前端配置工程師 O(∩_∩)O~

社區現狀

專一的腳手架

社區中存在着大量的專一型框架,主要針對一個目標任務做定製。比如下列腳手架

  1. vue-cli

vue-cli提供利用vue開發webpack, 以及 遠程克隆生成文件等 pwa等模板,本文腳手架參考了vue-cli的實現。

  1. dva-cli

dva-cli 針對dva開發使用的腳手架

  1. think-cli

think-cli 針對 thinkjs項目創建項目

通用腳手架

  1. yeoman

yeoman是一款強壯的且有一系列工具的通用型腳手架,但yeoman發佈指定package名稱,和用其開發工具。具體可點擊這裏查看yeoman添加生成器規則

開發初衷和目標

由於公司形態決定了,業務類型多樣,前端技術發展迭代,爲了跟進社區發展,更好的完成下列目標而誕生。

  • 完成業務:專心,穩定,快速
  • 團隊規範:代碼規範,測試流程,發佈流程
  • 沉澱:專人做專事,持續穩定的迭代更新,跟進時代
  • 效益:少加班,少造輪子,完成kpi,做更有意義的事兒

實現準備

依託於Github,根據Github API來實現,如下:

  1. 獲取項目
curl -i https://api.github.com/orgs/project-scaffold/repos
  1. 獲取版本
curl -i https://api.github.com/repos/project-scaffold/cli/tags

實現邏輯

根據github api獲取到項目列表和版本號之後,根據輸入的名稱,選擇對應的版本下載到本地私有倉庫,生成到執行目錄下。核心流程圖如下:。

image

總體設計

  1. 規範
  • 使用Node進行腳手架開發,版本選擇 >=6.0.0
  • 選用async/await開發,解決異步回調問題
  • 使用babel編譯
  • 使用ESLint規範代碼
  1. 功能

遵守單一職責原則,每個文件爲一個單獨模塊,解決獨立的問題。可以自由組合,從而實現複用。以下是最終的目錄結構:

├── LICENSE
├── README.md
├── bin
│   └── project
├── package.json
├── src
│   ├── clear.js
│   ├── config.js
│   ├── helper
│   │   ├── metalAsk.js
│   │   ├── metalsimth.js
│   │   └── render.js
│   ├── index.js
│   ├── init.js
│   ├── install.js
│   ├── list.js
│   ├── project.js
│   ├── search.js
│   ├── uninstall.js
│   ├── update.js
│   └── utils
│       ├── betterRequire.js
│       ├── check.js
│       ├── copy.js
│       ├── defs.js
│       ├── git.js
│       ├── loading.js
│       └── rc.js
└── yarn.lock

配置和主框架

使用babel-preset-env保證版本兼容

{
  "presets": [
    ["env", {
      "targets": {
        "node": "6.0.0"
      }
    }]
  ]
}

使用eslint管理代碼

{
  "parserOptions": {
      "ecmaVersion": 7,
      "sourceType": "module",
      "ecmaFeatures": {
          "jsx": true
      }
  },
  "extends": "airbnb-base/legacy",
  "rules": {
      "consistent-return": 1,
      "prefer-destructuring": 0,
      "no-mixed-spaces-and-tabs": 0,
      "no-console": 0,
      "no-tabs": 0,
      "one-var":0,
      "no-unused-vars": 2,
      "no-multi-spaces": 2,
      "key-spacing": [
        2,
        {
          "beforeColon": false,
          "afterColon": true,
          "align": {
            "on": "colon"
          }
        }
      ],
      "no-return-await": 0
  },
  "env": {
      "node": true,
      "es6": true
  }
}

使用husky檢測提交

使用husky, 來定義git-hooks, 規範git代碼提交流程,這裏只做 commit校驗

package.json配置如下:

"husky": {
    "hooks": {
      "pre-commit": "npm run lint"
    }
}

入口

統一配置和入口,分發到不同單一文件,執行輸出。核心代碼

function registerAction(command, type, typeMap) {
  command
    .command(type)
    .description(typeMap[type].desc)
    .alias(typeMap[type].alias)
    .action(async () => {
      try {
        if (type === 'help') {
          help();
        } else if (type === 'config') {
          await project('config', ...process.argv.slice(3));
        } else {
          await project(type);
        }
      } catch (e) {
        console.log(e);
        help();
      }
    });

  return command;
}

本地配置讀和寫

配置用來獲取腳手架的基本設置, 如registry, type等基本信息。

  1. 使用
project config set registry koajs # 設置本地倉庫下載源

project config get registry # 獲取本地倉庫設置的屬性

project config delete registry # 刪除本地設置的屬性
  1. 邏輯
判定本地設置文件存在 ===> 讀/寫

本地配置文件, 格式是 .ini
若中間每一步 數據爲空/文件不存在 則給予提示

  1. 核心代碼
switch (action) {
    case 'get':
      console.log(await rc(k));
      console.log('');
      return true;

    case 'set':
      await rc(k, v);
      return true;

    case 'remove':
      await rc(k, v, true);
      return true;

    default:
      console.log(await rc());

下面每個命令的實現邏輯。

下載

  1. 使用
project i
  1. 邏輯
Github API ===> 獲取項目列表 ===> 選擇一個項目 ===> 獲取項目版本號 ===> 選擇一個版本號 ===> 下載到本地倉庫

獲取項目列表

獲取tag列表

若中間每一步 數據爲空/文件不存在 則給予提示

請求代碼

function fetch(api) {
  return new Promise((resolve, reject) => {
    request({
      url    : api,
      method : 'GET',
      headers: {
        'User-Agent': `${ua}`
      }
    }, (err, res, body) => {
      if (err) {
        reject(err);
        return;
      }

      const data = JSON.parse(body);
      if (data.message === 'Not Found') {
        reject(new Error(`${api} is not found`));
      } else {
        resolve(data);
      }
    });
  });
}

下載代碼

export const download = async (repo) => {
  const { url, scaffold } = await getGitInfo(repo);

  return new Promise((resolve, reject) => {
    downloadGit(url, `${dirs.download}/${scaffold}`, (err) => {
      if (err) {
        reject(err);
        return;
      }
      resolve();
    });
  });
};
  1. 核心代碼

  // 獲取github項目列表
  const repos = await repoList();

  choices = repos.map(({ name }) => name);
  answers = await inquirer.prompt([
    {
      type   : 'list',
      name   : 'repo',
      message: 'which repo do you want to install?',
      choices
    }
  ]);
  // 選擇的項目
  const repo = answers.repo;

  // 項目的版本號劣幣愛哦
  const tags = await tagList(repo);

  if (tags.length === 0) {
    version = '';
  } else {
    choices = tags.map(({ name }) => name);

    answers = await inquirer.prompt([
      {
        type   : 'list',
        name   : 'version',
        message: 'which version do you want to install?',
        choices
      }
    ]);
    version = answers.version;
  }
  // 下載
  await download([repo, version].join('@'));

生成項目

  1. 使用
project init
  1. 邏輯
獲取本地倉庫列表 ===> 選擇一個本地項目 ===> 輸入基本信息 ===> 編譯生成到臨時文件 ===> 複製並重名到目標目錄

若中間每一步 數據爲空/文件不存在/生成目錄已重複 則給予提示

  1. 核心代碼

  // 獲取本地倉庫項目
  const list = await readdir(dirs.download);

  // 基本信息
  const answers = await inquirer.prompt([
    {
      type   : 'list',
      name   : 'scaffold',
      message: 'which scaffold do you want to init?',
      choices: list
    }, {
      type   : 'input',
      name   : 'dir',
      message: 'project name',
      // 必要的驗證
      async validate(input) {
        const done = this.async();

        if (input.length === 0) {
          done('You must input project name');
          return;
        }

        const dir = resolve(process.cwd(), input);

        if (await exists(dir)) {
          done('The project name is already existed. Please change another name');
        }

        done(null, true);
      }
    }
  ]);
  const metalsmith = await rc('metalsmith');
  if (metalsmith) {
    const tmp = `${dirs.tmp}/${answers.scaffold}`;
    // 複製一份到臨時目錄,在臨時目錄編譯生成
    await copy(`${dirs.download}/${answers.scaffold}`, tmp);
    await metal(answers.scaffold);
    await copy(`${tmp}/${dirs.metalsmith}`, answers.dir);
    // 刪除臨時目錄
    await rmfr(tmp);
  } else {
    await copy(`${dirs.download}/${answers.scaffold}`, answers.dir);
  }

其中模板引擎編譯實現核心代碼如下:

// metalsmith邏輯
function metal(answers, tmpBuildDir) {
    return new Promise((resolve, reject) => {
    metalsmith
      .metadata(answers)
      .source('./')
      .destination(tmpBuildDir)
      .clean(false)
      .use(render())
      .build((err) => {
        if (err) {
          reject(err);
          return;
        }
        resolve(true);
      });
  });
}
// metalsmith render中間件實現
function render() {
    return function _render(files, metalsmith, next) {
    const meta = metalsmith.metadata();

    /* eslint-disable */
    
    Object.keys(files).forEach(function(file){
      const str = files[file].contents.toString();

      consolidate.swig.render(str, meta, (err, res) => {
        if (err) {
          return next(err);
        }

        files[file].contents = new Buffer(res);
        next();
      });
    })
    
  }
}

升級/降級版本

  1. 使用
project update
  1. 邏輯
獲取本地倉庫列表 ===> 選擇一個本地項目 ===> 獲取版本信息列表 ===> 選擇一個版本 ===> 覆蓋原有的版本文件

若中間每一步 數據爲空/文件不存在 則給予提示

  1. 核心代碼
  // 獲取本地倉庫列表
  const list = await readdir(dirs.download);

  // 選擇一個要升級的項目
  answers = await inquirer.prompt([
    {
      type   : 'list',
      name   : 'scaffold',
      message: 'which scaffold do you want to update?',
      choices: list,
      async validate(input) {
        const done = this.async();

        if (input.length === 0) {
          done('You must choice one scaffold to update the version. If not update, Ctrl+C');
          return;
        }

        done(null, true);
      }
    }
  ]);

  const repo = answers.scaffold;

  // 獲取該項目的版本信息
  const tags = await tagList(repo);

  if (tags.length === 0) {
    version = '';
  } else {
    choices = tags.map(({ name }) => name);

    answers = await inquirer.prompt([
      {
        type   : 'list',
        name   : 'version',
        message: 'which version do you want to install?',
        choices
      }
    ]);
    version = answers.version;
  }
  // 下載覆蓋文件
  await download([repo, version].join('@'))

搜索

搜索遠程的github倉庫有哪些項目列表

  1. 使用

project search
  1. 邏輯
獲取github項目列表 ===> 輸入搜索的內容 ===> 返回匹配的列表

若中間每一步 數據爲空 則給予提示

  1. 核心代碼
 const answers = await inquirer.prompt([
    {
      type   : 'input',
      name   : 'search',
      message: 'search repo'
    }
  ]);

  if (answers.search) {
    let list = await searchList();

    list = list
      .filter(item => item.name.indexOf(answers.search) > -1)
      .map(({ name }) => name);

    console.log('');
      if (list.length === 0) {
          console.log(`${answers.search} is not found`);
      }
      console.log(list.join('\n'));
      console.log('');
  }

總結

以上是這款通用腳手架產生的背景,針對用戶以及具體實現,該腳手架目前還有一些可以優化的地方:

  1. 不同源,存儲不同的文件
  2. 支持離線功能

硬廣:如果您覺得project-next-cli好用,歡迎star,也歡迎fork一塊維護。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章