前言

随着前端工程化的概念越来越深入FEer心,前端开发过程的技术选型、代码规范、构建发布等流程的规范化、标准化是需要工具来保驾护航的,而不是每次都对重复工作进行手动复制粘贴。脚手架则可作为工程化的辅助工具,从很大程度上为前端研发提效

脚手架是什么?

那脚手架是什么呢?

在以往工作中,我们可能需要先做如下操作才能开始编写业务代码:

  • 技术选型
  • 初始化项目,选择包管理工具,安装依赖
  • 编写基础配置项
  • 配置本地服务,启动项目
  • 开始编码

随着Vue/React的兴起,我们可以借助官方提供的脚手架vue-clicreate-react-app在命令行中通过选择输入来按我们的要求和喜好快速生成项目。它们能让我们专注于代码,而不是构建工具。

脚手架能力

但是这些脚手架是针对于具体语言(Vue/React)的,而在我们实际工作中不同BU针对不同端(PC、Wap、小程序…)所采用的技术栈也可能不同,往往特定端采用的技术栈在一定程度上都可以复用的到其他类似项目中。我们更期望能在命令行通过几个命令和选择、输入构建出不同端不同技术栈的项目。

上述只是新建项目的例子,前端开发过程中不止于此,一般有如下场景:

  • 创建项目+集成通用代码。项目模板中包含大量通用代码,比如通用工具方法、通用样式、通用请求库处理HTTP请求、内部组件库、埋点监控…

  • Git操作。一般需要手动在Gitlab中创建仓库、解决代码冲突、远程代码同步、创建版本、发布打Tag…等操作。

  • CICD。业务代码编写完成后,还需要对其进行构建打包、上传服务器、域名绑定、区分测试正式环境、支持回滚…等持续集成、持续部署操作。

为什么不用自动化构建工具

一般情况下,我们会采用Jenkins、Gitlab CI、Webhooks等进行自动化构建,为什么还需要脚手架?

因为这些自动化构建工具都是在服务端执行的,在云端就无法覆盖研发同学本地的功能,比如上述创建项目、本地Git操作等;并且这些自动化工具定制过程需要开发插件,前端同学对语言和实现需要一定学习和时间成本,前端同学也更期望只使用JavaScript就能实现这些功能。

脚手架核心价值

综上,前端脚手架存在意义重大。脚手架的核心目标是提升前端研发整个流程的效能。

  • 自动化。避免项目重复代码拷贝删改的场景;将项目周期内的Git操作自动化。

  • 标准化。快速根据模板创建项目;提供CICD能力。

  • 数据化。通过对脚手架自身埋点统计,将耗时量化,形成直观对比。

往往各个公司对于自动化标准化的部分功能Git操作、CICD都有实现一套完善的类似于代码发布管理系统,帮助我们在Gitlab上管理项目,并提供持续集成、持续部署的能力。更有甚者,针对小程序的项目也会对其进行代码发布管理,将其规范化。

我们可能就只需要考虑

  • 创建项目+集成通用代码

  • 常见痛点的解决方案(快速生成页面并配置路由…)

  • 配置(eslint、tsconfig、prettier…)

  • 提效工具(拷贝各种文件)

  • 插件(解决webpack构建流程中的某个问题…)

下面则介绍我们在公司内部基于这些场景所做的尝试。

使用脚手架

首先在终端通过focus create projectName命令新建一个项目。其中focus表示主命令create表示commandprojectName表示command的param。然后根据终端交互去选择和输入最终生成项目。

我们为各个BU、各个端、各个技术栈提供不同模板项目,于此同时,每个同学都能将小组内的项目沉淀并提炼成一个模板项目,并按一定规范集成到脚手架中,反哺整个BU。

@focus/cli架构

如下架构图,采用Lerna做项目的管理工具,目前babel、vue-cli、create-react-app大型项目均采用Lerna进行管理。它的优势在于:

  • 大幅减少重复操作。多个Package时的本地link、单元测试、代码提交、代码发布,可以通过Lerna一键操作。
  • 提升操作的标准化。多个Package时的发布版本和相互依赖可以通过Lerna保持一致性。

@focus/cli脚手架中,根据功能进行拆分:

  • @focus/cli存放脚手架主要功能
  • focus create projectName拉取模板项目
  • focus add material新建物料,可以是一个package、page、component...粒度可大可小
  • focus cache清除缓存、配置文件信息、临时存放的模板
  • focus domain拷贝配置文件
  • focus upgrade更新脚手架版本,也有自动询问更新机
  • @focus/eslint-config-focus-fe存放组内统一的eslint规则
  • 也可通过focus add material新建子Package实现特定功能…

依赖项概览

一个脚手架核心功能需要依赖以下基础库去做支撑。

  • chalk:控制台字符样式
  • commander:node.js命令行接口的完整解决方案
  • fs-extra:增强的基础文件操作库
  • inquirer:实现命令行之间的交互
  • ora:优雅终端Spinner等待动画
  • axios:结合Gitlab API获取仓库列表、Tags…
  • download-git-repo:从Github/Gitlab中拉取仓库代码
  • consolidate :模板引擎整合库。主要使用ejs实现模板字符替换
  • ncp :像cp -r一样拷贝目录、文件
  • metalsmith :可插入的静态网站生成器;例如获取到根据用户自定义的输入或选择配合ejs渲染变量后的最终内容后,通过它做插入修改。
  • semver :获取库的有效版本号
  • ini :一个用于节点的ini格式解析器和序列化器。主要是对配置做编码和解码。
  • jscodeshift :可以解析文件将代码从AST-to-AST。例如新建一个页面后需要在routes.ts中新建一份路由。

采用Typescript编码,使用babel编译。

提示💡

除了tsc之外,babel7也能编译typescript代码了,这是两个团队合作一年的结果。但是babel因为单文件编译的特点,做不了和tsc的多文件类型编译一样的效果,有几个特性不支持(主要是 namespace 的跨文件合并、导出非 const 的值),不过影响不大,整体是可用的。babel 做代码编译,还是需要用 tsc 来进行类型检查,单独执行 tsc --noEmit 即可。引用自为什么说用 babel 编译 typescript 是更好的选择

{
  "scripts": {
    "dev""npx babel src -d lib -w -x \".ts, .tsx\"",
    "build""npx babel src -d lib -x \".ts, .tsx\"",
    "lint""eslint src/**/*.ts --ignore-pattern src/types/*",
    "typeCheck""tsc --noEmit"
  },  
}

pre-commit中需要先npm run lint && npm run typeCheckbuild最后才能提交代码。

focus create projectName核心流程

对依赖项做了初步了解并做好准备工作后,我们再来了解核心功能focus create xxx的流程。

  1. 在终端运行focus create xxx,会先借助figlet打印logo
    • 借助semver获取有效版本号后,设置N天后自动检测最新版本提示是否要更新
  2. 结合Gitlab API能力通过axios拉取所有的模板项目并罗列以供选择
  3. 选择具体模板后,拉取该模板所有Tags
  4. 选择具体Tag后,需要安装依赖时所需要的包管理工具npm/yarn
  5. 使用download-git-repoGitlab中拉取具体模板具体Tag,并缓存到.focusTemplate
  6. 如果模板项目中没提供ask-for-cli.js文件,则使用ncp直接拷贝代码到本地
    • 如果存在则使用inquirer根据用户输入和选择渲染(consolidate.ejs)变量最终通过metalsmith遍历所有文件做插入修改
  7. 安装依赖,并执行git init初始化仓库
  8. 完成

核心代码实现

其中值得关注的在第6步

src/create/index.ts中实现拷贝

