再见二丁目 | yitimo的个人日志

再见二丁目

深入 taro 运行时 - @tarojs/taro 模块的实现

发布于: 2025-06-01 18:39

taro工程下有很多子包, 以下是 AI 给的各个子包简单说明, 供参考:

今天先重点来看看比较常用到的 @tarojs/taro 包的实现细节, 与之相关的有 taro、taro-api、taro-runtime、shared 等目录, 对应 @tarojs/taro@tarojs/taro-api@tarojs/taro-runtime@tarojs/shared这几个 npm 包.

整个 @tarojs/taro 包的实际 js 代码就以下几行:

const { hooks } = require('@tarojs/runtime')
const taro = require('@tarojs/api').default

if (hooks.isExist('initNativeApi')) {
  hooks.call('initNativeApi', taro)
}

module.exports = taro
module.exports.default = module.exports

主要涉及了三块内容:

另外包里还有完整的 types 类型定义和 h5 下的 css 样式文件.

types类型定义

所有类型都定义在入口模块下, 通过 /// <reference path="...">/// <reference types="...">,引入来拆分子目录文件方便维护,包括:

其中 TaroStatic 会被挂载到 default 导出的对象上, 即最终 import Taro from '@tarojs/taro' 导入的大对象的类型

还有 index.d.ts 里定义了三个全局类型:

小细节像 getCurrentPages/getApp 这些小程序下的全局函数, taro 是没有声明到 global 的, 而是也声明到了 TaroStatic 里, 实际上 taro 代码里也是这样来调用的: Taro.getCurrentPages()

Taro对象

来到 packages/taro-api 目录下, @tarojs/api 包默认导出了这些字段:

const Taro: Record<string, unknown> = {
  Current, // 从 @tarojs/runtime 引入的 Current 对象, 包含 app, page, router 等运行时状态数据
  getCurrentInstance, // 从 @tarojs/runtime 引入, 返回 Current 对象
  eventCenter, // 从 @tarojs/runtime 引入的事件系统实例, 运行时触发 getEventCenter hook 并 new 出来
  Events, // 从 @tarojs/runtime 引入, 事件class, eventCenter 也是 new 了此实例
  nextTick, // 从 @tarojs/runtime 引入, 内部二次实现的 wx.nextTick
  options, // 从 @tarojs/runtime 引入, 用来定制运行时行为, 比如是否开启 prerender, 是否开启 debug 等

  ENV_TYPE, // 是个枚举常量, 对应 process.env.TARO_ENV 里的各平台值
  getEnv, // 根据 process.env.TARO_ENV 返回 ENV_TYPE 枚举值

  Behavior, // 模拟小程序的 Behavior 机制, 默认实现直接返回了传入的 options
  Link, // request 拦截器类, Taro.request 就包装了一层 new Link 的拦截器容器实例
  interceptors, // timeoutInterceptor 和 logInterceptor 两个内置 request 拦截器
  interceptorify, // 返回一个 Link 拦截器实例的快捷方法, 内部没有实际使用
  // 返回一个函数, 能将 designWidth/deviceRatio 等尺寸转换相关配置设置到 taro.config 里
  // taro 编译时会注入到 entry 代码里执行
  getInitPxTransform,
}

// 挂载 设计稿初始化函数, 支持手动调用
Taro.initPxTransform = getInitPxTransform(Taro)
// 挂载 px 转换函数, 用来在 jsx style 里转成 rpx/rem
Taro.pxTransform = getPxTransform(Taro)
// 挂载 preload 函数, 功能就是将字段设置到 Current.preloadData 里, 可以用于页面跳转传参
Taro.preload = getPreload(Current)

export default Taro

看到这里会发现导出的对象和实际使用时的 api 实现并不一致, 而且缺少了很多平台 api, 我们往下看具体做了什么.

通用 api 挂载实现

接下来举例看看部分通用 api, 首先是 pxTransform 的实现:

export function getPxTransform (taro) {
  return function (size) {
    // 从全局配置里取设计尺寸
    const config = taro.config || {}
    const baseFontSize = config.baseFontSize
    const deviceRatio = config.deviceRatio || defaultDesignRatio
    const designWidth = ((input = 0) => isFunction(config.designWidth)
      ? config.designWidth(input)
      : config.designWidth || defaultDesignWidth)(size)
    if (!(designWidth in deviceRatio)) {
      throw new Error(`deviceRatio 配置中不存在 ${designWidth} 的设置!`)
    }
    const targetUnit = config.targetUnit || defaultTargetUnit
    const unitPrecision = config.unitPrecision || defaultUnitPrecision
    // 向下取整
    const formatSize = ~~size
    // 基于当前设计尺寸的目标单位比例, 比如默认 750 尺寸下比例为 1
    let rootValue = 1 / deviceRatio[designWidth]
    switch (targetUnit) {
      case 'rem':
        // 转 rem 基于基础字号的 2 倍
        rootValue *= baseFontSize * 2
        break
      case 'px':
        // 转 px 基于自身的 2 倍
        rootValue *= 2
        break
    }
    // 原始尺寸转目标单位尺寸: 1px -> 2px/0.025rem
    let val = formatSize / rootValue
    if (unitPrecision >= 0 && unitPrecision <= 100) {
      // 保护小数点 默认是 5 位
      val = Number(val.toFixed(unitPrecision))
    }
    return val + targetUnit
  }
}

函数最终会获取配置好的设计尺寸计算出和 css 里一致的 rpx 数值, 并加上目标单位(仍然是 px)返回, 至于 px 单位转 rpx 单位是另外由 postcss-pxtransform 插件完成的.

然后看看似曾相识的 Taro.nextTick api. 我们知道 vue 里有 this.nextTick 方法在数据触发 dom 更新后回调, 小程序里也有 wx.nextTick 等待本轮同步 setData 完成后的微任务队列里回调, 而 react 里直接没有这个 api, 看看 taro 是怎么实现的:

import { Current } from './current'
import { TaroRootElement } from './dom/root'
import env from './env'

import type { TFunc } from './interface'

const TIMEOUT = 100

export const nextTick = (cb: TFunc, ctx?: Record<string, any>) => {
  const beginTime = Date.now()
  const router = Current.router

  const timerFunc = () => {
    setTimeout(function () {
      ctx ? cb.call(ctx) : cb()
    }, 1)
  }

  if (router === null) return timerFunc()

  const path = router.$taroPath

  /**
   * 三种情况
   *   1. 调用 nextTick 时,pendingUpdate 已经从 true 变为 false(即已更新完成),那么需要光等 100ms
   *   2. 调用 nextTick 时,pendingUpdate 为 true,那么刚好可以搭上便车
   *   3. 调用 nextTick 时,pendingUpdate 还是 false,框架仍未启动更新逻辑,这时最多轮询 100ms,等待 pendingUpdate 变为 true。
   */
  function next () {
    const pageElement: TaroRootElement | null = env.document.getElementById<TaroRootElement>(path)
    if (pageElement?.pendingUpdate) {
      if (process.env.TARO_PLATFORM === 'web') {
        // eslint-disable-next-line dot-notation
        pageElement.firstChild?.['componentOnReady']?.().then(() => {
          timerFunc()
        }) ?? timerFunc()
      } else {
        pageElement.enqueueUpdateCallback(cb, ctx)
      }
    } else if (Date.now() - beginTime > TIMEOUT) {
      timerFunc()
    } else {
      setTimeout(() => next(), 20)
    }
  }

  next()
}

taro 框架下更新状态是从根节点下发的, 并且内部优化了 setData 的调用, 也就是上面代码里的 pageElement, 而 Taro.nextTick 主要就是在等待下一轮更新开始然后回调, 而且是按 20ms 的分片来轮询, 最多到 100ms.

Taro.nextTick(): setState(react) -> render(react) -> setData(taro) -> setData(wx) -> 触发回调

平台 api 挂载

以上介绍了 taro 下全平台通用 api 的挂载和实现, 那么 taro 又是如何挂载某个具体平台下的常规 api 的呢? 答案在 initNativeApi 这个 hook 里.

以微信平台为例, 先找到 packages/taro-platform-weapp/src/runtime.ts 这个模块:

import { mergeInternalComponents, mergeReconciler } from '@tarojs/shared'
import { components, hostConfig } from './runtime-utils'

mergeReconciler(hostConfig)
// ...

这个模块最终会在项目构建时注入到头部代码里, 然后在应用启动时前置执行, 具体的 taro 构建流程后续会另开一篇介绍 :)

其中 mergeReconciler 函数会调用入参 hostConfig 里的 hook 函数:

export function mergeReconciler (hostConfig, hooksForTest?) {
  // hooks 是 packages/shared/src/runtime-hooks.ts 里创建的 taro 内部 hook 列表, 其中就有 initNativeApi
  const obj = hooksForTest || hooks
  const keys = Object.keys(hostConfig)
  keys.forEach(key => {
    obj.tap(key, hostConfig[key])
  })
}

