前言
随着前端工程化的概念越来越深入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
编译。
{
"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构建流程中的某个问题...)
我们还基于特定业务场景对上面的下三项做了部分支持,使得我们在开发过程中重工具、轻工程,大大提高了交付速度,也能让组内研发同学参与进来共同构建。比如说实现通过脚手架新建脚手架?通过脚手架新建一切物料?
总结
脚手架的核心目标是提升前端研发整个流程的效能。虽然脚手架没有固定形态,在不同公司有不同实现,他是有必须具备的要素。
- 从功能实现的角度,要考虑与业务的高度匹配。
- 从底层框架的角度,要具备高度的可扩展性和执行环境多样性支持。