// 拷贝操作
if (!fs.existsSync(path.join(result, CONFIG.ASK_FOR_CLI as string))) {
  // 不存在直接拷贝到本地
  await ncp(result, path.resolve(projectName));
  successTip();
else {
  const args = require(path.join(result, CONFIG.ASK_FOR_CLI as string));
  await new Promise<void>((resolvereject=> {
    MetalSmith(__dirname)
      .source(result)
      .destination(path.resolve(projectName))
      .use(async (filesmetaldone=> {
        // requiredPrompts 没有时取默认导出
        const obj = await Inquirer.prompt(args.requiredPrompts || args);
        const meta = metal.metadata();
        Object.assign(meta, obj);
        delete files[CONFIG.ASK_FOR_CLI];
        done(null, files, metal);
      })
      .use((filesmetaldone=> {
        const obj = metal.metadata();
        const effectFiles = args.effectFiles || [];
        Reflect.ownKeys(files).forEach(async (file=> {
          // effectFiles 为空时 就都需要遍历
          if (effectFiles.length === 0 || effectFiles.includes(file)) {
            let content = files[file as string].contents.toString();
            if (/<%=([\s\S]+?)%>/g.test(content)) {
              content = await ejs.render(content, obj);
              files[file as string].contents = Buffer.from(content);
            }
          }
        });
        successTip();
        done(null, files, metal);
      })
      .build((err=> {
        if (err) {
          reject();
        } else {
          resolve();
        }
      });
  });
}

ask-for-cli.js中配置变量

// 需要根据用户填写修改的字段
const requiredPrompts = [
  {
    type: 'input',
    name: 'repoNameEn',
    message: 'please input repo English Name ? (e.g. `smart-case`.focus.cn)',
  },
  {
    type: 'input',
    name: 'repoNameZh',
    message: 'please input repo Chinese Name ?(e.g. `智慧案场`)',
  },
];
// 需要修改字段所在文件
const effectFiles = [
  `README.md`,
  `code/package.json`,
  `code/client/package.json`,
  `code/client/README.md`,
  // ...
]
module.exports = {
  requiredPrompts,
  effectFiles,
};

README.md中使用ejs变量语法占位

## <%=repoNameZh%>项目
 
访问地址 <%=repoNameEn%>.focus.cn

例如用户输入repoNameEn值为smart-caserepoNameZh值为智慧案场

最终会将README.md渲染成如下内容

## 智慧案场项目 访问地址 smart-case.focus.cn 复制

小结

我们还能将变量使用到项目的其他配置,例如publicPath、base、baseURL...

通过以上步骤实现了项目的初始化,组内的新同学不必关注各种繁琐的配置,即可愉快的进入业务编码。

focus add material核心流程

在开发一个页面的过程中,你可能需要如下几个步骤

  1. src/pages/新建NewPage目录,以及index.tsx/index.less/index.d.ts
  2. src/models/新建NewPage.ts文件,去做状态管理
  3. src/servers/新建NewPage.ts文件,去管理接口调用
  4. config/routes.ts文件中插入一条NewPage的路由

每次新增页面都需要这么繁琐的操作,我们其实也能将以上步骤集成到脚手架中,通过一行命令、选择即可得到效果。

大致思路如下

  1. 事先准备好index.tsx/index.less/index.d.ts/models.ts/servers.ts模板,可根据功能再做细分,例如常见的List页面、Drawer组件…
  2. 将模板拷贝到指定的目录下
  3. 利用jscodeshift读取项目的路由配置文件,然后插入一条路由
  4. 完成

核心代码实现

  1. src/add/umi.page/template.ts中准备好jsContent/cssContent/modelsContent/servicesContent模板
export const jsContent = `
import React from 'react';
import './index.less';
interface IProps {}
const Page: React.FC<IProps> = (props) => {
  console.log(props);
  return <div>Page</div>;
};
`;
 
export const cssContent = `
// TODO: write here ...
`;
 
export const modelsContent = (upperPageName: stringlowerPageName: string=> (`
import type { Effect, Reducer } from 'umi';
import {
  get${upperPageName}List,
} from '@/services/${lowerPageName}';
 
export type ${upperPageName}ModelState = {
  ${lowerPageName}List: {
    list: any[];
  };
};
 
export type ${upperPageName}ModelType = {
  namespace: string;
  state: ${upperPageName}ModelState;
  effects: {
    get${upperPageName}List: Effect;
  };
  reducers: {
    updateState: Reducer;
  };
};
 
const ${upperPageName}Model: ${upperPageName}ModelType = {
  namespace: '${lowerPageName}',
 
  state: {
    ${lowerPageName}List: {
      list: [],
    },
  },
 
  effects: {
    *get${upperPageName}List({ payload }, { call, put }) {
      const res = yield call(get${upperPageName}List, payload);
      yield put({
        type: 'updateState',
        payload: {
          ${lowerPageName}List: {
            list: res ? res.map((l: any) => ({
              ...l, 
              id: l.${lowerPageName}Id,
              key: l.${lowerPageName}Id,
            })) : []
          },
        },
      });
    },
  },
 
  reducers: {
    updateState(state, action) {
      return {
        ...state,
        ...action.payload,
      };
    },
  },
};
export default ${upperPageName}Model;
`);
 
export const servicesContent = (upperPageName: stringlowerPageName: string=> (`
import { MainDomain } from '@/utils/env';
import request from './decorator';
export async function get${upperPageName}List(
  params: any,
): Promise<any> {
  return request(\`\${MainDomain}/${lowerPageName}\`, {
    params,
  });
}
`);
  1. src/add/umi.page/index.ts中将拷贝的目的地址和模板做映射
import fs from 'fs';
import path from 'path';
import jf from 'jscodeshift';
import {
  cssContent,
  jsContent,
  modelsContent,
  servicesContent,
from './template';
import { firstToUpper, getUmiPrefix } from '../../../utils/util';
import { IGenerateRule } from '../../../index.d';
 
module.exports = (cwdDir: stringpageName: string): IGenerateRule => {
  const lowerPageName = pageName.toLocaleLowerCase();
  const upperPageName = firstToUpper(pageName);
  const pagesPrefix = getUmiPrefix(cwdDir, 'src/pages');
  const modelsPrefix = getUmiPrefix(cwdDir, 'src/models');
  const servicesPrefix = getUmiPrefix(cwdDir, 'src/services');
  const routesPrefix = getUmiPrefix(cwdDir, 'config');
  const routesPath = path.resolve(cwdDir, `${routesPrefix}/routes.ts`);
  const routeContent = fs.readFileSync(routesPath, 'utf-8');
  const routeContentRoot = jf(routeContent);
  routeContentRoot.find(jf.ArrayExpression)
    .forEach((ppIndex=> {
      if (pIndex === 1) {
        p.get('elements').unshift(`{
  path: '/${pageName}', // TODO: 是否需要菜单调整位置?
  name: '${pageName}',
  component: './${upperPageName}',
}`);
      }
    });
  return {
    [`${pagesPrefix}/${upperPageName}/index.tsx`]: jsContent,
    [`${pagesPrefix}/${upperPageName}/index.less`]: cssContent,
    [`${modelsPrefix}/${lowerPageName}.ts`]: modelsContent(upperPageName, lowerPageName),
    [`${servicesPrefix}/${lowerPageName}.ts`]: servicesContent(upperPageName, lowerPageName),
    [`${routesPrefix}/routes.ts`]: routeContentRoot.toSource(),
  };
};

其中使用jscodeshift先读取项目中路由配置,找到路由的第一项,然后插入unshift一条路由。

  1. 再在src/add/index.ts中读取所有的物料模板与映射关系,最后做拷贝。
import chalk from 'chalk';
import inquirer from 'inquirer';
import path from 'path';
import { getDirName } from '../../utils/util';
import writeFileTree from '../../utils/writeFileTree';
import { UMI_DIR_ARR } from '../../utils/constants';
 
module.exports = async (pageName: string=> {
  const cwdDirArr = process.cwd().split('/');
  const cwdDirTail = cwdDirArr[cwdDirArr.length - 1];
  if (!UMI_DIR_ARR.includes(cwdDirTail)) {
    console.log(`${chalk.red('please make sure in the "src" directory when executing the "focus add material" command !')}`);
    return;
  }
  const pages = getDirName(__dirname);
  if (!pages.length) {
    console.log(`${chalk.red('please support page !')}`);
    return;
  }
  const { pageType } = await inquirer.prompt({
    name: 'pageType',
    type: 'list',
    message: 'please choose a type to add page',
    choices: pages,
  });
  const generateRule = require(path.resolve(__dirname, `${pageType}`));
  const fileTree = await generateRule(process.cwd(), pageName);
  writeFileTree(process.cwd(), fileTree);
};

小结

上面代码实现了快速新建一个页面的场景,不仅仅于此,我们能将工作中在多个文件下有关联且频繁拷贝粘贴的重复操作进行模板提炼,按一定规范放置在脚手架的src/add/目录下即可实现一键新建物料

通用能力

上述从focus create projectNamefocus add material的使用和核心实现阐述了脚手架@focus/cli在前端研发过程的所起到提效作用。我们实现了对创建项目+集成通用代码常见痛点的解决方案(快速生成页面并配置路由…)

  • [x] 创建项目+集成通用代码
  • [x] 常见痛点的解决方案(快速生成页面并配置路由…)
  • [ ] 配置(eslint、tsconfig、prettier…)
  • [ ] 提效工具(拷贝各种文件)
  • [ ] 插件(解决webpack构建流程中的某个问题…)

我们还基于特定业务场景对上面的下三项做了部分支持,使得我们在开发过程中重工具、轻工程,大大提高了交付速度,也能让组内研发同学参与进来共同构建。比如说实现通过脚手架新建脚手架?通过脚手架新建一切物料?

总结

提示💡

上述代码存放在仓库@careteen/cli。

脚手架的核心目标是提升前端研发整个流程的效能。虽然脚手架没有固定形态,在不同公司有不同实现,他是有必须具备的要素。

  • 从功能实现的角度,要考虑与业务的高度匹配。
  • 从底层框架的角度,要具备高度的可扩展性和执行环境多样性支持。