本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
这是
学习源码整体架构系列,链接: juejin.cn/post/737836… 。
2024.07.15 更新:taro-cli Config类
2024.07.21 更新:taro-kernel(内核)
- 揭开整个框架的入口-taro init 初始化项目的秘密
- 揭开整个框架的插件系统的秘密
前言:
- 学会两种方法调试taro源码
- 学会入口 taro-cli 具体实现方式
- 学会cli init 命令实现原理,读取用户项目配置文件和用户全局配置文件
- 学会 taro-service kernel(内核)解藕实现
- 初步学会 taro 插件框架,学会如何编写一个 taro插件
1.准备工作:
# 克隆项目
git clone https://github.com/NervJS/taro.git
# 切换换到分支4.x
git checkout 4.x
# 4.0.0-beta.83 cf9dd497d284679810c175e659388842515c53c0
git checkout cf9dd497d284679810c175e659388842515c53c0
看一个开源项目,第一步应该是先看README.md再看贡献文档和package.json
环境准备
需要安装Node.js 16(建议安装16.20.0及以上版本)及pnpm7
一般使用nvm管理node版本
nvm install 18
nvm use 18
# 可以把 node 默认版本设置为 18,调试时会使用默认版本
nvm alias default 18
pnpm -v
# 9.5.0
node -v
# v18.20.2
cd taro
# 安装依赖
pnpm i
# 编译构建
pnpm build
# 删除根目录的 node_modules 和所有 workspace 里的 node_modules
pnpm run clear-all
# 对应的是:rimraf **/node_modules
# mac 下可以用 rm -rf **/node_modules
安装依赖可能会报错。
# 报错信息
Failed to set up Chromium r1108766! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.
谷歌大法:stackoverflow
Mac : export PUPPETEER_SKIP_DOWNLOAD='true' Windows: SET PUPPETEER_SKIP_DOWNLOAD='true'
pnpm build 完成,如下图所示:
2.调试
package.json
2.1 入口文件 packages/taro-cli/bin/taro
2.2 调试方法 1 JavaScript Debug Terminal
简而言之:
- 找到入口文件设置断点
- ctrl + `(反引号) 打开终端,配置JavaScript调试终端
- 在终端输入 node 相关命令,这里以
init为例 - 调试代码
node ./packages/taro-cli/bin/taro init taro-init-debug
如图所示
调试时此时应该会报错,binding taro.[os-platform].node。如图
运行报错不要慌,这时看下贡献文档应该可以找到答案,来看下贡献文档-10-rust-部分
通过rustup找到安装命令:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
安装之后,执行pnpmrun build:building:debug 或pnpm run building:release产出编译文件:
crates/native_binding/taro.darwin-arm64.node
完美解决,调试时就不会报错了。
2.3 调试方式2 配置 .vscode/launch.json
taro 文档 - 单步调测配置 写的挺好的,通过配置 launch.json 来调试,在此就不再赘述了。
不过补充一条,launch.json 文件可以添加一条"console": "integratedTerminal" (集成终端)配置,就可以在调试终端输入内容了。args参数添加init和指定要初始化项目的文件夹。当然调试其他的时候也可以修改为其他参数。比如args: ["build", "--type", "weapp", "--watch"]。
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "CLI debug",
"program": "${workspaceFolder}/packages/taro-cli/bin/taro",
// "cwd": "${project absolute path}",
"args": [
"init",
"taro-init-debug",
// "build",
// "--type",
// "weapp",
// "--watch"
],
"skipFiles": ["<node_internals>/**"]
},
]
}
console- 启动程序的控制台(internalConsole,integratedTerminal,externalTerminal)。
我们跟着断点进入,入口文件中的第一句require("../dist/util").printPkgVersion(); printPkgVersion函数,打印taro/packages/taro-cli/package.json版本号。
3. taro-cli/src/utils/index.ts
工具函数
import { chalk, fs, isWindows } from '@tarojs/helper'
import { exec } from 'child_process'
import * as path from 'path'
export function getRootPath (): string {
return path.resolve(__dirname, '../../')
}
export function getPkgVersion (): string {
return require(path.join(getRootPath(), 'package.json')).version
}
export function printPkgVersion () {
const taroVersion = getPkgVersion()
console.log(`👽 Taro v${taroVersion}`)
console.log()
}
// code...
// 代码有删减
4. CLI 整体结构
这个文件整体结构。class CLI 包含一个appPath属性(一般指taro工作目录),两个函数(run和parseArgs)。
// packages/taro-cli/src/cli.ts
class CLI {
constructor(appPath) {
this.appPath = appPath || process.cwd();
}
run() {
return this.parseArgs();
}
parseArgs() {
const args = minimist(process.argv.slice(2), {
alias: {
// 别名设置
},
boolean: ['version', 'help', 'disable-global-config'],
default: {
build: true,
},
});
const _ = args._;
const command = _[0];
if (command) {
// 设置环境变量、注册命令
// 并执行命令
}
else {
if (args.h) {
// 帮助信息
} else if (args.v) {
// 输出版本号
console.log((0, util_1.getPkgVersion)());
}
}
}
使用了minimist,参数解析工具。
同类工具还有:
Commander.js、cac、yargs
cli.run 最终调用的是cli.parseArgs。
5. cli parseArgs
5.1 presets预设插件集合
// packages/taro-cli/src/cli.ts
parseArgs() {
const args = minimist(process.argv.slice(2), {
alias: {
// 别名设置
},
boolean: ['version', 'help', 'disable-global-config'],
default: {
build: true,
},
});
const _ = args._;
const command = _[0];
if (command) {
const appPath = this.appPath;
const presetsPath = path.resolve(__dirname, 'presets');
const commandsPath = path.resolve(presetsPath, 'commands');
const platformsPath = path.resolve(presetsPath, 'platforms');
const commandPlugins = helper_1.fs.readdirSync(commandsPath);
const targetPlugin = `${command}.js`;
// 设置环境变量
(_c = process.env).NODE_ENV || (_c.NODE_ENV = args.env);
if (process.env.NODE_ENV === 'undefined' && (command === 'build' || command === 'inspect')) {
process.env.NODE_ENV = (args.watch ? 'development' : 'production');
}
args.type || (args.type = args.t);
if (args.type) {
process.env.TARO_ENV = args.type;
}
if (typeof args.plugin === 'string') {
process.env.TARO_ENV = 'plugin';
}
// code...
}
else {
if (args.h) {
// 帮助信息
} else if (args.v) {
// 输出版本号
console.log((0, util_1.getPkgVersion)());
}
}
presets对应目录结构如下:
/taro/packages/taro-cli/dist/presets/commands/*
5.2 Config
// packages/taro-cli/src/cli.ts
const config = new Config({
appPath: this.appPath,
disableGlobalConfig: disableGlobalConfig
});
```
```
// packages/taro-cli/src/cli.ts
// 这里解析 dotenv 以便于 config 解析时能获取 dotenv 配置信息
const expandEnv = (0, helper_1.dotenvParse)(appPath, args.envPrefix, mode);
const disableGlobalConfig = !!(args['disable-global-config'] || DISABLE_GLOBAL_CONFIG_COMMANDS.includes(command));
const configEnv = {
mode,
command,
};
dotenvParse函数简单来说就是通过dotenv 和dotenv-expand解析.env、.env.development、.env.production 等变量。
dotenv是一个零依赖的模块,将.env的文件中环境变量加载到process.env中。
Config类
// packages/taro-service/src/Config.ts
export default class Config {
appPath: string
configPath: string
initialConfig: IProjectConfig
initialGlobalConfig: IProjectConfig
isInitSuccess: boolean
disableGlobalConfig: boolean
constructor (opts: IConfigOptions) {
this.appPath = opts.appPath
this.disableGlobalConfig = !!opts?.disableGlobalConfig
}
async init (configEnv: {
mode: string
command: string
}) {
}
initGlobalConfig () {
code...
}
getConfigWithNamed (platform, configName) {
code...
}
}
Config构造函数有两个属性:appPath是taro项目的路径,disableGlobalConfig是禁用全局配置。
5.2.1 config.init 读取配置
读取的是config/index.(js|ts)路径。判断是否禁用全局配置。不禁用读取全局配置.taro-global-config/index.json。
async init (configEnv: {
mode: string
command: string
}) {
this.initialConfig = {}
this.initialGlobalConfig = {}
this.isInitSuccess = false
this.configPath = resolveScriptPath(path.join(this.appPath, CONFIG_DIR_NAME, DEFAULT_CONFIG_FILE))
if (!fs.existsSync(this.configPath)) {
if (this.disableGlobalConfig) return
this.initGlobalConfig()
} else {
createSwcRegister({
only: [
filePath => filePath.indexOf(path.join(this.appPath, CONFIG_DIR_NAME)) >= 0
]
})
try {
const userExport = getModuleDefaultExport(require(this.configPath))
this.initialConfig = typeof userExport === 'function' ? await userExport(merge, configEnv) : userExport
this.isInitSuccess = true
} catch (err) {
console.log(err)
}
}
}
createSwcRegister使用了@swc/register 来编译ts等转换为common.js。可以直接使用require。
使用 swc 的方法之一是通过
require钩子。require钩子会将自身绑定到node的require并自动动态编译文件。不过现在更推荐 @swc-node/register。
// 这句就是config/index.ts支持函数也支持对象的实现。
const getModuleDefaultExport = exports => exports.__esModule ? exports.default : exports;
5.2.2 config.initGlobalConfig 初始化全局配置
读取配置~/.taro-global-config/index.json
{
"plugins": [],
"presets": []
}
initGlobalConfig () {
const homedir = getUserHomeDir()
if (!homedir) return console.error('获取不到用户 home 路径')
const globalPluginConfigPath = path.join(getUserHomeDir(), TARO_GLOBAL_CONFIG_DIR, TARO_GLOBAL_CONFIG_FILE)
if (!fs.existsSync(globalPluginConfigPath)) return
const spinner = ora(`开始获取 taro 全局配置文件: ${globalPluginConfigPath}`).start()
try {
this.initialGlobalConfig = fs.readJSONSync(globalPluginConfigPath) || {}
spinner.succeed('获取 taro 全局配置成功')
} catch (e) {
spinner.stop()
console.warn(`获取全局配置失败,如果需要启用全局插件请查看配置文件: ${globalPluginConfigPath} `)
}
}
getUserHomeDir函数主要是获取用户的主页路径。比如,mac中是/User/用户名。如果支持os.homedir()直接获取返回,如果不支持则根据操作系统和环境变量判断获取。
ora 是控制台的 loading 小动画。
这里的fs是@tarojs/helper导出的fs-extra。
fs-extra 添加本机模块中未包含的文件系统方法 fs,并为这些方法添加承诺支持 fs。它还用于 graceful-fs 防止 EMFILE 错误。它应该是 的替代品 fs。
使用 fs.readJSONSync 同步读取 json 的方法。
官网文档描述:全局插件或插件集配置
官网文档描述:全局插件或插件集配置
6.Kernel(内核)
// packages/taro-cli/src/cli.ts
const kernel = new Kernel({
appPath,
presets: [
path.resolve(__dirname, '.', 'presets', 'index.js')
],
config,
plugins: []
})
kernel.optsPlugins ||= []
Kernel类继承自Nodejs的事件模块EventEmitter。
// packages/taro-service/src/Kernel.ts
export default class Kernel extends EventEmitter {
constructor (options: IKernelOptions) {
super()
this.debugger = process.env.DEBUG === 'Taro:Kernel' ? helper.createDebug('Taro:Kernel') : function () {}
this.appPath = options.appPath || process.cwd()
this.optsPresets = options.presets
this.optsPlugins = options.plugins
this.config = options.config
this.hooks = new Map()
this.methods = new Map()
this.commands = new Map()
this.platforms = new Map()
this.initHelper()
this.initConfig()
this.initPaths()
this.initRunnerUtils()
}
}
this.debugger当没有配置DEBUG 环境变量时,则debugger是空函数,如果配置了process.env.DEBUG === 'Taro:Kernel'则为调用npm包 debug。
构造器中有几个初始化函数,基本含义都是顾名思义。
// packages/taro-service/src/Kernel.ts
// 项目配置
initConfig () {
// 省略部分代码
}
// Taro 编译时工具库,主要供 CLI、编译器插件使用
initHelper() {
// 省略部分代码
}
// 当前执行命令的相关路径
initPaths () {
this.paths = {
appPath: this.appPath,
nodeModulesPath: helper.recursiveFindNodeModules(path.join(this.appPath, helper.NODE_MODULES))
} as IPaths
if (this.config.isInitSuccess) {
Object.assign(this.paths, {
configPath: this.config.configPath,
sourcePath: path.join(this.appPath, this.initialConfig.sourceRoot as string),
outputPath: path.resolve(this.appPath, this.initialConfig.outputRoot as string)
})
}
this.debugger(`initPaths:${JSON.stringify(this.paths, null, 2)}`)
}
// 暴露给 runner 的公用工具函数
initRunnerUtils() {
this.runnerUtils = runnerUtils
this.debugger('initRunnerUtils')
}
初始化后的参数如taro 官方文档 - 编写插件 api。
6.1 cli Kernel.optsPlugins 等
kernel.optsPlugins ||= []
// code...
if (command === 'doctor') {
kernel.optsPlugins.push('@tarojs/plugin-doctor')
} else if (commandPlugins.includes(targetPlugin)) {
// 针对不同的内置命令注册对应的命令插件
kernel.optsPlugins.push(path.resolve(commandsPath, targetPlugin))
}
// code...
// 省略部分代码
6.2 cli customCommand 函数
switch (command) {
case 'inspect':
case 'build': {
// code...
// 省略部分代码
if (command === 'inspect') {
customCommand(command, kernel, args)
break
}
customCommand(command, kernel, {
// args
})
break
}
case 'init': {
customCommand(command, kernel, {
// args
}
break
default:
customCommand(command, kernel, args)
break
最终调用的是customCommand函数
// packages/taro-cli/src/commands/customCommand.ts
import { Kernel } from '@tarojs/service'
export default function customCommand (
command: string,
kernel: Kernel,
args: { _: string[], [key: string]: any }
) {
if (typeof command === 'string') {
const options: any = {}
const excludeKeys = ['_', 'version', 'v', 'help', 'h', 'disable-global-config']
Object.keys(args).forEach(key => {
if (!excludeKeys.includes(key)) {
options[key] = args[key]
}
})
kernel.run({
name: command,
opts: {
_: args._,
options,
isHelp: args.h
}
})
}
}
可以看出customCommand 函数移除一些 run函数不需要的参数,最终调用的是kernel.run函数。
7.kernel.run 执行函数
将run函数分成两部分解析:
// packages/taro-service/src/Kernel.ts
async run (args: string | { name: string, opts?: any }) {
let name
let opts
if (typeof args === 'string') {
name = args
} else {
name = args.name
opts = args.opts
}
this.debugger('command:run')
this.debugger(`command:run:name:${name}`)
this.debugger('command:runOpts')
this.debugger(`command:runOpts:${JSON.stringify(opts, null, 2)}`)
// 将参数暂存起来
this.setRunOpts(opts)
// code...
// 省略下半部分
}
// packages/taro-service/src/Kernel.ts
setRunOpts (opts) {
// 将参数暂存起来
// Taro 文档 - 编写插件 - ctx.runOpts
this.runOpts = opts
}
run函数下半部分:
主要有三个函数:
- this.initPresetsAndPlugins() 函数,顾名知义。初始化预设插件集合和插件。
- this.applyPlugins() 执行插件
- this.runHelp() 执行 命令行的帮助信息,例:taro init --help
async run (args: string | { name: string, opts?: any }) {
// code...
// 省略上半部分
this.debugger('initPresetsAndPlugins')
this.initPresetsAndPlugins()
await this.applyPlugins('onReady')
this.debugger('command:onStart')
await this.applyPlugins('onStart')
if (!this.commands.has(name)) {
throw new Error(`${name} 命令不存在`)
}
if (opts?.isHelp) {
return this.runHelp(name)
}
if (opts?.options?.platform) {
opts.config = this.runWithPlatform(opts.options.platform)
await this.applyPlugins({
name: 'modifyRunnerOpts',
opts: {
opts: opts?.config
}
})
}
await this.applyPlugins({
name,
opts
})
}
this.initPresetsAndPlugins() 函数 下节解析
接着看插件注册:
8.kernel ctx.registerCommand 注册 init 命令
// packages/taro-cli/src/presets/commands/init.ts
import type { IPluginContext } from '@tarojs/service'
export default (ctx: IPluginContext) => {
ctx.registerCommand({
name: 'init',
optionsMap: {
'--name [name]': '项目名称',
'--description [description]': '项目介绍',
'--typescript': '使用TypeScript',
'--npm [npm]': '包管理工具',
'--template-source [templateSource]': '项目模板源',
'--clone [clone]': '拉取远程模板时使用git clone',
'--template [template]': '项目模板',
'--css [css]': 'CSS预处理器(sass/less/stylus/none)',
'-h, --help': 'output usage information'
},
async fn (opts) {
// init project
const { appPath } = ctx.paths
const { options } = opts
const { projectName, templateSource, clone, template, description, typescript, css, npm, framework, compiler, hideDefaultTemplate, sourceRoot } = options
const Project = require('../../create/project').default
const project = new Project({
sourceRoot,
projectName,
projectDir: appPath,
npm,
templateSource,
clone,
template,
description,
typescript,
framework,
compiler,
hideDefaultTemplate,
css
})
project.create()
}
})
}
通过ctx.registerCommand注册了一个name为init的命令,会存入到内核Kernel实例对象的hooks属性中,其中ctx就是kernel的实例对象。具体实现是fn函数。
9.kernel.applyPlugins触发插件
// packages/taro-service/src/Kernel.ts
async applyPlugins (args: string | { name: string, initialVal?: any, opts?: any }) {
let name
let initialVal
let opts
if (typeof args === 'string') {
name = args
} else {
name = args.name
initialVal = args.initialVal
opts = args.opts
}
this.debugger('applyPlugins')
this.debugger(`applyPlugins:name:${name}`)
this.debugger(`applyPlugins:initialVal:${initialVal}`)
this.debugger(`applyPlugins:opts:${opts}`)
if (typeof name !== 'string') {
throw new Error('调用失败,未传入正确的名称!')
}
// 省略部分代码
}
上半部分,主要是适配两种传参的方式。
// packages/taro-service/src/Kernel.ts
async applyPlugins (args: string | { name: string, initialVal?: any, opts?: any }) {
// 省略部分代码
const hooks = this.hooks.get(name) || []
if (!hooks.length) {
return await initialVal
}
const waterfall = new AsyncSeriesWaterfallHook(['arg'])
if (hooks.length) {
const resArr: any[] = []
for (const hook of hooks) {
waterfall.tapPromise({
name: hook.plugin!,
stage: hook.stage || 0,
// @ts-ignore
before: hook.before
}, async arg => {
const res = await hook.fn(opts, arg)
if (IS_MODIFY_HOOK.test(name) && IS_EVENT_HOOK.test(name)) {
return res
}
if (IS_ADD_HOOK.test(name)) {
resArr.push(res)
return resArr
}
return null
})
}
}
return await waterfall.promise(initialVal)
}
Taro 的插件架构基于Tapable。
这里使用了AsyncSeriesWaterfallHook。
简单来说就是异步或者同步方法串联起来,上一个结果作为下一个函数的参数依次执行。
通过
ctx.register注册过的钩子需要通过方法ctx.applyPlugins进行触发。
官网文档描述:插件方法
10.kernel.runHelp 命令帮助信息
在kernel.run 函数中,有一个opts.isHelp的判断,执行kernel.runHelp方法
// packages/taro-service/src/Kernel.ts
if (opts?.isHelp) {
return this.runHelp(name)
}
runHelp具体代码实现如下:
runHelp (name: string) {
const command = this.commands.get(name)
const defaultOptionsMap = new Map()
defaultOptionsMap.set('-h, --help', 'output usage information')
let customOptionsMap = new Map()
if (command?.optionsMap) {
customOptionsMap = new Map(Object.entries(command?.optionsMap))
}
const optionsMap = new Map([...customOptionsMap, ...defaultOptionsMap])
printHelpLog(name, optionsMap, command?.synopsisList ? new Set(command?.synopsisList) : new Set())
}
根据name从this.commands 的Map中获取到命令,输出对应的optionsMap和synopsisList。
$taro init --help
总结:
- 回顾了两种调试方式。command方式、.vscode/launch.json。
- taro入口 taro-cli具体实现
- taro-cli命令实现原理,读取用户配置文件和全局配置文件
- 使用
minimist命令行解析参数工具,设置环境变量,通过dotenv和dotenv-expand解析.env等变量,将.env的文件中环境变量加载到process.env中。并注册命令。 - taro-service Config和Kernel(内核)等解耦实现。taro单独抽离了一个
taro-service模块,包含Config、Kernel内核、Plugin等。 - Taro 插件机制,学会了如何编写一个taro插件。
- Taro 的插件架构基于Tapable。
AsyncSeriesWaterfallHook将异步和同步函数串联起来执行,实现各个插件分别在不同的地方,达到解耦效果。
完结。
此文章为2024年06月Day1源码共读,生活在阴沟里,也要记得仰望星空。