Taro3 跨端跨框架原理初探

背景

在項目中,我們用到了 Taro3 進行跨端開發,在這裏分享下 Taro3 的一些跨端跨框架原理,也是希望能幫助自己更好的去理解 Taro 背後的事情,也能加速定位問題。此文章也是起到一個拋磚引玉的效果,讓自己理解的更加深入一點。

過去的整體架構,它分爲兩個部分,第⼀部分是編譯時,第⼆部分是運⾏時。編譯時會先對⽤戶的 React 代碼進⾏編譯,轉換成各個端上的⼩程序都可以運⾏的代碼,然後再在各個⼩程序端上⾯都配上⼀個對應的運⾏時框架進⾏適配,最終讓這份代碼運⾏在各個⼩程序端上⾯。

Taro3 之前(重編譯時,輕運行時):

編譯時是使用 babel-parser 將 Taro 代碼解析成抽象語法樹,然後通過 babel-types 對抽象語法樹進行一系列修改、轉換操作,最後再通過 babel-generate 生成對應的目標代碼。

這樣的實現過程有三⼤缺點:

再看⼀下運⾏時的缺陷。對於每個⼩程序平臺,都會提供對應的⼀份運⾏時框架進⾏適配。當修改⼀些 Bug 或者新增⼀些特性的時候,需要同時去修改多份運⾏時框架。

總的來說,之前的 Taro3.0 之前有以下問題:

Taro3 之後(重運行時):

Taro 3 則可以大致理解爲解釋型架構(相對於 Taro 1/2 而言),主要通過在小程序端模擬實現 DOM、BOM API 來讓前端框架直接運行在小程序環境中,從而達到小程序和 H5 統一的目的,而對於生命週期、組件庫、API、路由等差異,依然可以通過定義統一標準,各端負責各自實現的方式來進行抹平。而正因爲 Taro 3 的原理,在 Taro 3 中同時支持 React、Vue 等框架,甚至還支持了 jQuery,還能支持讓開發者自定義地去拓展其他框架的支持,比如 Angular,Taro 3 整體架構如下:

Taro 小程序:

Taro 3 之後 ⼩程序端的整體架構。⾸先是⽤戶的 React 或 Vue 的代碼會通過 CLI 進⾏ Webpack 打包,其次在運⾏時會提供 React 和 Vue 對應的適配器進⾏適配,然後調⽤ Taro 提供的 DOM 和 BOM API, 最後把整個程序渲染到所有的⼩程序端上⾯。

React 有點特殊,因爲 React-DOM 包含大量瀏覽器兼容類的代碼,導致包太大,而這部分代碼是不需要的,因此做了一些定製和優化。

在 React 16+ ,React 的架構如下:

最上層是 React 的核心部分 react-core ,中間是 react-reconciler,其的職責是維護 VirtualDOM 樹,內部實現了 Diff/Fiber 算法,決定什麼時候更新、以及要更新什麼。

Renderer 負責具體平臺的渲染工作,它會提供宿主組件、處理事件等等。例如 React-DOM 就是一個渲染器,負責 DOM 節點的渲染和 DOM 事件處理。

Taro 實現了 taro-react 包,用來連接 react-reconcilertaro-runtime 的 BOM/DOM API。是基於 react-reconciler 的小程序專用 React 渲染器,連接 @tarojs/runtime的 DOM 實例,相當於小程序版的 react-dom,暴露的 API 也和 react-dom 保持一致。

創建一個自定義渲染器只需兩步: 具體的實現主要分爲兩步:

第一步: ** 實現宿主配置 ( 實現**react-reconcilerhostConfig**配置) 這是react-reconciler要求宿主提供的一些適配器方法和配置項。這些配置項定義瞭如何創建節點實例、構建節點樹、提交和更新等操作。即在 hostConfig 的方法中調用對應的 Taro BOM/DOM 的 API。

const Reconciler = require('react-reconciler');



const HostConfig = {

  // ... 實現適配器方法和配置項

};
# @tarojs/react reconciler.ts



/* eslint-disable @typescript-eslint/indent */

import Reconciler, { HostConfig } from 'react-reconciler'

import * as scheduler from 'scheduler'

import { TaroElement, TaroText, document } from '@tarojs/runtime'

import { noop, EMPTY_ARR } from '@tarojs/shared'

import { Props, updateProps } from './props'



const {

  unstable_scheduleCallback: scheduleDeferredCallback,

  unstable_cancelCallback: cancelDeferredCallback,

  unstable_now: now

} = scheduler



function returnFalse () {

  return false

}



const hostConfig: HostConfig<

  string, // Type

  Props, // Props

  TaroElement, // Container

  TaroElement, // Instance

  TaroText, // TextInstance

  TaroElement, // HydratableInstance

  TaroElement, // PublicInstance

  Record<string, any>, // HostContext

  string[], // UpdatePayload

  unknown, // ChildSet

  unknown, // TimeoutHandle

  unknown // NoTimeout

> & {

  hideInstance (instance: TaroElement): void

  unhideInstance (instance: TaroElement, props): void

} = {

  createInstance (type) {

    return document.createElement(type)

  },



  createTextInstance (text) {

    return document.createTextNode(text)

  },



  getPublicInstance (inst: TaroElement) {

    return inst

  },



  getRootHostContext () {

    return {}

  },



  getChildHostContext () {

    return {}

  },



  appendChild (parent, child) {

    parent.appendChild(child)

  },



  appendInitialChild (parent, child) {

    parent.appendChild(child)

  },



  appendChildToContainer (parent, child) {

    parent.appendChild(child)

  },



  removeChild (parent, child) {

    parent.removeChild(child)

  },



  removeChildFromContainer (parent, child) {

    parent.removeChild(child)

  },



  insertBefore (parent, child, refChild) {

    parent.insertBefore(child, refChild)

  },



  insertInContainerBefore (parent, child, refChild) {

    parent.insertBefore(child, refChild)

  },



  commitTextUpdate (textInst, _, newText) {

    textInst.nodeValue = newText

  },



  finalizeInitialChildren (dom, _, props) {

    updateProps(dom, {}, props)

    return false

  },



  prepareUpdate () {

    return EMPTY_ARR

  },



  commitUpdate (dom, _payload, _type, oldProps, newProps) {

    updateProps(dom, oldProps, newProps)

  },



  hideInstance (instance) {

    const style = instance.style

    style.setProperty('display''none')

  },



  unhideInstance (instance, props) {

    const styleProp = props.style

    let display = styleProp?.hasOwnProperty('display') ? styleProp.display : null

    display = display == null || typeof display === 'boolean' || display === '' ? '' : ('' + display).trim()

    // eslint-disable-next-line dot-notation

    instance.style['display'] = display

  },



  shouldSetTextContent: returnFalse,

  shouldDeprioritizeSubtree: returnFalse,

  prepareForCommit: noop,

  resetAfterCommit: noop,

  commitMount: noop,

  now,

  scheduleDeferredCallback,

  cancelDeferredCallback,

  clearTimeout: clearTimeout,

  setTimeout: setTimeout,

  noTimeout: -1,

  supportsMutation: true,

  supportsPersistence: false,

  isPrimaryRenderer: true,

  supportsHydration: false

}



export const TaroReconciler = Reconciler(hostConfig)

第二步:實現渲染函數,類似於 ReactDOM.render() 方法。可以看成是創建 Taro DOM Tree 容器的方法。

// 創建Reconciler實例, 並將HostConfig傳遞給Reconciler

const MyRenderer = Reconciler(HostConfig);



/**

 * 假設和ReactDOM一樣,接收三個參數

 * render(<MyComponent />, container, () => console.log('rendered'))

 */

export function render(element, container, callback) {

  // 創建根容器

  if (!container._rootContainer) {

    container._rootContainer = ReactReconcilerInst.createContainer(container, false);

  }



  // 更新根容器

  return ReactReconcilerInst.updateContainer(element, container._rootContainer, null, callback);

}

經過上面的步驟,React 代碼實際上就可以在小程序的運行時正常運行了,並且會生成 Taro DOM Tree。那麼偌大的 Taro DOM Tree 怎樣更新到頁面呢?

因爲⼩程序並沒有提供動態創建節點的能⼒,需要考慮如何使⽤相對靜態的 wxml 來渲染相對動態的 Taro DOM 樹。Taro 使⽤了模板拼接的⽅式,根據運⾏時提供的 DOM 樹數據結構,各 templates 遞歸地 相互引⽤,最終可以渲染出對應的動態 DOM 樹。

首先,將小程序的所有組件挨個進行模版化處理,從而得到小程序組件對應的模版。如下圖就是小程序的 view 組件模版經過模版化處理後的樣子。⾸先需要在 template ⾥⾯寫⼀個 view,把它所有的屬性全部列出來(把所有的屬性都列出來是因爲⼩程序⾥⾯不能去動態地添加屬性)。

接下來是遍歷渲染所有⼦節點,基於組件的 template,動態 “遞歸” 渲染整棵樹

具體流程爲先去遍歷 Taro DOM Tree 根節點的子元素,再根據每個子元素的類型選擇對應的模板來渲染子元素,然後在每個模板中我們又會去遍歷當前元素的子元素,以此把整個節點樹遞歸遍歷出來。

Taro H5:

Taro 這邊遵循的是以微信小程序爲主,其他小程序爲輔的組件與 API 規範。 但瀏覽器並沒有小程序規範的組件與 API 可供使用,我們不能在瀏覽器上使用小程序的 view 組件和 getSystemInfo API。因此 Taro 在 H5 端實現一套基於小程序規範的組件庫和 API 庫。

再來看⼀下 H5 端的架構,同樣的也是需要把⽤戶的 React 或者 Vue 代碼通過 Webpack 進⾏打包。然後在運⾏時做了三件事情:第⼀件事情是實現了⼀個組件庫,組件庫需要同時給到 React 、Vue 甚⾄更加多的⼀些框架去使⽤,Taro 使⽤了 Stencil 去實現了⼀個基於 WebComponents 且遵循微信⼩程序規範的組件庫,第⼆、三件事是實現了⼀個⼩程序規範的 API 和路由機制,最終就可以把整個程序給運⾏在瀏覽器上⾯。下面,我們主要關注實現組件庫。

實現組件庫:

最先容易想到的是使用 Vue 再開發一套組件庫,這樣最爲穩妥,工作量也沒有特別大。

但考慮到以下兩點,官方遂放棄了此思路:

  1. 組件庫的可維護性和拓展性不足。每當有問題需要修復或新功能需要添加,需要分別對 React 和 Vue 版本的組件庫進行改造。

  2. Taro Next 的目標是支持使用任意框架開發多端應用。倘若將來支持使用 Angular 等框架進行開發,那麼需要再開發對應支持 Angular 等框架的組件庫。

那麼是否存在着一種方案,使得只用一份代碼構建的組件庫能兼容所有的 web 開發框架呢?

Taro 的選擇是 Web Components

Web Components

Web Components 由一系列的技術規範所組成,它讓開發者可以開發出瀏覽器原生支持的組件。允許你創建可重用的定製元素(它們的功能封裝在你的代碼之外)並且在你的 web 應用中使用它們。

Web Components— 它由三項主要技術組成,它們可以一起使用來創建封裝功能的定製元素,可以在你喜歡的任何地方重用,不必擔心代碼衝突。

實現 web component 的基本方法通常如下所示:

  1. 創建一個類或函數來指定 web 組件的功能。

  2. 使用  CustomElementRegistry.define() 方法註冊您的新自定義元素 ,並向其傳遞要定義的元素名稱、指定元素功能的類、以及可選的其所繼承自的元素。

  3. 如果需要的話,使用 Element.attachShadow() 方法將一個 shadow DOM 附加到自定義元素上。使用通常的 DOM 方法向 shadow DOM 中添加子元素、事件監聽器等等。

  4. 如果需要的話,使用<template> 和 <slot> 定義一個 HTML 模板。再次使用常規 DOM 方法克隆模板並將其附加到您的 shadow DOM 中。

  5. 在頁面任何您喜歡的位置使用自定義元素,就像使用常規 HTML 元素那樣。

示例

定義模板:

<template id="template">

  <h1>Hello World!</h1>

</template>

構造 Custom Element:

class App extends HTMLElement {

  constructor () {

    super(...arguments)



    // 開啓 Shadow DOM

    const shadowRoot = this.attachShadow({ mode: 'open' })



    // 複用 <template> 定義好的結構

    const template = document.querySelector('#template')

    const node = template.content.cloneNode(true)

    shadowRoot.appendChild(node)

  }

}

window.customElements.define('my-app', App)

使用:

<my-app></my-app>

使用原生語法去編寫 Web Components 相當繁瑣,因此需要一個框架幫助我們提高開發效率和開發體驗。業界已經有很多成熟的 Web Components 框架,Taro 選擇的是 Stencil,Stencil 是一個可以生成 Web Components 的編譯器。它糅合了業界前端框架的一些優秀概念,如支持 Typescript、JSX、虛擬 DOM 等。

創建 Stencil Component:

import { Component, Prop, State, h } from '@stencil/core'



@Component({

  tag: 'my-component'

})

export class MyComponent {

  @Prop() first = ''

  @State() last = 'JS'



  componentDidLoad () {

    console.log('load')

  }



  render () {

    return (

      <div>

        Hello, my name is {this.first} {this.last}

      </div>

    )

  }

}

使用組件:

<my-component first='Taro' />

在 React 中使用 Stencil

Custom Elements Everywhere[1] 上羅列出業界前端框架對 Web Components 的兼容問題及相關 issues。在 React 文檔中,也略微提到過在在 React 中使用 Web Components 的注意事項 https://zh-hans.reactjs.org/docs/web-components.html#using-web-components-in-react

在 Custom Elements Everywhere 上可以看到,React 對 Web Components 的兼容問題。

翻譯過來,就是說。

  1. React 使用 setAttribute 的形式給 Web Components 傳遞參數。當參數爲原始類型時是可以運行的,但是如果參數爲對象或數組時,由於 HTML 元素的 attribute 值只能爲字符串或 null,最終給 WebComponents 設置的 attribute 會是 attr="[object Object]"

attribute 和 property 的區別:https://stackoverflow.com/questions/6003819/what-is-the-difference-between-properties-and-attributes-in-html#answer-6004028,不展開說了。

  1. 因爲 React 有一套自己實現的合成事件系統,所以它不能監聽到 Web Components 發出的自定義事件。

reactify-wc.js

實際上,這個高階組件的實現是根據開源庫 reactify-wc 修改的一個版本,reactify-wc 是一個銜接 WebComponent 和 React 的庫,目的是爲了在 React 中能夠使用 WebComponent。這個修改的庫就是爲了解決上述所說的問題。

props

Taro 的處理,採用 DOM Property 的方法傳參。把 Web Components 包裝一層高階組件,把高階組件上的 props 設置爲 Web Components 的 property。

const reactifyWebComponent = WC => {

 return class Index extends React.Component {

  ref = React.createRef()

  update (prevProps) {

      this.clearEventHandlers()

      if (!this.ref.current) return



      Object.keys(prevProps || {}).forEach((key) => {

        if (key !== 'children' && key !== 'key' && !(key in this.props)) {

          updateProp(this, WC, key, prevProps, this.props)

        }

      })



      Object.keys(this.props).forEach((key) => {

        updateProp(this, WC, key, prevProps, this.props)

      })

  }

 

  componentDidUpdate () {

    this.update()

  }

 

  componentDidMount () {

    this.update()

  }

 

  render () {

    const { children, dangerouslySetInnerHTML } = this.props

    return React.createElement(WC, {

      ref: this.ref,

      dangerouslySetInnerHTML

    }, children)

}
Event

因爲 React 有一套自己實現的合成事件系統,所以它不能監聽到 Web Components 發出的自定義事件。以下 Web Component 的 onLongPress 回調不會被觸發:

<my-view onLongPress={onLongPress}>view</my-view>

通過 ref 取得 Web Component 元素,手動 addEventListener 綁定事件。改造上述的高階組件:

const reactifyWebComponent = WC ={

  return class Index extends React.Component {

    ref = React.createRef()

    eventHandlers = []



    update () {

      this.clearEventHandlers()



      Object.entries(this.props).forEach(([prop, val]) ={

        if (typeof val === 'function' && prop.match(/^on[A-Z]/)) {

          const event = prop.substr(2).toLowerCase()

          this.eventHandlers.push([event, val])

          return this.ref.current.addEventListener(event, val)

        }



        ...

      })

    }



    clearEventHandlers () {

      this.eventHandlers.forEach(([event, handler]) ={

        this.ref.current.removeEventListener(event, handler)

      })

      this.eventHandlers = []

    }



    componentWillUnmount () {

      this.clearEventHandlers()

    }



    ...

  }

}
ref

爲了解決 Props 和 Events 的問題,引入了高階組件。那麼當開發者向高階組件傳入 ref 時,獲取到的其實是高階組件,但我們希望開發者能獲取到對應的 Web Component。

domRef 會獲取到 MyComponent,而不是 <my-component></my-component>

<MyComponent ref={domRef} />

使用 forwardRef[2] 傳遞 ref。改造上述的高階組件爲 forwardRef 形式:

const reactifyWebComponent = WC ={

  class Index extends React.Component {

    ...



    render () {

      const { children, forwardRef } = this.props

      return React.createElement(WC, {

        ref: forwardRef

      }, children)

    }

  }

  return React.forwardRef((props, ref) =(

    React.createElement(Index, { ...props, forwardRef: ref })

  ))

}
className

在 Stencil 裏我們可以使用 Host 組件爲 host element 添加類名。

import { Component, Host, h } from '@stencil/core';



@Component({

  tag: 'todo-list'

})

export class TodoList {

  render () {

    return (

      <Host class='todo-list'>

        <div>todo</div>

      </Host>

    )

  }

}

然後在使用 <todo-list> 元素時會展示我們內置的類名 “todo-list” 和 Stencil 自動加入的類名 “hydrated”:

關於類名 “hydrated”:

Stencil 會爲所有 Web Components 加上 visibility: hidden; 的樣式。然後在各 Web Component 初始化完成後加入類名 “hydrated”,將 visibility 改爲 inherit。如果 “hydrated” 被抹除掉,Web Components 將不可見。

爲了不要覆蓋 wc 中 host 內置的 class 和 stencil 加入的 class, 對對內置 class 進行合併。

function getClassName (wc, prevProps, props) {

  const classList = Array.from(wc.classList)

  const oldClassNames = (prevProps.className || prevProps.class || '').split(' ')

  let incomingClassNames = (props.className || props.class || '').split(' ')

  let finalClassNames = []



  classList.forEach(classname ={

    if (incomingClassNames.indexOf(classname) > -1) {

      finalClassNames.push(classname)

      incomingClassNames = incomingClassNames.filter(name => name !== classname)

    } else if (oldClassNames.indexOf(classname) === -1) {

      finalClassNames.push(classname)

    }

  })



  finalClassNames = [...finalClassNames, ...incomingClassNames]



  return finalClassNames.join(' ')

}

到這裏,我們的 reactify-wc 就打造好了。我們不要忘了,Stencil 是幫我們寫 web components 的,reactify-wc 目的是爲了在 React 中能夠使用 WebComponent。如下包裝後,我們就能直接在 react 裏面用 View、Text 等組件了

//packages/taro-components/h5



import reactifyWc from './utils/reactify-wc'

import ReactInput from './components/input'



export const View = reactifyWc('taro-view-core')

export const Icon = reactifyWc('taro-icon-core')

export const Progress = reactifyWc('taro-progress-core')

export const RichText = reactifyWc('taro-rich-text-core')

export const Text = reactifyWc('taro-text-core')

export const Button = reactifyWc('taro-button-core')

export const Checkbox = reactifyWc('taro-checkbox-core')

export const CheckboxGroup = reactifyWc('taro-checkbox-group-core')

export const Editor = reactifyWc('taro-editor-core')

export const Form = reactifyWc('taro-form-core')

export const Input = ReactInput

export const Label = reactifyWc('taro-label-core')

export const Picker = reactifyWc('taro-picker-core')

export const PickerView = reactifyWc('taro-picker-view-core')

export const PickerViewColumn = reactifyWc('taro-picker-view-column-core')

export const Radio = reactifyWc('taro-radio-core')

export const RadioGroup = reactifyWc('taro-radio-group-core')

export const Slider = reactifyWc('taro-slider-core')

export const Switch = reactifyWc('taro-switch-core')

export const CoverImage = reactifyWc('taro-cover-image-core')

export const Textarea = reactifyWc('taro-textarea-core')

export const CoverView = reactifyWc('taro-cover-view-core')

export const MovableArea = reactifyWc('taro-movable-area-core')

export const MovableView = reactifyWc('taro-movable-view-core')

export const ScrollView = reactifyWc('taro-scroll-view-core')

export const Swiper = reactifyWc('taro-swiper-core')

export const SwiperItem = reactifyWc('taro-swiper-item-core')

export const FunctionalPageNavigator = reactifyWc('taro-functional-page-navigator-core')

export const Navigator = reactifyWc('taro-navigator-core')

export const Audio = reactifyWc('taro-audio-core')

export const Camera = reactifyWc('taro-camera-core')

export const Image = reactifyWc('taro-image-core')

export const LivePlayer = reactifyWc('taro-live-player-core')

export const Video = reactifyWc('taro-video-core')

export const Map = reactifyWc('taro-map-core')

export const Canvas = reactifyWc('taro-canvas-core')

export const Ad = reactifyWc('taro-ad-core')

export const OfficialAccount = reactifyWc('taro-official-account-core')

export const OpenData = reactifyWc('taro-open-data-core')

export const WebView = reactifyWc('taro-web-view-core')

export const NavigationBar = reactifyWc('taro-navigation-bar-core')

export const Block = reactifyWc('taro-block-core')

export const CustomWrapper = reactifyWc('taro-custom-wrapper-core')
//packages/taro-components/src/components/view/view.tsx

//拿View組件舉個例子



// eslint-disable-next-line @typescript-eslint/no-unused-vars

import { Component, Prop, h, ComponentInterface, Host, Listen, State, Event, EventEmitter } from '@stencil/core'

import classNames from 'classnames'



@Component({

  tag: 'taro-view-core',

  styleUrl: './style/index.scss'

})

export class View implements ComponentInterface {

  @Prop() hoverClass: string

  @Prop() hoverStartTime = 50

  @Prop() hoverStayTime = 400

  @State() hover = false

  @State() touch = false



  @Event({

    eventName: 'longpress'

  }) onLongPress: EventEmitter



  private timeoutEvent: NodeJS.Timeout

  private startTime = 0



  @Listen('touchstart')

  onTouchStart () {

    if (this.hoverClass) {

      this.touch = true

      setTimeout(() ={

        if (this.touch) {

          this.hover = true

        }

      }, this.hoverStartTime)

    }



    this.timeoutEvent = setTimeout(() ={

      this.onLongPress.emit()

    }, 350)

    this.startTime = Date.now()

  }



  @Listen('touchmove')

  onTouchMove () {

    clearTimeout(this.timeoutEvent)

  }



  @Listen('touchend')

  onTouchEnd () {

    const spanTime = Date.now() - this.startTime

    if (spanTime < 350) {

      clearTimeout(this.timeoutEvent)

    }

    if (this.hoverClass) {

      this.touch = false

      setTimeout(() ={

        if (!this.touch) {

          this.hover = false

        }

      }, this.hoverStayTime)

    }

  }



  render () {

    const cls = classNames({

      [`${this.hoverClass}`]: this.hover

    })

    return (

      <Host class={cls}>

        <slot></slot>

      </Host>

    )

  }

}

至此,組件庫就實現好了。

Taro 3 部分使用的 NPM 包名及其具體作用。

YOJcrH

總結

Taro 3 重構是爲了解決架構問題,還有提供多框架的⽀持。從之前的重編譯時,到現在的重運行時。

同等條件下,編譯時做的工作越多,也就意味着運行時做的工作越少,性能會更好。從長遠來看,計算機硬件的性能越來越冗餘,如果在犧牲一點可以容忍的性能的情況下換來整個框架更大的靈活性和更好的適配性,並且能夠極大的提升開發體驗。

參考

  1. https://github.com/NervJS/taro

  2. https://mp.weixin.qq.com/s?__biz=MzU3NDkzMTI3MA==&mid=2247483770&idx=1&sn=ba2cdea5256e1c4e7bb513aa4c837834

  3. https://www.yuque.com/zaotalk/posts/cz8knq#HUlMM

  4. https://juejin.cn/post/6868099130720256007#heading-0

  5. https://developer.mozilla.org/zh-CN/docs/Web/Web_Components

  6. https://custom-elements-everywhere.com/

  7. https://zhuanlan.zhihu.com/p/83324871

參考資料

[1] Custom Elements Everywhere: https://custom-elements-everywhere.com/

[2] forwardRef: https://reactjs.org/docs/forwarding-refs.html#forwarding-refs-to-dom-components

[3] babel-preset-taro: https://www.npmjs.com/package/babel-preset-taro

[4] @tarojs/taro: https://www.npmjs.com/package/@tarojs/taro

[5] @tarojs/shared: https://www.npmjs.com/package/@tarojs/shared

[6] @tarojs/api: https://www.npmjs.com/package/@tarojs/api

[7] @tarojs/taro-h5: https://www.npmjs.com/package/@tarojs/taro-h5

[8] @tarojs/router: https://www.npmjs.com/package/@tarojs/router

[9] @tarojs/react: https://www.npmjs.com/package/@tarojs/react

[10] @tarojs/cli: https://www.npmjs.com/package/@tarojs/cli

[11] @tarojs/extend: https://www.npmjs.com/package/@tarojs/extend

[12] @tarojs/helper: https://www.npmjs.com/package/@tarojs/helper

[13] @tarojs/service: https://www.npmjs.com/package/@tarojs/service

[14] @tarojs/taro-loader: https://www.npmjs.com/package/@tarojs/taro-loader

[15] @tarojs/runner-utils: https://www.npmjs.com/package/@tarojs/runner-utils

[16] @tarojs/webpack-runner: https://www.npmjs.com/package/@tarojs/webpack-runner

[17] @tarojs/mini-runner: https://www.npmjs.com/package/@tarojs/mini-runner

[18] @tarojs/components: https://www.npmjs.com/package/@tarojs/components

[19] @tarojs/taroize: https://www.npmjs.com/package/@tarojs/taroize

[20] @tarojs/with-weapp: https://www.npmjs.com/package/@tarojs/with-weapp

[21] eslint-config-taro: https://www.npmjs.com/package/eslint-config-taro

[22] eslint-plugin-taro: https://www.npmjs.com/package/eslint-plugin-taro

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/hx2qVNMH8mwJfg3DF6Um4g