乐趣区

关于前端工程化:前端工程化打造企业通用脚手架

前言

转载自搜狐 - 前端工程化 - 打造企业通用脚手架

随着前端工程化的概念越来越深刻 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
    2. 借助 semver 获取无效版本号后,设置 N 天 后自动检测最新版本提醒是否要更新
    1. 联合 Gitlab API 能力通过 axios 拉取 所有的模板我的项目 并列举以供选择
    1. 抉择具体模板后,拉取该 模板所有 Tags
    1. 抉择具体 Tag 后,须要装置依赖时所须要的包管理工具npm/yarn
    1. 应用 download-git-repoGitlab中拉取具体模板具体 Tag,并缓存到 .focusTemplate
    1. 如果模板我的项目中没提供 ask-for-cli.js 文件,则应用 ncp 间接拷贝代码到本地
    2. 如果存在则应用 inquirer 依据用户输出和抉择渲染(consolidate.ejs)变量最终通过 metalsmith 遍历所有文件做插入批改
    1. 装置依赖,并执行 git init 初始化仓库
    1. 实现

外围代码实现

其中值得关注的在 第 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>((resolve, reject) => {MetalSmith(__dirname)
      .source(result)
      .destination(path.resolve(projectName))
      .use(async (files, metal, done) => {
        // 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((files, metal, done) => {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
    1. src/models/ 新建 NewPage.ts 文件,去做状态治理
    1. src/servers/ 新建 NewPage.ts 文件,去治理接口调用
    1. config/routes.ts 文件中插入一条 NewPage 的路由

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

大抵思路如下

    1. 当时筹备好 index.tsx/index.less/index.d.ts/models.ts/servers.ts 模板,可依据性能再做细分,例如常见的 List 页面、Drawer组件 …
    1. 将模板拷贝到指定的目录下
    1. 利用 jscodeshift 读取我的项目的路由配置文件,而后插入一条路由
    1. 实现

外围代码实现

  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: string, lowerPageName: 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: string, lowerPageName: 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,});
    }
    `);
  2. 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: string, pageName: 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((p, pIndex) => {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);
    };
  2. src/utils/writeFileTree.ts 中实现拷贝的逻辑

    import chalk from 'chalk';
    import fs from 'fs-extra';
    import path from 'path';
    const writeFileTree = async (dir: string, files: any) => {Object.keys(files).forEach((name) => {const filePath = path.join(dir, name);
     fs.ensureDirSync(path.dirname(filePath));
     fs.writeFileSync(filePath, files[name]);
     console.log(`${chalk.green(name)} write done .`);
      });
    };
    export default writeFileTree;

小结

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

通用能力

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

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

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

总结

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

原文地址: 搜狐 - 前端工程化 - 打造企业通用脚手架

脚手架的外围指标是晋升前端研发整个流程的效力。尽管脚手架没有固定状态,在不同公司有不同实现,他是有必须具备的因素。

  • 从性能实现的角度,要思考与业务的高度匹配。
  • 从底层框架的角度,要具备高度的可扩展性和执行环境多样性反对。
退出移动版