然后 hostConfig 里透传了 initNativeApi 函数:

// ...
import { initNativeApi } from './apis'
// ...
export const hostConfig = {
  initNativeApi,
  // ...
}

看看 initNativeApi 函数的实现:

import { processApis } from '@tarojs/shared'

import { needPromiseApis } from './apis-list'

declare const wx: any

export function initNativeApi (taro) {
  processApis(taro, wx, {
    // ...
  })
  taro.cloud = wx.cloud
  taro.getTabBar = function (pageCtx) {
    if (typeof pageCtx?.getTabBar === 'function') {
      return pageCtx.getTabBar()?.$taroInstances
    }
  }
  taro.getRenderer = function () {
    return taro.getCurrentInstance()?.page?.renderer ?? 'webview'
  }
}

果然是在挂载 api 到 taro 对象上, 最后看看 processApis 函数的实现:

function processApis (taro, global, config: IProcessApisIOptions = {}) {
  // ...
  const patchNeedPromiseApis = config.needPromiseApis || []
  const _needPromiseApis = new Set<string>([...patchNeedPromiseApis, ...needPromiseApis])
  const preserved = [
    // ...不需要处理的一些 api
  ]
  const apis = new Set(
    !config.isOnlyPromisify
      ? Object.keys(global).filter(api => preserved.indexOf(api) === -1)
      : patchNeedPromiseApis
  )
  // ...
  apis.forEach(key => {
    if (_needPromiseApis.has(key)) {
      const originKey = key
      taro[originKey] = (options: Record<string, any> | string = {}, ...args) => {
        // ...
        // Promise 化
        const p: any = new Promise((resolve, reject) => {
          obj.success = res => {
            config.modifyAsyncResult?.(key, res)
            options.success?.(res)
            if (key === 'connectSocket') {
              resolve(
                Promise.resolve().then(() => task ? Object.assign(task, res) : res)
              )
            } else {
              resolve(res)
            }
          }
          obj.fail = res => {
            options.fail?.(res)
            reject(res)
          }
          obj.complete = res => {
            options.complete?.(res)
          }
          if (args.length) {
            task = global[key](obj, ...args)
          } else {
            task = global[key](obj)
          }
        })
        // ...
        return p
      }
    } else {
      // ...
    }
  })
  // ...
}

常用 api 挂载

taro 挂载完平台 api 后, 还会挂载一些常用 api, 比如 Taro.getCurrentPages 等, 此外一开始看到 taro 大对象里有拦截器相关字段, 这里还会二次包装 request api 来支持拦截器能力:

function equipCommonApis (taro, global, apis: Record<string, any> = {}) {
  taro.canIUseWebp = getCanIUseWebp(taro)
  taro.getCurrentPages = getCurrentPages || nonsupport('getCurrentPages')
  taro.getApp = getApp || nonsupport('getApp')
  taro.env = global.env || {}

  try {
    taro.requirePlugin = requirePlugin || nonsupport('requirePlugin')
  } catch (error) {
    taro.requirePlugin = nonsupport('requirePlugin')
  }

  // request & interceptors
  const request = apis.request || getNormalRequest(global)
  function taroInterceptor (chain) {
    return request(chain.requestParams)
  }
  const link = new taro.Link(taroInterceptor)
  taro.request = link.request.bind(link)
  taro.addInterceptor = link.addInterceptor.bind(link)
  taro.cleanInterceptors = link.cleanInterceptors.bind(link)
  taro.miniGlobal = taro.options.miniGlobal = global
  taro.getAppInfo = function () {
    return {
      platform: process.env.TARO_PLATFORM || 'MiniProgram',
      taroVersion: process.env.TARO_VERSION || 'unknown',
      designWidth: taro.config.designWidth
    }
  }
  taro.createSelectorQuery = delayRef(taro, global, 'createSelectorQuery', 'exec')
  taro.createIntersectionObserver = delayRef(taro, global, 'createIntersectionObserver', 'observe')
}

总结

先来总结一下 import Taro from '@tarojs/taro' 背后都做了什么: taro_api_bound

这解释了为何在单测环境下直接使用 @tarojs/taro 会找不到 api, 甚至会有奇怪报错, 因为其背后依赖了一些 taro 运行时启动代码. 正确方式应该将其 mock 掉, 并且尝试将可测试代码与 taro 解耦.

这样设计也提供了一些参考: 除了到处 if/else 之外, 还可以如何封装多平台兼容的工具库

参考链接: