1. 前言
大家好,我是若川,欢迎关注我的公众号:若川视野。我倾力持续组织了 3 年多每周大家一起学习 200 行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02
参与。另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(6k+人)第一的专栏,写有几十篇源码文章。
截至目前(2024-08-16
),taro 4.0
正式版已经发布,目前最新是 4.0.4
,官方4.0
正式版本的介绍文章暂未发布。官方之前发过Taro 4.0 Beta 发布:支持开发鸿蒙应用、小程序编译模式、Vite 编译等。
计划写一个 Taro 源码揭秘系列,博客地址:https://ruochuan12.github.io/taro 可以加入书签,持续关注若川。
- [x] 1. 揭开整个架构的入口 CLI => taro init 初始化项目的秘密
- [x] 2. 揭开整个架构的插件系统的秘密
- [x] 3. 每次创建新的 taro 项目(taro init)的背后原理是什么
- [x] 4. 每次 npm run dev:weapp 开发小程序,build 编译打包是如何实现的?
- [x] 5. 高手都在用的发布订阅机制 Events 在 Taro 中是如何实现的?
- [x] 6. 为什么通过 Taro.xxx 能调用各个小程序平台的 API,如何设计实现的?
- [x] 7. Taro.request 和请求响应拦截器是如何实现的
- [x] 8. Taro 是如何使用 webpack 打包构建小程序的?
- [x] 9. Taro 是如何生成 webpack 配置进行构建小程序的?
- [ ] 等等
学完本文,你将学到:
1. 每次开发编译 npm run dev:weapp build 编译打包是如何实现的?
2. 微信小程序端平台插件 @tarojs/plugin-platform-weapp 是如何实现的?
3. 端平台插件基础抽象类 TaroPlatformBase、TaroPlatform 是如何实现的?
4. 最终是如何调用 runner 执行 webpack 编译构建的?
等等
经常使用 Taro
开发小程序的小伙伴,一定日常使用 npm run dev:weapp
等命令运行小程序。我们这篇文章就来解读这个命令背后,Taro
到底做了什么。npm run dev:weapp
对应的是 taro build --type weapp --watch
。npm run build:weapp
对应的是 taro build --type weapp
。
关于克隆项目、环境准备、如何调试代码等,参考第一篇文章-准备工作、调试。后续文章基本不再过多赘述。
文章中基本是先放源码,源码中不做过多解释。源码后面再做简单讲述。
2. 调试源码
初始化 taro
项目,方便调试,选择React
、TS
、Less
、pnpm
、webpack5
、CLI内置默认模板
。
npx @taro/cli init taro4-debug
cd taro4-debug
# 安装依赖
pnpm i
# 写文章时最新的版本是 4.0.4
如图所示
# 开发启动
pnpm run dev:weapp
如图
# 编译打包
pnpm run build:weapp
如图
我们来学习 taro 编译打包的源码。
2.1 调试方式1:使用项目里的依赖
克隆 taro
项目
git clone https://github.com/NervJS/taro.git
cd taro
pnpm i
pnpm run build
# 写文章时最新的版本是 4.0.4,可以 git checkout 39dd83eb0bfc2a937acd79b289c7c2ec6e59e202
# 39dd83eb0bfc2a937acd79b289c7c2ec6e59e202
# chore(release): publish 4.0.4 (#16202)
方式1调试截图如下:
优点,无需多余的配置,可以直接调试本身项目。
缺点:安装的 taro
依赖都是 dist
目录,压缩过后的,不方便查看原始代码。
我们使用调试方法2。
2.2 调试方式2:使用 taro 源码
我把 taro
源码和 taro4-debug
克隆到了同一个目录 github
。
优点:可以调试本身不压缩的源码。因为 taro
自身打包生成了对应的 sourcemap
文件,所以可以调试源码文件。
缺点:
- 需要配置
.vscode/launch.json
。
- 需要配置
- 还需要在对应的
dist
文件修改一些包的路径。
- 还需要在对应的
2.2.1 配置 .vscode/launch.json
重点添加配置 "cwd": "/Users/ruochuan/git-source/github/taro4-debug"、 "args": ["build","--type","weapp" ]
和 "console": "integratedTerminal"
我们配置调试微信小程序打包。
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "CLI debug",
"program": "${workspaceFolder}/packages/taro-cli/bin/taro",
"cwd": "/Users/ruochuan/git-source/github/taro4-debug",
"args": [
"build",
"--type",
"weapp",
],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"]
},
]
}
2.2.2 修改以下两个包的路径
@tarojs/plugin-platform-weapp
=>../taro/packages/taro-platform-weapp/index.js
@tarojs/webpack5-runner
=>../taro/packages/taro-webpack5-runner/index.js
对应的具体代码位置如下
// packages/taro-cli/dist/cli.js
switch (platform) {
// 省略一些平台
case 'weapp': {
kernel.optsPlugins.push(path.resolve(`../taro/packages/taro-platform-${platform}/index.js`))
// kernel.optsPlugins.push(`@tarojs/plugin-platform-${platform}`);
break;
}
}
// packages/taro-service/dist/platform-plugin-base/mini.js
getRunner() {
return __awaiter(this, void 0, void 0, function* () {
const { appPath } = this.ctx.paths;
const { npm } = this.helper;
const runnerPkg = this.compiler === 'vite' ? '@tarojs/vite-runner' : '@tarojs/webpack5-runner';
// const runner = yield npm.getNpmPkg(runnerPkg, appPath);
const runner = require(path.resolve('../taro/packages/taro-webpack5-runner/index.js'));
return runner.bind(null, appPath);
});
}
不配置的话,不会调用对应的 taro 文件源码,而是调用项目中的依赖包源码,路径不对。
方式2调试截图如下:
根据前面两篇 1. taro cli init、2. taro 插件机制 文章,我们可以得知:taro build
初始化命令,最终调用的是 packages/taro-cli/src/presets/commands/build.ts
文件中的 ctx.registerCommand
注册的 build
命令行的 fn
函数。
// packages/taro-cli/src/presets/commands/build.ts
import {
MessageKind,
validateConfig
} from '@tarojs/plugin-doctor'
import * as hooks from '../constant'
import type { IPluginContext } from '@tarojs/service'
export default (ctx: IPluginContext) => {
ctx.registerCommand({
name: 'build',
optionsMap: {
'--type [typeName]': 'Build type, weapp/swan/alipay/tt/qq/jd/h5/rn',
'--watch': 'Watch mode',
// 省略若干代码
'--no-check': 'Do not check config is valid or not',
},
synopsisList: [
'taro build --type weapp',
'taro build --type weapp --watch',
// 省略若干代码
],
async fn(opts) {
const { options, config, _ } = opts
const { platform, isWatch, blended, newBlended, withoutBuild, noInjectGlobalStyle, noCheck } = options
const { fs, chalk, PROJECT_CONFIG } = ctx.helper
const { outputPath, configPath } = ctx.paths
if (!configPath || !fs.existsSync(configPath)) {
console.log(chalk.red(`找不到项目配置文件${PROJECT_CONFIG},请确定当前目录是 Taro 项目根目录!`))
process.exit(1)
}
if (typeof platform !== 'string') {
console.log(chalk.red('请传入正确的编译类型!'))
process.exit(0)
}
// 校验 Taro 项目配置
if (!noCheck) {
const checkResult = await checkConfig({
projectConfig: ctx.initialConfig,
helper: ctx.helper
})
if (!checkResult.isValid) {
// 校验失败,退出
}
}
const isProduction = process.env.NODE_ENV === 'production' || !isWatch
// dist folder
// 确保输出路径存在,如果不存在就创建
fs.ensureDirSync(outputPath)
// is build native components mode?
const isBuildNativeComp = _[1] === 'native-components'
await ctx.applyPlugins(hooks.ON_BUILD_START)
await ctx.applyPlugins({
name: platform,
opts: {
config: {
...config,
isWatch,
mode: isProduction ? 'production' : 'development',
// 省略若干参数和若干钩子
},
},
})
await ctx.applyPlugins(hooks.ON_BUILD_COMPLETE)
},
})
}
async function checkConfig ({ projectConfig, helper }) {
const result = await validateConfig(projectConfig, helper)
return result
}
Taro
build
插件主要做了以下几件事:
- 判断
config/index
配置文件是否存在,如果不存在,则报错退出程序。 - 判断
platform
参数是否是字符串,这里是weapp
,如果不是,退出程序。 - 使用
@tarojs/plugin-doctor
中的validateConfig
方法 (checkConfig
) 函数校验配置文件config/index
,如果配置文件出错,退出程序。 - 调用
ctx.applyPlugins(hooks.ON_BUILD_START)
(编译开始)onBuildStart
钩子。 - 调用
ctx.applyPlugins({ name: platform, })
(调用 weapp) 钩子。 - 调用
ctx.applyPlugins(hooks.ON_BUILD_COMPLETE)
(编译结束)onBuildComplete
钩子。
其中
await ctx.applyPlugins({
name: platform,
});
调用的是端平台插件,本文以微信小程序为例,所以调用的是 weapp。对应的源码文件路径是:packages/taro-platform-weapp/src/index.ts
。我们来看具体实现。
3. 端平台插件 Weapp
Taro文档 - 端平台插件 中,有对端平台插件的比较详细的描述,可以参考学习。
我们接着学习微信小程序端平台插件源码。
// packages/taro-platform-weapp/src/index.ts
import Weapp from './program'
import type { IPluginContext } from '@tarojs/service'
// 让其它平台插件可以继承此平台
export { Weapp }
export interface IOptions {
enablekeyboardAccessory?: boolean
}
export default (ctx: IPluginContext, options: IOptions) => {
ctx.registerPlatform({
name: 'weapp',
useConfigName: 'mini',
async fn ({ config }) {
const program = new Weapp(ctx, config, options || {})
await program.start()
}
})
}
重点就是这两行代码。
const program = new Weapp(ctx, config, options || {})
await program.start()
ctx.registerPlatform
注册 weapp
平台插件,调用 Weapp
构造函数,传入 ctx
、config
和 options
等配置。再调用实例对象的 start
方法。
4. new Weapp 构造函数
packages/taro-platform-weapp/src/program.ts
// packages/taro-platform-weapp/src/program.ts
import { TaroPlatformBase } from '@tarojs/service'
import { components } from './components'
import { Template } from './template'
import type { IOptions } from './index'
const PACKAGE_NAME = '@tarojs/plugin-platform-weapp'
export default class Weapp extends TaroPlatformBase {
template: Template
platform = 'weapp'
globalObject = 'wx'
projectConfigJson: string = this.config.projectConfigName || 'project.config.json'
runtimePath = `${PACKAGE_NAME}/dist/runtime`
taroComponentsPath = `${PACKAGE_NAME}/dist/components-react`
fileType = {
templ: '.wxml',
style: '.wxss',
config: '.json',
script: '.js',
xs: '.wxs'
}
/**
* 1. setupTransaction - init
* 2. setup
* 3. setupTransaction - close
* 4. buildTransaction - init
* 5. build
* 6. buildTransaction - close
*/
constructor (ctx, config, pluginOptions?: IOptions) {
super(ctx, config)
this.template = new Template(pluginOptions)
this.setupTransaction.addWrapper({
close () {
// 增加组件或修改组件属性
this.modifyTemplate(pluginOptions)
// 修改 Webpack 配置
this.modifyWebpackConfig()
}
})
}
// 省略代码 modifyTemplate 和 modifyWebpackConfig 具体实现
}
class Weapp
继承于抽象类TaroPlatformBase
继承于抽象类TaroPlatform
如图所示:
关于抽象类和更多类相关,可以参考:
TypeScript 入门教程 - 类
抽象类(Abstract Class):抽象类是供其他类继承的基类,抽象类不允许被实例化。抽象类中的抽象方法必须在子类中被实现
Classes(中文版)
ECMAScript 6 入门 - Class
这样抽象的好处,在于其他端平台插件 比如小红书 基于这个抽象类扩展继承就比较方便。
5. TaroPlatform 端平台插件抽象类
5.1 Transaction 事务
// packages/taro-service/src/platform-plugin-base/platform.ts
interface IWrapper {
init? (): void
close? (): void
}
export class Transaction<T = TaroPlatform> {
wrappers: IWrapper[] = []
async perform (fn: Func, scope: T, ...args: any[]) {
this.initAll(scope)
await fn.call(scope, ...args)
this.closeAll(scope)
}
initAll (scope: T) {
const wrappers = this.wrappers
wrappers.forEach(wrapper => wrapper.init?.call(scope))
}
closeAll (scope: T) {
const wrappers = this.wrappers
wrappers.forEach(wrapper => wrapper.close?.call(scope))
}
addWrapper (wrapper: IWrapper) {
this.wrappers.push(wrapper)
}
}
这样的好处在于,方便按顺序执行 init
perform
close
函数。我们也可以在工作中使用。
我们接着来看 class TaroPlatform
。
// packages/taro-service/src/platform-plugin-base/platform.ts
import { PLATFORM_TYPE } from '@tarojs/shared'
import type { Func } from '@tarojs/taro/types/compile'
import type { IPluginContext, TConfig } from '../utils/types'
const VALID_COMPILER = ['webpack5', 'vite']
const DEFAULT_COMPILER = 'webpack5'
export default abstract class TaroPlatform<T extends TConfig = TConfig> {
protected ctx: IPluginContext
protected config: T
protected helper: IPluginContext['helper']
protected compiler: string
abstract platformType: PLATFORM_TYPE
abstract platform: string
abstract runtimePath: string | string[]
protected setupTransaction = new Transaction<this>()
protected buildTransaction = new Transaction<this>()
constructor (ctx: IPluginContext, config: T) {
this.ctx = ctx
this.helper = ctx.helper
this.config = config
this.updateOutputPath(config)
const _compiler = config.compiler
this.compiler = typeof _compiler === 'object' ? _compiler.type : _compiler
// Note: 兼容 webpack4 和不填写 compiler 的情况,默认使用 webpack5
if (!VALID_COMPILER.includes(this.compiler)) {
this.compiler = DEFAULT_COMPILER
}
}
// 拆开下方
}
5.2 emptyOutputDir 清空输出的文件夹等
// 清空输出的文件夹
protected emptyOutputDir (excludes: Array<string | RegExp> = []) {
const { outputPath } = this.ctx.paths
this.helper.emptyDirectory(outputPath, { excludes })
}
/**
* 如果分端编译详情 webpack 配置了 output 则需更新 outputPath 位置
*/
private updateOutputPath (config: TConfig) {
const platformPath = config.output?.path
if (platformPath) {
this.ctx.paths.outputPath = platformPath
}
}
/**
* 调用 runner 开启编译
*/
abstract start(): Promise<void>
我们来看 TaroPlatformBase
的实现
6. TaroPlatformBase 端平台插件基础抽象类
// packages/taro-service/src/platform-plugin-base/mini.ts
import * as path from 'node:path'
import { recursiveMerge, taroJsMiniComponentsPath } from '@tarojs/helper'
import { isObject, PLATFORM_TYPE } from '@tarojs/shared'
import { getPkgVersion } from '../utils/package'
import TaroPlatform from './platform'
import type { RecursiveTemplate, UnRecursiveTemplate } from '@tarojs/shared/dist/template'
import type { TConfig } from '../utils/types'
interface IFileType {
templ: string
style: string
config: string
script: string
xs?: string
}
export abstract class TaroPlatformBase<T extends TConfig = TConfig> extends TaroPlatform<T> {
platformType = PLATFORM_TYPE.MINI
abstract globalObject: string
abstract fileType: IFileType
abstract template: RecursiveTemplate | UnRecursiveTemplate
// Note: 给所有的小程序平台一个默认的 taroComponentsPath
taroComponentsPath: string = taroJsMiniComponentsPath
projectConfigJson?: string
private projectConfigJsonOutputPath: string
/**
* 调用 runner 开启编译
*/
public async start () {
await this.setup()
await this.build()
}
}
start
实现,setup
再执行 build
。我们来看 setup 函数。
6.1 setup
/**
* 1. 清空 dist 文件夹
* 2. 输出编译提示
* 3. 生成 project.config.json
*/
private async setup () {
await this.setupTransaction.perform(this.setupImpl, this)
this.ctx.onSetupClose?.(this)
}
private setupImpl () {
const { output } = this.config
// webpack5 原生支持 output.clean 选项,但是 webpack4 不支持, 为统一行为,这里做一下兼容
// (在 packages/taro-mini-runner/src/webpack/chain.ts 和 packages/taro-webpack-runner/src/utils/chain.ts 的 makeConfig 中对 clean 选项做了过滤)
// 仅 output.clean 为 false 时不清空输出目录
// eslint-disable-next-line eqeqeq
if (output == undefined || output.clean == undefined || output.clean === true) {
this.emptyOutputDir()
} else if (isObject(output.clean)) {
this.emptyOutputDir(output.clean.keep || [])
}
this.printDevelopmentTip(this.platform)
if (this.projectConfigJson) {
this.generateProjectConfig(this.projectConfigJson)
}
// 省略以下这两部分的代码
// 打印开发者工具-项目目录
// Webpack5 代码自动热重载
}
我们继续来看 build
函数。
6.2 build
/**
* 调用 runner 开始编译
* @param extraOptions 需要额外传入 runner 的配置项
*/
private async build (extraOptions = {}) {
this.ctx.onBuildInit?.(this)
await this.buildTransaction.perform(this.buildImpl, this, extraOptions)
}
private async buildImpl (extraOptions = {}) {
const runner = await this.getRunner()
const options = this.getOptions(
Object.assign(
{
runtimePath: this.runtimePath,
taroComponentsPath: this.taroComponentsPath
},
extraOptions
)
)
await runner(options)
}
我们来看下 getRunner
的实现:
/**
* 返回当前项目内的 runner 包
*/
protected async getRunner () {
const { appPath } = this.ctx.paths
const { npm } = this.helper
const runnerPkg = this.compiler === 'vite' ? '@tarojs/vite-runner' : '@tarojs/webpack5-runner'
const runner = await npm.getNpmPkg(runnerPkg, appPath)
return runner.bind(null, appPath)
}
build
函数最后调用 runner
函数。appPath
项目路径是/Users/ruochuan/git-source/github/taro4-debug
。
初始化项目使用的 webpack5
所以使用的是 @tarojs/webpack5-runner
我们来看它的具体实现。
7. runner => @tarojs/webpack5-runner
package.json
属性 "main": "index.js" 入口文件 index.js
if (process.env.TARO_PLATFORM === 'web') {
module.exports = require('./dist/index.h5.js').default
} else if (process.env.TARO_PLATFORM === 'harmony' || process.env.TARO_ENV === 'harmony') {
module.exports = require('./dist/index.harmony.js').default
} else {
module.exports = require('./dist/index.mini.js').default
}
module.exports.default = module.exports
本文中打包微信小程序,根据 process.env.TARO_PLATFORM
和 process.env.TARO_ENV
调用的是打包后的文件 dist/index.mini.js
,源码文件是 packages/taro-webpack5-runner/src/index.mini.ts
。
// packages/taro-webpack5-runner/src/index.mini.ts
import webpack from 'webpack'
// 省略若干代码
export default async function build (appPath: string, rawConfig: IMiniBuildConfig): Promise<Stats | void> {
const combination = new MiniCombination(appPath, rawConfig)
await combination.make()
// 省略若干代码
const webpackConfig = combination.chain.toConfig()
const config = combination.config
return new Promise<Stats | void>((resolve, reject) => {
if (config.withoutBuild) return
const compiler = webpack(webpackConfig)
const callback = async (err: Error, stats: Stats) => {
// 省略若干代码
onFinish(null, stats)
resolve(stats)
}
if (config.isWatch) {
compiler.watch({
aggregateTimeout: 300,
poll: undefined
}, callback)
} else {
compiler.run((err: Error, stats: Stats) => {
compiler.close(err2 => callback(err || err2, stats))
})
}
})
}
const compiler = webpack(webpackConfig)
使用 webpack
编译。这部分代码比较多。后续有空再写 webpack
编译的文章。
8. 总结
我们学习了两种方式如何调试 taro
build 部分的源码。
我们来总结下,打包构建流程简单梳理。
- 调用 taro 插件 build
- 调用端平台插件 Weapp
- 平台插件继承自 TaroPlatformBase 端平台插件基础抽象类
- 平台插件继承自 TaroPlatform 端平台插件抽象类
简版代码
class Weapp extends TaroPlatformBase{}
export abstract class TaroPlatformBase extends TaroPlatform{
public async start () {
/**
* 1. 清空 dist 文件夹
* 2. 输出编译提示
* 3. 生成 project.config.json
*/
await this.setup()
/**
* 调用 runner 开始编译
* @param extraOptions 需要额外传入 runner 的配置项
*/
await this.build()
}
}
export default (ctx: IPluginContext, options: IOptions) => {
ctx.registerPlatform({
name: 'weapp',
useConfigName: 'mini',
async fn ({ config }) {
const program = new Weapp(ctx, config, options || {})
await program.start()
}
})
}
最后调用的是 @tarojs/webpack5-runner
,webpack
编译打包生成项目文件。
在 packages/taro-service/dist/Kernel.js
的 applyPlugins
方法中,打印出 plugins-name
,调用插件的依次顺序是:
也是开发者开发插件配置钩子的执行。
更多 Taro
端平台插件实现细节可参考Taro文档 - 端平台插件,更多 Taro
实现细节也可以参考官方文档:Taro 实现细节。
如果看完有收获,欢迎点赞、评论、分享、收藏支持。你的支持和肯定,是我写作的动力。也欢迎提建议和交流讨论。
作者:常以若川为名混迹于江湖。所知甚少,唯善学。若川的博客,github blog,可以点个 star
鼓励下持续创作。
最后可以持续关注我@若川,欢迎关注我的公众号:若川视野。我倾力持续组织了 3 年多每周大家一起学习 200 行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02
参与。另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(6k+人)第一的专栏,写有几十篇源码文章。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。