Taro 4 已发布:11. Taro 是如何解析入口配置 app.config.ts 和页面配置的?

1. 前言

大家好,我是若川,欢迎关注我的公众号:若川视野。从 2021 年 8 月起,我持续组织了好几年的每周大家一起学习 200 行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与。另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(6k+人)第一的专栏,写有几十篇源码文章。

截至目前(2025-04-16),目前最新是 4.0.12,官方4.0正式版本的介绍文章暂未发布。官方之前发过Taro 4.0 Beta 发布:支持开发鸿蒙应用、小程序编译模式、Vite 编译等

计划写一个 Taro 源码揭秘系列,博客地址:ruochuan12.github.io/taro 可以加入书签,持续关注若川

时隔3个月才继续写第 11 篇,我会继续持续写下去,争取做全网最新最全的 Taro 源码系列。

前面 4 篇文章都是讲述编译相关的,CLI、插件机制、初始化项目、编译构建流程。

第 5-7 篇讲述的是运行时相关的 Events、API、request 等。

第 10 篇接着继续追随第 4 篇和第 8、9 篇的脚步,分析 TaroMiniPlugin webpack 的插件实现(全流程讲述)。

第 11 篇,我们继续分析 TaroMiniPlugin webpack 的插件实现。分析 Taro 是如何解析入口文件和页面的?

关于克隆项目、环境准备、如何调试代码等,参考第 1 篇文章-准备工作、调试第 4 篇 npm run dev:weapp(本文以这篇文章中的调试为例)。后续文章基本不再过多赘述。

学完本文,你将学到:

bash 复制代码
1. Taro 是如何解析入口文件和页面的?
2. 学完能对自己平时写的 Taro 项目更理解透彻
等等

我们先来看 TaroMiniPlugin 结构

ts 复制代码
export default class TaroMiniPlugin {
	constructor (options: ITaroMiniPluginOptions) {
		// 省略
	}
	/**
	* 插件入口
	*/
	apply (compiler: Compiler) {
		this.context = compiler.context
		//  根据 webpack entry 配置获取入口文件路径
		//  @returns app 入口文件路径
		this.appEntry = this.getAppEntry(compiler)
		// 省略代码
		/** build mode */
		compiler.hooks.run.tapAsync(
			PLUGIN_NAME,
			this.tryAsync<Compiler>(async compiler => {
				await this.run(compiler)
				// 省略
			})
		)
	}
}

插件入口 apply 方法,获取到 this.appEntry src/app.[js|jsx|ts|tsx],调用 run 方法。

这里的 this.appEntry"/Users/ruochuan/git-source/github/taro4-debug/src/app.ts"

本文主要讲述 run 方法的具体实现。分析 app 入口文件,搜集页面、组件信息,往 this.dependencies 中添加资源模块。

ts 复制代码
export default class TaroMiniPlugin {
	/**
	 * 分析 app 入口文件,搜集页面、组件信息,
	 * 往 this.dependencies 中添加资源模块
	 */
	async run(compiler: Compiler) {
		if (this.options.isBuildPlugin) {
			this.getPluginFiles();
			this.getConfigFiles(compiler);
		} else {
			this.appConfig = await this.getAppConfig();
			this.getPages();
			this.getPagesConfig();
			this.getDarkMode();
			this.getConfigFiles(compiler);
			this.addEntries();
		}
	}
}
  • 插件构建模式 ( this.options.isBuildPlugin 为 true 时):
    • 调用 getPluginFiles() 获取插件文件
    • 调用 getConfigFiles(compiler) 获取配置文件
  • 正常构建模式:
    • 首先通过 getAppConfig() 获取应用配置
    • 然后依次执行:
      • getPages() 获取页面信息
      • getPagesConfig() 获取页面配置
      • getDarkMode() 获取暗黑模式配置
      • getConfigFiles(compiler) 获取配置文件
      • addEntries() 添加入口文件
graph TD A[开始] --> B{是否为插件构建模式?} B -- 是 --> C[调用 getPluginFiles] C --> D[调用 getConfigFiles] B -- 否 --> E[调用 getAppConfig] E --> F[调用 getPages] F --> G[调用 getPagesConfig] G --> H[调用 getDarkMode] H --> I[调用 getConfigFiles] I --> J[调用 addEntries] D --> K[结束] J --> K subgraph 函数解释 C["getPluginFiles: 获取插件文件,处理插件相关配置"] D["getConfigFiles: 获取配置文件,处理配置相关逻辑"] E["getAppConfig: 获取应用全局配置"] F["getPages: 根据app配置收集页面信息,处理分包和tabbar"] G["getPagesConfig: 读取页面及其依赖组件的配置"] H["getDarkMode: 获取暗黑模式相关配置"] I["getConfigFiles: 获取配置文件,处理配置相关逻辑"] J["addEntries: 添加app、模板组件、页面、组件等资源模块"] end

2. getAppConfig 获取入口配置文件配置

ts 复制代码
this.appConfig = await this.getAppConfig();

入口文件是 src/app.[js|jsx|ts|tsx]getAppConfig 获取入口文件的配置。

这个函数要做的事情是把配置文件 src/app.config.ts 转换成

ts 复制代码
// src/app.config.ts
export default defineAppConfig({
  pages: [
    'pages/index/index'
  ],
  window: {
    backgroundTextStyle: 'light',
    navigationBarBackgroundColor: '#fff',
    navigationBarTitleText: 'WeChat',
    navigationBarTextStyle: 'black'
  }
})

最终这样的对象,方便读取 pages 等相关配置,再进行处理。

ts 复制代码
{
  pages: [
    "pages/index/index",
  ],
  window: {
    backgroundTextStyle: "light",
    navigationBarBackgroundColor: "#fff",
    navigationBarTitleText: "WeChat",
    navigationBarTextStyle: "black",
  },
}

我们来看 getAppConfig 的具体实现。

ts 复制代码
async getAppConfig (): Promise<AppConfig> {
	// 'app'
    const appName = path.basename(this.appEntry).replace(path.extname(this.appEntry), '')

	// 编译处理入口配置文件 app.config.ts
    this.compileFile({
      name: appName,
      path: this.appEntry,
      isNative: false
    })

	// 编译完成,获取文件内容
    const fileConfig = this.filesConfig[this.getConfigFilePath(appName)]
    const appConfig = fileConfig ? fileConfig.content || {} : {}

    if (isEmptyObject(appConfig)) {
      throw new Error('缺少 app 全局配置文件,请检查!')
    }
	// 预留钩子函数可修改配置文件
    const { modifyAppConfig } = this.options.combination.config
    if (typeof modifyAppConfig === 'function') {
      await modifyAppConfig(appConfig)
    }
    return appConfig as AppConfig
  }

其中 compileFile 函数,简单来说就是读取配置,然后存储到 this.filesConfig 上。

modifyAppConfig 是传入的修改 app.config.ts 配置文件的钩子函数。

我们继续来看实现 compileFile 函数的实现。

2.1 compileFile 读取页面、组件的配置,并递归读取依赖的组件的配置

ts 复制代码
/**
   * 读取页面、组件的配置,并递归读取依赖的组件的配置
   */
  compileFile (file: IComponent, independentPackage?: IndependentPackage) {
    const filePath = file.path
    const fileConfigPath = file.isNative ? this.replaceExt(filePath, '.json') : this.getConfigFilePath(filePath)
    const fileConfig = readConfig(fileConfigPath, this.options.combination.config)
    const { componentGenerics, usingComponents } = fileConfig

    if (this.options.isBuildPlugin && componentGenerics) {
      // 省略...
    }

    // 递归收集依赖的第三方组件
    if (usingComponents) {
      // 省略...
    }

    this.filesConfig[this.getConfigFilePath(file.name)] = {
      content: fileConfig,
      path: fileConfigPath
    }
  }

compileFile 相对比较复杂,我们省略了一些代码。

最终给 this.filesConfig 赋值,对象如下图所示:

包含配置文件的路径和配置的内容。

这个函数中,其中有一个很关键的函数 readConfig,我们来看它的具体实现。

2.2 readConfig 读取配置

页面配置

ts 复制代码
// packages/taro-helper/src/utils.ts
export function readConfig<T extends IReadConfigOptions> (configPath: string, options: T = {} as T) {
  let result: any = {}
  if (fs.existsSync(configPath)) {
    if (REG_JSON.test(configPath)) {
      result = fs.readJSONSync(configPath)
    } else {
      result = requireWithEsbuild(configPath, {
		// 省略若干代码
	  })
    }

    result = getModuleDefaultExport(result)
  } else {
    result = readPageConfig(configPath)
  }
  return result
}

仓库里有更多 readConfig 测试用例

我们很容易看出,存在配置文件,如果是JSON,则用 fs.readJSONSync 读取。

  • 支持多种配置文件格式(支持 JSON 和 JS/TS 文件格式)
  • 支持路径别名和常量定义
  • 使用 esbuild、SWC 进行快速编译
  • 处理模块默认导出
  • 支持页面配置读取

其中 requireWithEsbuild 函数,看函数名即可猜测出具体含义。基于 esbuildrequire 实现。

2.2.1 requireWithEsbuild

ts 复制代码
// packages/taro-helper/src/esbuild/index.ts

import { Config, transformSync } from '@swc/core'
import esbuild from 'esbuild'
import requireFromString from 'require-from-string'
// 省略若干代码

/** 基于 esbuild 的 require 实现 */
export function requireWithEsbuild(
  id: string,
  { customConfig = {}, customSwcConfig = {}, cwd = process.cwd() }: IRequireWithEsbuildOptions = {}
) {
  const { outputFiles = [] } = esbuild.buildSync(
	// 省略若干代码
  )

  // Note: esbuild.buildSync 模式下不支持引入插件,所以这里需要手动转换
  const { code = '' } = transformSync(
    outputFiles[0].text,
    defaults(customSwcConfig, {
      jsc: { target: 'es2015' },
    })
  )
  return requireFromString(code, id)
}

这块可以调试细看,当然我知道大部分人不会去调试,问题不大,知道个大概即可。后续碰到类似问题能想到这里的解决方案即可。

2.2.2 getModuleDefaultExport 处理模块默认导出

ts 复制代码
export const getModuleDefaultExport = (exports) => (exports.__esModule ? exports.default : exports)

2.2.3 readPageConfig 读取页面配置

这个函数要实现的功能是:在页面 JS 文件中使用 definePageConfig 也能配置读取。

ts 复制代码
// packages/taro-helper/src/utils.ts

export function readPageConfig(configPath: string) {
  let result: any = {}
  const extNames = ['.js', '.jsx', '.ts', '.tsx', '.vue']

  // check source file extension
  extNames.some((ext) => {
    const tempPath = configPath.replace('.config', ext)
    if (fs.existsSync(tempPath)) {
      try {
        result = readSFCPageConfig(tempPath)
      } catch (error) {
        result = {}
      }
      return true
    }
  })
  return result
}
  • 读取页面配置文件
  • 支持多种文件格式(.js, .jsx, .ts, .tsx, .vue)
  • 调用 readSFCPageConfig 解析文件内容
  • 返回解析后的配置对象

2.2.4 readSFCPageConfig

ts 复制代码
// packages/taro-helper/src/utils.ts

// read page config from a sfc file instead of the regular config file
function readSFCPageConfig(configPath: string) {
  if (!fs.existsSync(configPath)) return {}

  const sfcSource = fs.readFileSync(configPath, 'utf8')
  const dpcReg = /definePageConfig\(\{[\w\W]+?\}\)/g
  const matches = sfcSource.match(dpcReg)

  let result: any = {}

  if (matches && matches.length === 1) {
    const callExprHandler = (p: any) => {
      const { callee } = p.node
      if (!callee.name) return
      if (callee.name && callee.name !== 'definePageConfig') return

      const configNode = p.node.arguments[0]
      result = exprToObject(configNode)
      p.stop()
    }
    const configSource = matches[0]
    const program = (babel.parse(configSource, { filename: '' }))?.program

    program && babel.traverse(program, { CallExpression: callExprHandler })
  }

  return result
}
  • 从单文件组件(SFC)中读取页面配置
  • 解析 definePageConfig 函数调用
  • 将 AST 节点转换为 JavaScript 对象
  • 返回解析后的配置对象

仓库里有更多 readConfig 测试用例

上述提到的测试用例缺失,有读者感兴趣可以补上,提 PR 的机会来了。也可以修改项目中的配置为页面中的 JS 进行调试。

3. getPages 获取页面信息

ts 复制代码
/**
 * 根据 app config 的 pages 配置项,收集所有页面信息,
 * 包括处理分包和 tabbar
 */
getPages () {
	if (isEmptyObject(this.appConfig)) {
		throw new Error('缺少 app 全局配置文件,请检查!')
	}

	const appPages = this.appConfig.pages
	if (!appPages || !appPages.length) {
		throw new Error('全局配置缺少 pages 字段,请检查!')
	}

	if (!this.isWatch && this.options.logger?.quiet === false) {
		printLog(processTypeEnum.COMPILE, '发现入口', this.getShowPath(this.appEntry))
	}

	const { newBlended, frameworkExts, combination } = this.options
	const { prerender } = combination.config

	// 拆分到下方
}

一些判断和校验。 其中经常能在控制台看到的一句提示发现入口,源码就是在这里实现的。

ts 复制代码
	this.prerenderPages = new Set(validatePrerenderPages(appPages, prerender).map(p => p.path))
	this.getTabBarFiles(this.appConfig)
	this.pages = new Set([
		...appPages.map<IComponent>(item => {
			const pagePath = resolveMainFilePath(path.join(this.options.sourceDir, item), frameworkExts)
			const pageTemplatePath = this.getTemplatePath(pagePath)
			const isNative = this.isNativePageORComponent(pageTemplatePath)
			return {
				name: item,
				path: pagePath,
				isNative,
				stylePath: isNative ? this.getStylePath(pagePath) : undefined,
				templatePath: isNative ? this.getTemplatePath(pagePath) : undefined
			}
		})
	])
	this.getSubPackages(this.appConfig)
	// 新的混合原生编译模式 newBlended 下,需要收集独立编译为原生自定义组件
	newBlended && this.getNativeComponent()
}

执行这个函数过后,this.pages 会得到所有的页面数据。

接下来是获取页面配置信息和组件等。

4. getPagesConfig 读取页面及其依赖的组件的配置

ts 复制代码
/**
 * 读取页面及其依赖的组件的配置
 */
getPagesConfig () {
	this.pages.forEach(page => {
		if (!this.isWatch && this.options.logger?.quiet === false) {
			printLog(processTypeEnum.COMPILE, '发现页面', this.getShowPath(page.path))
		}

		const pagePath = page.path
		const independentPackage = this.getIndependentPackage(pagePath)

		this.compileFile(page, independentPackage)
	})
}

其中经常能在控制台看到的一句提示发现页面,源码就是在这里实现的。

pages 的配置。

5. getDarkMode 收集 dark mode 配置中的文件

ts 复制代码
/**
   * 收集 dark mode 配置中的文件
   */
  getDarkMode () {
    const themeLocation = this.appConfig.themeLocation
    const darkMode = this.appConfig.darkmode
    if (darkMode && themeLocation && typeof themeLocation === 'string') {
      this.themeLocation = themeLocation
    }
  }

DarkMode 适配指南

收集起来,后续再使用。

我们接下来看 getConfigFiles 的实现。

6. getConfigFiles 往 this.dependencies 中新增或修改所有 config 配置模块

ts 复制代码
/**
   * 往 this.dependencies 中新增或修改所有 config 配置模块
   */
  getConfigFiles (compiler: Compiler) {
    const filesConfig = this.filesConfig
    Object.keys(filesConfig).forEach(item => {
      if (fs.existsSync(filesConfig[item].path)) {
        this.addEntry(filesConfig[item].path, item, META_TYPE.CONFIG)
      }
    })

    // webpack createChunkAssets 前一刻,去除所有 config chunks
    compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
      compilation.hooks.beforeChunkAssets.tap(PLUGIN_NAME, () => {
        const chunks = compilation.chunks
        const configNames = Object.keys(filesConfig)

        for (const chunk of chunks) {
          if (configNames.find(configName => configName === chunk.name)) chunks.delete(chunk)
        }
      })
    })
  }

compileFile 编译的文件,全部遍历标记为配置文件,添加到 dependencies 中。

webpack createChunkAssets 前一刻,去除所有 config chunks

7. addEntries 在 this.dependencies 中新增或修改 app、模板组件、页面、组件等资源模块

加入依赖项中的类型。方便针对不同类型,采用不同的 loader 处理。

ts 复制代码
// packages/taro-helper/src/constants.ts
export enum META_TYPE {
  ENTRY = 'ENTRY',
  PAGE = 'PAGE',
  COMPONENT = 'COMPONENT',
  NORMAL = 'NORMAL',
  STATIC = 'STATIC',
  CONFIG = 'CONFIG',
  EXPORTS = 'EXPORTS',
}
ts 复制代码
/**
   * 在 this.dependencies 中新增或修改 app、模板组件、页面、组件等资源模块
   */
  addEntries () {
    const { template } = this.options

    this.addEntry(this.appEntry, 'app', META_TYPE.ENTRY)
    if (!template.isSupportRecursive) {
      this.addEntry(path.resolve(__dirname, '..', 'template/comp'), 'comp', META_TYPE.STATIC)
    }
    this.addEntry(path.resolve(__dirname, '..', 'template/custom-wrapper'), 'custom-wrapper', META_TYPE.STATIC)
	// 拆分到下方
  }
  1. 添加应用入口
  2. 添加模板组件
  • 如果模板不支持递归,添加 comp 组件
  • 添加 custom-wrapper 组件
  1. 添加页面
  • 如果是原生页面,添加页面文件、样式文件和模板文件
  • 如果是普通页面,只添加页面文件

遍历页面添加到依赖项中。

ts 复制代码
    this.pages.forEach(item => {
      if (item.isNative) {
        this.addEntry(item.path, item.name, META_TYPE.NORMAL, { isNativePage: true })
        if (item.stylePath && fs.existsSync(item.stylePath)) {
          this.addEntry(item.stylePath, this.getStylePath(item.name), META_TYPE.NORMAL)
        }
        if (item.templatePath && fs.existsSync(item.templatePath)) {
          this.addEntry(item.templatePath, this.getTemplatePath(item.name), META_TYPE.NORMAL)
        }
      } else {
        this.addEntry(item.path, item.name, META_TYPE.PAGE)
      }
    })

遍历组件添加到依赖项中。

ts 复制代码
    this.components.forEach(item => {
      if (item.isNative) {
        this.addEntry(item.path, item.name, META_TYPE.NORMAL, { isNativePage: true })
        if (item.stylePath && fs.existsSync(item.stylePath)) {
          this.addEntry(item.stylePath, this.getStylePath(item.name), META_TYPE.NORMAL)
        }
        if (item.templatePath && fs.existsSync(item.templatePath)) {
          this.addEntry(item.templatePath, this.getTemplatePath(item.name), META_TYPE.NORMAL)
        }
      } else {
        this.addEntry(item.path, item.name, META_TYPE.COMPONENT)
      }
    })

7.1 addEntry 在 this.dependencies 中新增或修改模块

ts 复制代码
/**
   * 在 this.dependencies 中新增或修改模块
   */
  addEntry (entryPath: string, entryName: string, entryType: META_TYPE, options = {}) {
    let dep: TaroSingleEntryDependency
    if (this.dependencies.has(entryPath)) {
      dep = this.dependencies.get(entryPath)!
      dep.name = entryName
      dep.loc = { name: entryName }
      dep.request = entryPath
      dep.userRequest = entryPath
      dep.miniType = entryType
      dep.options = options
    } else {
      dep = new TaroSingleEntryDependency(entryPath, entryName, { name: entryName }, entryType, options)
    }
    this.dependencies.set(entryPath, dep)
  }

this.dependencies 是这样的结构。存储的是路径和 TaroSingleEntryDependency 实例对象。如果存在则更新。

行文至此,我们完成了对 run 函数的分析。分析 app 入口文件,搜集页面、组件信息,往 this.dependencies 中添加资源模块。后续针对不同的类型,采用不同的 loader 处理等。

8. 总结

根据 webpack entry 配置获取入口文件路径 这里的 this.appEntry"/Users/ruochuan/git-source/github/taro4-debug/src/app.ts"

根据入口文件获取到配置文件,app.config.ts

run 函数

  • 首先通过 getAppConfig() 获取应用配置
    • 然后依次执行:
      • getPages() 获取页面信息(从 app.config.ts 中 pages 读取)
      • getPagesConfig() 获取页面配置(找到对应的页面配置)
      • getDarkMode() 获取暗黑模式配置
      • getConfigFiles(compiler) 获取配置文件(包含第三方组件等)
      • addEntries() 添加入口文件()

其中 compileFile 函数是重点,读取页面、组件的配置,并递归读取依赖的组件的配置。

readConfig 读取配置文件

  • fs.readJSONSync 读取 JSON
  • 通过 esbuildrequire 实现
  • 或者通过 AST 读取页面配置文件

启发:Taro 是非常知名的跨端框架,我们在使用它,享受它带来便利的同时,有余力也可以多为其做出一些贡献。比如帮忙解答一些 issue 或者提 pr 修改 bug 等。 在这个过程,我们会不断学习,促使我们去解决问题,带来的好处则是不断拓展知识深度和知识广度。


如果看完有收获,欢迎点赞、评论、分享、收藏支持。你的支持和肯定,是我写作的动力。也欢迎提建议和交流讨论

作者:常以若川 为名混迹于江湖。所知甚少,唯善学。若川的博客github blog,可以点个 star 鼓励下持续创作。

最后可以持续关注我@若川,欢迎关注我的公众号:若川视野。从 2021 年 8 月起,我持续组织了好几年的每周大家一起学习 200 行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与。另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(6k+人)第一的专栏,写有几十篇源码文章。

相关推荐
恋猫de小郭1 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅8 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60618 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了8 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅8 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅9 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅9 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment9 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端