脚手架

88 阅读10分钟

脚手架开发难点:

1. 分包,将复杂的系统分为若干个模块

解决:然后使用memorepo架构,使用lerna进行包管理

使用lerna可以解决一些重复操作,比如多package本地 link,多package依赖安装,多package提交,多package单元测试

也可以解决版本一致性问题

2. 命令注册,需要注册多种命令

vue create

vue add

vue invoke

3. 参数解析

vue commander [option]

4. 帮助文档

可以使用写文档的方式或者使用global help进行对参数的介绍,如--version -v,--help -h

5. 命令行交互

6. 日志打印

7. 命令行变色

8. 文件处理

9. 网络通信 http/websocket

# memorepo

## 是一种单一代码库架构,

有以下好处:

1. 可见性(Visibility):每个人都可以看到其他人的代码,这样可以带来更好的协作和跨团队贡献——不同团队的开发人员都可以修复代码中的bug,而你甚至都不知道这个bug的存在。

2. 更简单的依赖关系管理(Simpler dependency management):共享依赖关系很简单,因为所有模块都托管在同一个存储库中,因此都不需要包管理器。

3. 唯一依赖源(Single source of truth):每个依赖只有一个版本,意味着没有版本冲突,没有依赖地狱。

4. 一致性(Consistency):当你把所有代码库放在一个地方时,执行代码质量标准和统一的风格会更容易。

5. 共享时间线(Shared timeline):API或共享库的变更会立即被暴露出来,迫使不同团队提前沟通合作,每个人都得努力跟上变化。

6. 统一的构建流程(Unified build process):代码库中的每个应用程序可以共享一致的构建流程[5]。

7. 原子提交(Atomic commits):原子提交使大规模重构更容易,开发人员可以在一次提交中更新多个包或项目。

坏处:

1. 性能差(Bad performance):单一代码库难以扩大规模,像git blame这样的命令可能会不合理的花费很长时间执行,IDE也开始变得缓慢,生产力受到影响,对每个提交测试整个repo变得不可行。

2. 破坏主线(Broken main/master):主线损坏会影响到在单一代码库中工作的每个人,这既可以被看作是灾难,也可以看作是保证测试既可以保持简洁又可以跟上开发的好机会。

3. 学习曲线(Learning curve):如果代码库包含了许多紧密耦合的项目,那么新成员的学习曲线会更陡峭。

4. 大量的数据(Large volumes of data):单一代码库每天都要处理大量的数据和提交。

5. 所有权(Ownership):维护文件的所有权更有挑战性,因为像Git或Mercurial这样的系统没有内置的目录权限。

6. Code reviews:通知可能会变得非常嘈杂。例如,GitHub有有限的通知设置,不适合大量的pull request和code review。

# 创建脚手架

1. 在脚手架入口文件中输入#! /usr/bin/env node 将文件变成可执行文件,指明要通过node来运行这个文件

2. 配置package.json中的bin,bin:{'dms-cli': 'bin/index.js'}

3. 使用npm link可将包软链到本地中,从而可以直接使用dms-cli。这个命令将在我们当前安装的node里安装一个命令,这个命令将链向全局node_module下的包,而这个包又会链向我们本地的包

(注意: package.json中的main是指当前项目作为一个库使用时的入口文件)

4. 引用本地其他包时,先将其他包进行npm link,软链到本地的全局node_module,然后再在项目中使用npm link "lib"来进行安装在本项目的node_module,最后手动在package.json的dependencies中引入"dependencies": {"test-lib": '^1.0.0'}

# lerna

是一个git+npm的多package项目的管理工具

lerna init:初始化

lerna create : 创建包

lerna add: 安装依赖,可以安装在所有的package(lerna add "依赖"),也可以单独安装在某个package(lerna add "依赖" "package/路径")

lerna link: 链接本地项目,需要现在package.json里手动引入依赖,但是多个地方link会造成混乱,我们可以直接使用file:../../的方式来引入依赖,但是要执行npm install。在lerna publish的时候,会自动帮我们替换

lerna exec: 执行shell脚本,例如lerna exec -- rm -rf node_modules/

lerna run: 执行npm命令

lerna clean: 清空依赖

lerna bootstrap:重装依赖

lerna version:

lerna changed:查看上版本依赖的所有变更

lerna diff: 查看diff

lerna publish:项目发布

## lerna源码

实现原理

1. 通过import-local 优先调用本地lerna命令

2. 通过Yargs生成脚手架,并且在生成脚手架过程中优先注册全局属性,然后再注册命令,最后通过parse方法解析参数

3. lerna命令注册时需要传入builder和handler两个方法,builder方法用于注册命令专属的options,handler用来处理命令的业务逻辑

4. lerna通过配置npm本地依赖的方式进行本地开发,具体写法是在package.json的依赖中写入:file:your-local-module-path,而不是逐一的npm link。在lerna publish时会自动将改路径替换。

### import-local

可以用来判断:当全局node_modules和本地源码都有一个脚手架命令时,优先加载本地源码的文件

__filename: 当前文件的名称

__dirname: 当前文件夹路径

1. 先通过pkg-dir获取package.json所在的路径

pkg-dir:通过findUp("package.json",{cwd})进行查找,其中**{cwd}是path.dirname(filename))**

find-up:通过path.resolve() 对相对路径做一个解析,比如传入了一个“.”,那么就会解析成当前路径,然后通过path.parse()对路径进行解析并取出root,也就是根路径。然后进行一个死循环并使用locatePath.sync([].concat(filename: 'package.json'), {cwd: dir})将传入的参数进行一个迭代的判断,来查找出匹配的第一个路径

locatePath.sync: 迭代过程中通过调用pathExists.sync(path.resolve(process.cwd), filename: 'package.json')判断路径是否存在,如果存在就返回这个filename

pathExists:其中会调用fs.accessSync()来判断路径是否存在,如果不存在会报错,所以pathExists对这个accessSync进行了try catch

locatePath匹配出并返回第一个filename之后,

find-up会继续使用path.resolve()来解析合并成一个绝对路径并返回。

pkg-dir拿到返回的路径之后,再调用path.dirname()处理返回路径并返回

最终就生成了globalDir

2. 通过path.relative取出相对路径

3. 通过path.join的方法将globalDir和“package.json”拼接成路径并将其require为一个pkg

4. 通过resolveCwd.silent(path.join(pkg.name, relativePath)来判断当前路径下是否存在这个包名,如果找不到,就没有返回结果

resolveCwd.silent():通过调用resolveFrom.silent(process.cwd(), moduleId)

resolveFrom.silent(): 传入当前执行路径

Module._nodeModulePaths:主要用于生成node_modules可能的路径。会判断路径是否为根路径,如果是,直接返回["node_modules],如果不是,就遍历各级目录,在目录后添加node_modules,并存储到paths,其中遍历的时候时倒排查找的原因时希望就近进行查找

Module._resolveFilename:主要用于解析模块的真实路径,会判断是否为内置模块,如果不是,就会调用Modules._resolveLookupPaths,将paths和环境遍历node_modules合并,然后调用Module_findPath来在paths中解析模块的真实路径

Module_findPath:会去查找缓存,如果缓存有,直接返回结果,如果没有,变量path,合并path和request,判断文件是否存在是,

调用toRealPath生成真实路径,判断路径是否存在,存在就返回结果

# 脚手架工具

## yargs

自带help,version等参数解析

### 脚手架构成

通常由三部分构成

第一部分是主命令,bin: 需要在package.json中配置bin属性,本地开发还需要npm link进行安装,并且需要在文件代码顶部添加#! /usr/bin/env node来告知当前文件使用node进行执行

第二部分是命令部分,command:

第三部分是参数: options(boolean,string,number):

### 脚手架初始化流程

1. 通过Yargs()构造函数生成一个脚手架

2. 调用一系列脚手架方法对脚手架功能进行增强

Yargs.options:注册脚手架的属性,

Yargs.option,

Yargs.group将脚手架属性进行分组,

Yargs.demandCommand, 输入的命令少于指定的个数时,会提示demandCommand输入的语句报错

Yargs.recommendCommands, 输入一个不存在的命令的时候,回到所有命令中找出模糊匹配的并给出提示

Yargs.strict严格模式,输入不能解析的参数会提示报错,

Yargs.fail对脚手架的异常进行监听,

Yags.alias, 别名

Yargs.wrap,

Yargs.epilogue

### 脚手架参数解析方法

1. hideBin(process.argv) hideBin可以直接传入process.argv进行参数解析,解析完成之后会在最后调用Yargs.argv来完成参数解析

2. Yargs.parse(argv, options)直接调用Yargs.parse api,传入要解析的参数,Yargs会自动将这些参数列表全部注入到脚手架的命令当中,这样就完成了参数解析

### 命令注册

1. 调用Yargs.command(command, describe, builder, handler) / Yargs.command({command, describe, builder, handler})进行命令注册

### 例子

const yargs = require("yargs/yargs")

const { hideBin } = require('yargs/helpers')

const arg = hideBin(process.argv) //开始参数解析

//const cli = yargs(arg)

const cli = yargs()

cli

.usage("Usage: $0 [options]")

.demandCommand(1, "A command is required, Pass --help to see all avaliable commands and options")

.recommendCommands() //输入一个不存在的命令的时候,回到所有命令中找出模糊匹配的并给出提示

.strict() //严格模式

.fail((err, msg) => {console.log('err', err)})

.alias('h', "help")

.wrap(cli.terminalWidth())//填充命令界面

.options({debug: {type: 'boolean', describe: 'Bootstrap debug mode'}, alias: 'd'}) //注册脚手架属性

.option('registry', {type: 'string', describe: 'Define global register', alias: 'r'}) //注册脚手架属性

.group(['debug', 'Dev options:']) //将属性分组

.command('init [name]', 'Do init a project', yargs => {

yargs.option('name', {

type: 'string',

describe: 'Name of a project'

});

}, argv => {

console.log(argv) //{ _: ['init'], '$0', 'cli name'}

}

.command({ //command的另一种写法

command: 'list',

aliases: ['ls', 'la', 'll'],

describe: 'List local package',

builder: yargs => {} //命令执行之前执行,一般用来注册属性

handler: argv => {} //命令执行的操作

})

//.argv //完成参数解析

.parse(process.argv.slice(2), {cliVersion: require('../package.json').version})

## commander

# Node.js 模块路径解析流程

path.resolve():将路径或路径片段解析为一个绝对路径,如果后一个参数第一个字符带了“/”,那么就会将前面参数的路径忽略掉,就好像cd操作

console.log(path.resolve('/a/b', '/c/d')) // /c/d

console.log(path.resolve('/a/b', 'c/d')) // /a/b/c/d

console.log(path.resolve('.')) //当前路径

path.join() 是将路径拼接起来

## 回答:

Node.js中项目模块路径解析是通过require.resolve方法来实现的

而require.resolve就是通过node内置方法Module._resolveFileName方法实现的,这是nodejs的一个内置方法

1. **require.resolve**实现原理:

**Module._resolveFilename**方法的核心流程有3点

1. 判断是否为内置模块,如果是,直接返回,如果不是就往下

2. 通过**Module._resolveLookupPaths**方法将paths和环境变量node_modules合并,生成node_modules可能存在的路径

3. 通过**Module._findPath**查询模块的真实路径

**Module._findPath**核心流程有4点:

1. 查询缓存,会将request和paths通过空格(/x00)合成cacheKey

2. 遍历paths,将path和request组成文件路径basePath

3. 如果basePath存在则调用**fs.realPathSync**获取文件真实路径

4. 将文件真实路径缓存到Module._pathCache,key就是前面生成的key

**fs.realPathSync**核心流程有3点:

1. 查询缓存,缓存的key为p,即为刚刚_findPath中生成的文件路径

2. 从左往右遍历路径路径字符串,查询到/时,拆分路径,判断该路径是否为软连接,如果是软链接则查询真实链接,

并生成新路径p,然后继续往后遍历,其中有一个细节需要注意:遍历过程中生成的子路径base会缓存在knownHard和cache中,避免重复查询

3. 遍历完成得到模块对应的真实路径,此时会将原始路径original作为key,真实路径作为value,保存到缓存中

注意:fs.realPathSync中使用Symbol作为一个对象参数的key ,确保key不会被覆盖

2. **require.resolve.paths**等价于Module._resolveLookupPaths,该方法用于获取所有的node_modules可能存在的路径

require.resolve.paths实现原理:

1. 如果路径为/根目录,则直接返回['/node_modules']

2. 否则,将路径字符串从后往前遍历,查询到/时,拆分路径,在后面加上node_modules,并传入一个paths数组,直至查询不到/后返回paths数组