继续跟上川哥举办的阅读源码活动~ 掘金原文:https://juejin.cn/post/7018344866811740173#heading-4
1. 阅读前准备
这是一个vite+vue3的脚手架,目前还属于比较初版的状态,README也不是很全面,大致浏览源码后,主要有以下几点可以好好学习下:
- npm init
- 交互式初始化
- 根据选择配置生成不同模板
- 模板生成原理
项目地址: https://github.com/vuejs/create-vue
这是一个我之前完全没有接触过的项目,文章中间会拓展很多create-Vue之外的内容,最终希望自己可以通过阅读源码,产出cli工具,方便在工作中项目开发
2. 使用它
使用起来非常简单,只需要 npm init 即可
npm init vue@next
接着输入项目名、确认是否需要额外的配置项,即可生成一个完整的项目
最终只需要cd vue-project、npm install、npm run dev即可非常急速的打开这个项目
3. 阅读源码
首先克隆项目、安装依赖
git clone https://github.com/vuejs/create-vue.gitnpm install
进入package.json看看可执行的脚本
看到几个熟悉的脚本:
- build:将脚手架主文件index.js打包输出成outfile.cjs
- test:执行测试用例
3.1 snapshot
执行npm run test时,会先执行npm run pretest (第三期的知识点:npm钩子函数)
看看snapshot中都做了什么事情~
进入snapshot发现这里做了两件事:
- 调用node的子进程方法中同步进程函数,根据index.js文件中的规则,在playground文件夹下创建不同的模板,方便调用test.js进行测试
- 根据五种拓展属性,进行二进制的排列组合,最终会生成31种+default一共32种组合
这里主要看一下生成模板的方法:
// 获取基本路径const __dirname = path.dirname(new URL(import.meta.url).pathname).substring(process.platform === 'win32' ? 1 : 0)// 这里的bin是执行打包后的index.js文件生成项目模板,方便调试就改成了index.js// const bin = path.resolve(__dirname, './outfile.cjs')const bin = path.resolve(__dirname, './index.js')// 目标文件夹const playgroundDir = path.resolve(__dirname, './playground/')function createProjectWithFeatureFlags(flags) {// flags会以数组的形式传入,如果出现多个参数时,用-分割const projectName = flags.join('-')console.log(`Creating project ${projectName}`)// 调用子进程// 这部分会生成一个类似于 node ./index.js --typescript --force的命令// 主要就是调用index.js中的init方法生成模板const { status } = spawnSync('node',[bin, projectName, ...flags.map((flag) => `--${flag}`), '--force'],{cwd: playgroundDir,stdio: ['pipe', 'pipe', 'inherit']})if (status !== 0) {process.exit(status)}}
这里主要的灵魂就是调起子进程,执行文件生成项目模板
接着看看具体是怎么生成模板的
3.2 index.js
这部分主要做了以下几件事:
- 支持feature Flags直接生成模板
- 调用
prompts进行交互式配置 - 根据配置调用render()渲染模板
- 根据配置生成不同README
3.2.1 交互式
首先在script中添加一行"dev":"node index.js"运行调试,开始我们的debug
最先进入的会是一段交互式配置,这部分代码将做省略,保留一些调用了工具方法的配置
let result = {}try {result = await prompts([{name: 'shouldOverwrite',// 这里判断了一次改名称是否可写入type: () => (canSafelyOverwrite(targetDir) || forceOverwrite ? null : 'confirm'),message: () => {const dirForPrompt =targetDir === '.' ? 'Current directory' : `Target directory "${targetDir}"`return `${dirForPrompt} is not empty. Remove existing files and continue?`}},{name: 'overwriteChecker',type: (prev, values = {}) => {if (values.shouldOverwrite === false) {throw new Error(red('✖') + ' Operation cancelled')}return null}},{name: 'packageName',// 这里判断了一次当前包名称是否合法type: () => (isValidPackageName(targetDir) ? null : 'text'),message: 'Package name:',// 这里调用了转换包名工具initial: () => toValidPackageName(targetDir),validate: (dir) => isValidPackageName(dir) || 'Invalid package.json name'},],)} catch (cancelled) {console.log(cancelled.message)process.exit(1)}
这三个工具函数也比较简单,通过正则或node方法,进行简单的判断
// 使用正则判断当前包名是否合法,没有采用validate-npm-package-name(第七期检测包名是否合法的工具),自己实现了检测功能function isValidPackageName(projectName) {return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(projectName)}// 对传入的包名称进行转写function toValidPackageName(projectName) {return projectName.trim().toLowerCase().replace(/\s+/g, '-').replace(/^[._]/, '').replace(/[^a-z0-9-~]+/g, '-')}// 使用node的内置方法,判断本地是否存在该名称的文件夹,以及该文件夹下是否有文件function canSafelyOverwrite(dir) {return !fs.existsSync(dir) || fs.readdirSync(dir).length === 0}
在执行完所有的交互配置后,会在文件内部result属性中记录最终的配置结果:
3.2.2生成模板
生成完所有配置后,会根据配置进行模板生成,具体生成顺序如下:
- 生成base基础模板
- 根据不同配置需求,继续添加合并模板
- 如果需要typeScript,将所有.js文件改成.ts,并将jsconfig.json改成tsconfig.json
- 如果不需要测试,就把测试相关文件删除
- 生成README文档
第一步!生成模板
import renderTemplate from './utils/renderTemplate.js'// 设置模板文件地址const templateRoot = path.resolve(__dirname, 'template')// 生成模板主函数const render = function render(templateName) {// 具体要生成的模板目录下的子目录const templateDir = path.resolve(templateRoot, templateName)// 传入子目录和项目的地址renderTemplate(templateDir, root)}// Render base templaterender('base')
接着看看具体生成模板的流程,同样也分成三部分:
- 递归copy所有的模板文件进项目中
- 如果文件中存在package.json,进行对象合并
- 将以
_开头的文件替换成.开头
第一部分:处理文件夹
const stats = fs.statSync(src)// 如果传入的src是文件夹,就递归调用renderTemplate处理文件夹下的每一个文件if (stats.isDirectory()) {// if it's a directory, render its subdirectories and files recursivelyfs.mkdirSync(dest, { recursive: true })for (const file of fs.readdirSync(src)) {renderTemplate(path.resolve(src, file), path.resolve(dest, file))}return}
第二部分:处理package.json文件:
import deepMerge from './deepMerge.js'import sortDependencies from './sortDependencies.js'const filename = path.basename(src)// 如果文件名称为package.json且该文件已经存在if (filename === 'package.json' && fs.existsSync(dest)) {// 存放需要操作的文件const existing = JSON.parse(fs.readFileSync(dest))// 存放需要拷贝的文件const newPackage = JSON.parse(fs.readFileSync(src))// 重点!:调用了两个函数,deepMerge用于处理两个json的合并,sortDependencies用于排序const pkg = sortDependencies(deepMerge(existing, newPackage))fs.writeFileSync(dest, JSON.stringify(pkg, null, 2) + '\n')return}
处理文件过程中使用到两个函数:deepMerge、sortDependencies,分别用于处理合并和排序
继续深入看deepMerge,这部分主要是对 对象和数组的处理:
- 处理对象时,通过递归赋值进行处理
- 处理数组时,通过解构合并进行处理 ```typescript const isObject = (val) => val && typeof val === ‘object’ const mergeArrayWithDedupe = (a, b) => Array.from(new Set([…a, …b]))
/**
- Recursively merge the content of the new object to the existing one
- @param {Object} target the existing object
@param {Object} obj the new object */ function deepMerge(target, obj) { // 循环传入的template中的模板 for (const key of Object.keys(obj)) { const oldVal = target[key] const newVal = obj[key]
//如果参数的内容是数组
if (Array.isArray(oldVal) && Array.isArray(newVal)) { // 使用解构进行合并 target[key] = mergeArrayWithDedupe(oldVal, newVal) } else if (isObject(oldVal) && isObject(newVal)) { // 如果参数内容是对象,就继续递归处理,直到是内容 target[key] = deepMerge(oldVal, newVal) } else { // 直接为内容时,直接赋值 target[key] = newVal } }
return target }
合并后继续做排序的处理,这时会进入到`sortDependencies`函数中```typescriptexport default function sortDependencies(packageJson) {const sorted = {}// 依照数组中元素的顺序,进行遍历排序const depTypes = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']for (const depType of depTypes) {if (packageJson[depType]) {sorted[depType] = {}Object.keys(packageJson[depType]).sort().forEach((name) => {sorted[depType][name] = packageJson[depType][name]})}}return {...packageJson,...sorted}}
第三部分,修改文件名
项目中以.开头的文件都是一些配置文件,而如果直接在模板中存放这些文件时,可能会造成编译器识别的一些影响,所以在模板中,都以_开头存放文件,在生成模板文件时再进行改名
这里比较简单,就是一个node.path的调用
if (filename.startsWith('_')) {// rename `_file` to `.file`dest = path.resolve(path.dirname(dest), filename.replace(/^_/, '.'))}
至此,一个模板已经完全生成!
让我们进入简单的第二步:根据自定义配置继续生成模板
这部分比较简单,从之前的配置信息中,生成调用不同路径下的文件,继续生成模板
// Add configs.if (needsJsx) {render('config/jsx')}if (needsRouter) {render('config/router')}if (needsVuex) {render('config/vuex')}if (needsTests) {render('config/cypress')}if (needsTypeScript) {render('config/typescript')}// Render code template.// prettier-ignoreconst codeTemplate =(needsTypeScript ? 'typescript-' : '') +(needsRouter ? 'router' : 'default')render(`code/${codeTemplate}`)// Render entry file (main.js/ts).if (needsVuex && needsRouter) {render('entry/vuex-and-router')} else if (needsVuex) {render('entry/vuex')} else if (needsRouter) {render('entry/router')} else {render('entry/default')}
继续进入第三步:使用typeScript
这部分比较简单,递归遍历所有后缀为.js的文件,替换成.ts,以及将jsconfig.json转换成tsconfig.json(简单粗暴),在这个过程中调用了preOrderDirectoryTraverse方法,我们重点细看这部分方法
if (needsTypeScript) {// rename all `.js` files to `.ts`// rename jsconfig.json to tsconfig.jsonpreOrderDirectoryTraverse(root,() => {},(filepath) => {// 如果后缀为.jsif (filepath.endsWith('.js')) {// 进行替换fs.renameSync(filepath, filepath.replace(/\.js$/, '.ts'))// 同时如果为jsconfig.json} else if (path.basename(filepath) === 'jsconfig.json') {// 也进行替换fs.renameSync(filepath, filepath.replace(/jsconfig\.json$/, 'tsconfig.json'))}})// Rename entry in `index.html`const indexHtmlPath = path.resolve(root, 'index.html')const indexHtmlContent = fs.readFileSync(indexHtmlPath, 'utf8')fs.writeFileSync(indexHtmlPath, indexHtmlContent.replace('src/main.js', 'src/main.ts'))}
继续进入preOrderDirectoryTraverse
// 这里主要是做文件夹和文件的递归export function preOrderDirectoryTraverse(dir, dirCallback, fileCallback) {// 首先读取该路径下所有文件for (const filename of fs.readdirSync(dir)) {const fullpath = path.resolve(dir, filename)// 如果是一个文件夹if (fs.lstatSync(fullpath).isDirectory()) {// 执行文件夹的回调dirCallback(fullpath)// in case the dirCallback removes the directory entirely// 如果文件夹没有被删除,还存在if (fs.existsSync(fullpath)) {// 继续递归里面的文件preOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)}continue}// 执行文件的回调fileCallback(fullpath)}}
至此,ts的转化也已经完成~
进入第四步:删除测试相关文件
调用fs.rmdirSync删除文件夹时,只能删除空文件夹,所以需要通过遍历文件夹以及文件夹内所有的文件,先删除文件,再删除文件夹
这里同样调用了preOrderDirectoryTraverse方法,
并且在文件夹回调函数中调用了emptyDir方法
if (!needsTests) {preOrderDirectoryTraverse(root,(dirpath) => {const dirname = path.basename(dirpath)// 如果文件夹为测试文件夹if (dirname === 'cypress' || dirname === '__tests__') {// 调用方法进行删除emptyDir(dirpath)fs.rmdirSync(dirpath)}},() => {})}function emptyDir(dir) {// 继续文件夹递归postOrderDirectoryTraverse(dir,(dir) => fs.rmdirSync(dir),(file) => fs.unlinkSync(file))}export function postOrderDirectoryTraverse(dir, dirCallback, fileCallback) {for (const filename of fs.readdirSync(dir)) {const fullpath = path.resolve(dir, filename)// 如果是文件夹,就继续递归if (fs.lstatSync(fullpath).isDirectory()) {postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)dirCallback(fullpath)continue}// 如果是文件,调用文件回调函数fileCallback(fullpath)}}
最后第五步:生成README
fs.writeFileSync(path.resolve(root, 'README.md'),generateReadme({projectName: result.projectName || defaultProjectName,packageManager,needsTypeScript,needsTests}))
4. 总结
这次的cli源码阅读能感知到自己的知识短板,在执行流程能大概读懂外,对于很多封装方法中涉及到的算法、node指令,阅读起来还是比较吃力,需要反复使用debug和查阅node文档进行理解。
结合第7期,可以发现在制作脚手架、开源库时,有很多工具库可以直接使用,加快开发速度,例如prompts交互式生成配置,就是一个很不错的库
后续还会反复阅读这个库的源码,学习和借鉴制作cli的流程,开发一个在工作团队内部可以方便快速拉取模板代码库的cli,并总结一篇制作cli工具的文章
