前言
转载自搜狐-前端工程化-打造企业通用脚手架
随着前端工程化的概念越来越深刻FEer心,前端开发过程的技术选型、代码标准、构建公布等流程的规范化、标准化是须要工具来保驾护航的,而不是每次都对反复工作进行手动复制粘贴。脚手架则可作为工程化的辅助工具,从很大水平上为前端研发提效。
脚手架是什么?
那脚手架是什么呢?
在以往工作中,咱们可能须要先做如下操作能力开始编写业务代码:
- 技术选型
- 初始化我的项目,抉择包管理工具,装置依赖
- 编写根底配置项
- 配置本地服务,启动我的项目
- 开始编码
随着Vue/React
的衰亡,咱们能够借助官网提供的脚手架vue-cli
或create-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
示意command,projectName
示意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 typeCheck
再build
最初能力提交代码。
focus create projectName
外围流程
对依赖项做了初步理解并做好筹备工作后,咱们再来理解外围性能focus create xxx
的流程。
- 在终端运行
focus create xxx
,会先借助figlet
打印logo - 借助
semver
获取无效版本号后,设置N天
后自动检测最新版本提醒是否要更新
- 在终端运行
- 联合
Gitlab API
能力通过axios
拉取所有的模板我的项目并列举以供选择
- 联合
- 抉择具体模板后,拉取该模板所有Tags
- 抉择具体Tag后,须要装置依赖时所须要的包管理工具
npm/yarn
- 抉择具体Tag后,须要装置依赖时所须要的包管理工具
- 应用
download-git-repo
在Gitlab
中拉取具体模板具体Tag,并缓存到.focusTemplate
中
- 应用
- 如果模板我的项目中没提供
ask-for-cli.js
文件,则应用ncp
间接拷贝代码到本地 - 如果存在则应用
inquirer
依据用户输出和抉择渲染(consolidate.ejs
)变量最终通过metalsmith
遍历所有文件做插入批改
- 如果模板我的项目中没提供
- 装置依赖,并执行
git init
初始化仓库
- 装置依赖,并执行
- 实现
外围代码实现
其中值得关注的在第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-case
,repoNameZh
值为智慧案场
最终会将README.md
渲染成如下内容
## 智慧案场我的项目拜访地址 smart-case.focus.cn
小结
咱们还能将变量应用到我的项目的其余配置,例如publicPath、base、baseURL...
通过以上步骤实现了我的项目的初始化,组内的新同学不用关注各种繁琐的配置,即可欢快的进入业务编码。
focus add material
外围流程
在开发一个页面的过程中,你可能须要如下几个步骤
- 在
src/pages/
新建NewPage
目录,以及index.tsx/index.less/index.d.ts
- 在
- 在
src/models/
新建NewPage.ts
文件,去做状态治理
- 在
- 在
src/servers/
新建NewPage.ts
文件,去治理接口调用
- 在
- 在
config/routes.ts
文件中插入一条NewPage
的路由
- 在
每次新增页面都须要这么繁琐的操作,咱们其实也能将以上步骤集成到脚手架中,通过一行命令、抉择即可失去成果。
大抵思路如下
- 当时筹备好
index.tsx/index.less/index.d.ts/models.ts/servers.ts
模板,可依据性能再做细分,例如常见的List
页面、Drawer
组件...
- 当时筹备好
- 将模板拷贝到指定的目录下
- 利用
jscodeshift
读取我的项目的路由配置文件,而后插入一条路由
- 利用
- 实现
外围代码实现
在
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, });}`);
在
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
一条路由。
再在
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/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 projectName
和focus add material
的应用和外围实现论述了脚手架@focus/cli
在前端研发过程的所起到提效作用。咱们实现了对创立我的项目+集成通用代码和常见痛点的解决方案(疾速生成页面并配置路由...)。
- [x] 创立我的项目+集成通用代码
- [x] 常见痛点的解决方案(疾速生成页面并配置路由...)
- [ ] 配置(eslint、tsconfig、prettier...)
- [ ] 提效工具(拷贝各种文件)
- [ ] 插件(解决webpack构建流程中的某个问题...)
咱们还基于特定业务场景对下面的下三项做了局部反对,使得咱们在开发过程中重工具、轻工程,大大提高了交付速度,也能让组内研发同学参加进来独特构建。比如说实现通过脚手架新建脚手架?通过脚手架新建所有物料?
总结
上述代码寄存在仓库@careteen/cli。
原文地址:搜狐-前端工程化-打造企业通用脚手架
脚手架的外围指标是晋升前端研发整个流程的效力。尽管脚手架没有固定状态,在不同公司有不同实现,他是有必须具备的因素。
- 从性能实现的角度,要思考与业务的高度匹配。
- 从底层框架的角度,要具备高度的可扩展性和执行环境多样性反对。