React 代码如何跑在小程序上?

标题中我们提出一个问题:react 代码如何跑在小程序上?目前看来大致两种思路:

1. 把 react 代码编译成小程序代码,这样我们可以开发用 react,然后跑起来还是小程序原生代码,结果很完美,但是把 react 代码编译成各个端的小程序代码是一个力气活,而且如果想用 vue 来开发的话,那么还需要做一遍 vue 代码的编译,这是 taro 1/2 的思路。

2. 我们可以换个问题思考,react 代码是如何跑在浏览器里的?

  • 站在浏览器的角度来思考:无论开发用的是什么框架,React 也好,Vue 也罢,最终代码经过运行之后都是调用了浏览器的那几个 BOM/DOM 的 API ,如:createElement、appendChild、removeChild 等。
  • Taro 3 主要通过在小程序端模拟实现 DOM、BOM API 来让前端框架直接运行在小程序环境中。

下面我们具体看看各自的实现。

Taro 1/2

Taro 1/2 的架构主要分为:编译时 和 运行时。

其中编译时主要是将 Taro 代码通过 Babel 转换成 小程序的代码,如:JS、WXML、WXSS、JSON

运行时主要是进行一些:生命周期、事件、data 等部分的处理和对接。

Taro 编译时

Taro 的编译,使用 babel-parser 将 Taro 代码解析成抽象语法树,然后通过 babel-types 对抽象语法树进行一系列修改、转换操作,最后再通过 babel-generate 生成对应的目标代码。

整个编译时最复杂的部分在于 JSX 编译。

我们都知道 JSX 是一个 JavaScript 的语法扩展,它的写法千变万化,十分灵活。这里我们是采用 穷举 的方式对 JSX 可能的写法进行了一一适配,这一部分工作量很大,实际上 Taro 有大量的 Commit 都是为了更完善的支持 JSX 的各种写法。

Taro 运行时

接下来,我们可以对比一下编译后的代码,可以发现,编译后的代码中,React 的核心 render 方法 没有了。同时代码里增加了 BaseComponent 和 createComponent ,它们是 Taro 运行时的核心。

// 编译前import Taro, { Component } from '@tarojs/taro'import { View, Text } from '@tarojs/components'import './index.scss'export default class Index extends Component { config = { navigationBarTitleText: '首页' } componentDidMount () { } render () { return ( <View className=‘index' onClick={this.onClick}> <Text>Hello world!</Text> </View> ) }}// 编译后import {BaseComponent, createComponent} from '@tarojs/taro-weapp'class Index extends BaseComponent {// ... _createDate(){ //process state and props }}export default createComponent(Index)

BaseComponent 主要是对 React 的一些核心方法:setState、forceUpdate 等进行了替换和重写,结合前面编译后 render 方法被替换,大家不难猜出:Taro 当前架构只是在开发时遵循了 React 的语法,在代码编译之后实际运行时,和 React 并没有关系。

而 createComponent 主要作用是调用 Component() 构建页面;对接事件、生命周期等;进行 Diff Data 并调用 setData 方法更新数据。

这样的实现过程有三⼤缺点:

  • JSX ⽀持程度不完美。Taro 对 JSX 的⽀持是通过编译时的适配去实现的,但 JSX ⼜⾮常之灵活,因此还不能做到 100% ⽀持所有的 JSX 语法。JSX 是一个 JavaScript 的语法扩展,它的写法千变万化,十分灵活。之前Taro团队是采用穷举的方式对 JSX 可能的写法进行了一一适配,这一部分工作量很大。
  • 不⽀持 source-map。Taro 对源代码进⾏了⼀系列的转换操作之后,就不⽀持 source-map 了,⽤户 调试、使⽤这个项⽬就会不⽅便。
  • 维护和迭代⼗分困难。Taro 编译时代码⾮常的复杂且离散,维护迭代都⾮常的困难。

Taro 3

Taro 3 则可以大致理解为解释型架构(相对于 Taro 1/2 而言),主要通过在小程序端模拟实现 DOM、BOM API 来让前端框架直接运行在小程序环境中,从而达到小程序和 H5 统一的目的。

而对于生命周期、组件库、API、路由等差异,依然可以通过定义统一标准,各端负责各自实现的方式来进行抹平。

而正因为 Taro 3 的原理,在 Taro 3 中同时支持 React、Vue 等框架,甚至还支持了 jQuery,还能支持让开发者自定义地去拓展其他框架的支持,比如 Angular,Taro 3 整体架构如下:

React 代码如何跑在小程序上?

模拟实现 DOM、BOM API

Taro 3 创建了 taro-runtime 的包,然后在这个包中实现了 一套 高效、精简版的 DOM/BOM API(下面的 UML 图只是反映了几个主要的类的结构和关系):

React 代码如何跑在小程序上?

  • TaroEventTarget类,实现addEventListener和removeEventListener。
  • TaroNode类继承TaroEventTarget类,主要实现insertBefore、appendChild等操作 Dom 节点的方法。下面在页面渲染我们会具体看这几个方法的实现。
  • TaroElement类继承TaroNode类,主要是节点属性相关的方法和dispatchEvent方法,dispatchEvent方法在下面讲事件触发的时候也会涉及到。
  • TarorootElement类继承TaroElement类,其中最主要是enqueueUpdate和performUpdate,把虚拟 DOM setData 成小程序 data 的操作就是这两个函数。

然后,我们通过 Webpack 的 ProvidePlugin 插件,注入到小程序的逻辑层。

Webpack ProvidePlugin 是一个 webpack 自带的插件,用于在每个模块中自动加载模块,而无需使用 import/require 调用。该插件可以将全局变量注入到每个模块中,避免在每个模块中重复引用相同的依赖。

// trao-mini-runner/src/webpack/build.conf.tsplugin.providerPlugin = getProviderPlugin({ window: ['@tarojs/runtime', 'window'], document: ['@tarojs/runtime', 'document'], navigator: ['@tarojs/runtime', 'navigator'], requestAnimationFrame: ['@tarojs/runtime', 'requestAnimationFrame'], cancelAnimationFrame: ['@tarojs/runtime', 'cancelAnimationFrame'], Element: ['@tarojs/runtime', 'TaroElement'], SVGElement: ['@tarojs/runtime', 'SVGElement'], MutationObserver: ['@tarojs/runtime', 'MutationObserver'], history: ['@tarojs/runtime', 'history'], location: ['@tarojs/runtime', 'location'], URLSearchParams: ['@tarojs/runtime', 'URLSearchParams'], URL: ['@tarojs/runtime', 'URL'],})// trao-mini-runner/src/webpack/chain.tsexport const getProviderPlugin = args => { return partial(getPlugin, webpack.ProvidePlugin)([args])}

这样,在小程序的运行时,就有了 一套高效、精简版的 DOM/BOM API。

taro-react:小程序版的 react-dom

在 DOM/BOM 注入之后,理论上来说,react 就可以直接运行了。

但是因为 React-DOM 包含大量浏览器兼容类的代码,导致包太大。Taro 自己实现了 react 的自定义渲染器,代码在taro-react包里。

在 React 16 ,React 的架构如下:

React 代码如何跑在小程序上?

最上层是 React 的核心部分 react-core ,中间是 react-reconciler,其的职责是维护 VirtualDOM 树,内部实现了 Diff/Fiber 算法,决定什么时候更新、以及要更新什么。

而 Renderer 负责具体平台的渲染工作,它会提供宿主组件、处理事件等等。例如 React-DOM 就是一个渲染器,负责 DOM 节点的渲染和 DOM 事件处理。

Taro实现了taro-react 包,用来连接 react-reconciler 和 taro-runtime 的 BOM/DOM API。是基于 react-reconciler 的小程序专用 React 渲染器,连接 @tarojs/runtime的DOM 实例,相当于小程序版的react-dom,暴露的 API 也和react-dom 保持一致。

这里涉及到一个问题:如何自定义 React 渲染器?

第一步: 实现宿主配置( 实现react-reconciler的hostConfig配置)

这是react-reconciler要求宿主提供的一些适配器方法和配置项。这些配置项定义了如何创建节点实例、构建节点树、提交和更新等操作。即在 hostConfig 的方法中调用对应的 Taro BOM/DOM 的 API。

1. 创建形操作

createInstance(type,newProps,rootContainerInstance,_currentHostContext,workInProgress)。

react-reconciler 使用该方法可以创建对应目标平台的UI Element实例。比如 document.createElement 根据不同类型来创建 div、img、h2等DOM节点,并使用 newProps参数给创建的节点赋予属性。而在 Taro 中:

import { document } from '@tarojs/runtime'// 在 ReactDOM 中会调用 document.createElement 来生成 dom,// 而在小程序环境中 Taro 中模拟了 document,// 直接返回 `document.createElement(type)` 即可createInstance (type, props: Props, _rootContainerInstance: any, _hostContext: any, internalInstanceHandle: Fiber) { const element = document.createElement(type) precacheFiberNode(internalInstanceHandle, element) updateFiberProps(element, props) return element},

createTextInstance

如果目标平台允许创建纯文本节点。那么这个方法就是用来创建目标平台的文本节点。

import { document } from '@tarojs/runtime'// Taro: 模拟的 document 支持创建 text 节点, 返回 `document.createTextNode(text)` 即可.createTextInstance (text: string, _rootContainerInstance: any, _hostContext: any, internalInstanceHandle: Fiber) { const textNode = document.createTextNode(text) precacheFiberNode(internalInstanceHandle, textNode) return textNode},

2. UI树操作

appendInitialChild(parent, child)

初始化UI树创建。

// Taro: 直接 parentInstance.appendChild(child) 即可appendInitialChild (parent, child) { parent.appendChild(child)},

appendChild(parent, child)

此方法映射为 domElement.appendChild 。

appendChild (parent, child) { parent.appendChild(child)},

3. 更新prop操作

finalizeInitialChildren

finalizeInitialChildren 在组件挂载到页面中前调用,更新时不会调用。

这个方法我们下面事件注册时还会提到。

finalizeInitialChildren (dom, type: string, props: any) { updateProps(dom, {}, props) // 提前执行更新属性操作,Taro 在 Page 初始化后会立即从 dom 读取必要信息 // ....},

prepareUpdate(domElement, oldProps, newProps)

这里是比较oldProps,newProps的不同,用来判断是否要更新节点。

prepareUpdate (instance, _, oldProps, newProps) { return getUpdatePayload(instance, oldProps, newProps)},// ./props.tsexport Function getUpdatePayload (dom: TaroElement, oldProps: Props, newProps: Props){ let i: string let updatePayload: any[] | null = null for (i in oldProps) { if (!(i in newProps)) { (updatePayload = updatePayload || []).push(i, null) } } const isFormElement = dom instanceof FormElement for (i in newProps) { if (oldProps[i] !== newProps[i] || (isFormElement && i === 'value')) { (updatePayload = updatePayload || []).push(i, newProps[i]) } } return updatePayload}

commitUpdate(domElement, updatePayload, type, oldProps, newProps)

此函数用于更新domElement属性,下文要讲的事件注册就是在这个方法里。

// Taro: 根据 updatePayload,将属性更新到 instance 中,// 此时 updatePayload 是一个类似 `[prop1, value1, prop2, value2, ...]` 的数组commitUpdate (dom, updatePayload, _, oldProps, newProps) { updatePropsByPayload(dom, oldProps, updatePayload) updateFiberProps(dom, newProps)},export function updatePropsByPayload (dom: TaroElement, oldProps: Props, updatePayload: any[]){ for(let i = 0; i < updatePayload.length; i = 2){ // key, value 成对出现 const key = updatePayload[i]; const newProp = updatePayload[i 1]; const oldProp = oldProps[key] setProperty(dom, key, newProp, oldProp) }}function setProperty (dom: TaroElement, name: string, value: unknown, oldValue?: unknown) { name = name === 'className' ? 'class' : name if ( name === 'key' || name === 'children' || name === 'ref') { // skip } else if (name === 'style') { const style = dom.style if (isString(value)) { style.cssText = value } else { if (isString(oldValue)) { style.cssText = '' oldValue = null } if (isObject<StyleValue>(oldValue)) { for (const i in oldValue) { if (!(value && i in (value as StyleValue))) { setStyle(style, i, '') } } } if (isObject<StyleValue>(value)) { for (const i in value) { if (!oldValue || value[i] !== (oldValue as StyleValue)[i]) { setStyle(style, i, value[i]) } } } } } else if (isEventName(name)) { setEvent(dom, name, value, oldValue) } else if (name === 'dangerouslySetInnerHTML') { const newHtml = (value as DangerouslySetInnerHTML)?.__html ?? '' const oldHtml = (oldValue as DangerouslySetInnerHTML)?.__html ?? '' if (newHtml || oldHtml) { if (oldHtml !== newHtml) { dom.innerHTML = newHtml } } } else if (!isFunction(value)) { if (value == null) { dom.removeAttribute(name) } else { dom.setAttribute(name, value as string) } }}

上面是hostConfig里必要的回调函数的实现,源码里还有很多回调函数的实现,详见trao-react源码。

第二步:实现渲染函数,类似于ReactDOM.render() 方法。可以看成是创建 Taro DOM Tree 容器的方法。

源码实现详见trao-react/src/render.ts。

export function render (element: ReactNode, domContainer: TaroElement, cb: Callback) { const root = new Root(TaroReconciler, domContainer) return root.render(element, cb)}export function createRoot (domContainer: TaroElement, options: CreateRootOptions = {}) { // options should be an object const root = new Root(TaroReconciler, domContainer, options) // ...... return root}

class Root { public constructor (renderer: Renderer, domContainer: TaroElement, options?: CreateRootOptions) { this.renderer = renderer this.initInternalRoot(renderer, domContainer, options) } private initInternalRoot (renderer: Renderer, domContainer: TaroElement, options?: CreateRootOptions) { // ..... this.internalRoot = renderer.createContainer( containerInfo, tag, null, // hydrationCallbacks isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, onRecoverableError, transitionCallbacks ) } public render (children: ReactNode, cb: Callback) { const { renderer, internalRoot } = this renderer.updateContainer(children, internalRoot, null, cb) return renderer.getPublicRootInstance(internalRoot) }}

而 Root 类最后调用TaroReconciler的createContainr“updateContainer和 getPublicRootInstance 方法,实际上就是react-reconciler包里面对应的方法。

渲染函数是在什么时候被调用的呢?

在编译时,会引入插件taro-plugin-react, 插件内会调用 modifyMiniWebpackChain=> setAlias。

// taro-plugin-react/src/webpack.mini.tsfunction setAlias (ctx: IPluginContext, framework: Frameworks, chain) { if (framework === 'react') { alias.set('react-dom$', '@tarojs/react') }}

这样ReactDOM.createRoot和ReactDOM.render实际上调用的就是trao-react的createRoot和render方法。

经过上面的步骤,React 代码实际上就可以在小程序的运行时正常运行了,并且会生成 Taro DOM Tree。那么偌大的 Taro DOM Tree 怎样更新到页面呢?

从虚拟 Dom 到小程序页面渲染

因为⼩程序并没有提供动态创建节点的能⼒,需要考虑如何使⽤相对静态的 wxml 来渲染相对动态的 Taro DOM 树。Taro使⽤了模板拼接的⽅式,根据运⾏时提供的 DOM 树数据结构,各 templates 递归地 相互引⽤,最终可以渲染出对应的动态 DOM 树。

模版化处理

首先,将小程序的所有组件挨个进行模版化处理,从而得到小程序组件对应的模版。如下图就是小程序的 view 组件模版经过模版化处理后的样子。⾸先需要在 template ⾥⾯写⼀个 view,把它所有的属性全部列出来(把所有的属性都列出来是因为⼩程序⾥⾯不能去动态地添加属性)。

模板化处理的核心代码在 packages/shared/src/template.ts 文件中。会在编译工程中生成 base.wxml文件,这是我们打包产物之一。

// base.wxml<wxs module="xs" src="./utils.wxs" /><template name="taro_tmpl"> <block wx:for="{{root.cn}}" wx:key="sid"> // tmpl_' 0 '_' 2 <template is="{{xs.a(0, item.nn, '')}}" data="{{i:item,c:1,l:''}}" /> </block></template>....<template name="tmpl_0_2"> <view style="{{i.st}}" class="{{i.cl}}" id="{{i.uid||i.sid}}" data-sid="{{i.sid}}"> <block wx:for="{{i.cn}}" wx:key="sid"> <template is="{{xs.a(c, item.nn, l)}}" data="{{i:item,c:c 1,l:xs.f(l,item.nn)}}" /> </block> </view></template>

打包产生的页面代码是这样的:

// pages/index/index.wxml<import src="../../base.wxml"/><template is="taro_tmpl" data="{{root:root}}" />

接下来是遍历渲染所有⼦节点,基于组件的 template,动态 “递归” 渲染整棵树。

具体流程为先去遍历 Taro DOM Tree 根节点的子元素,再根据每个子元素的类型选择对应的模板来渲染子元素,然后在每个模板中我们又会去遍历当前元素的子元素,以此把整个节点树递归遍历出来。

hydrate Data

而动态递归时需要获取到我们的 data,也就是 root。

首先,在 createPageConfig 中会对 config.data 进行初始化,赋值 {root:{cn:[]}}。

export function createPageConfig (component: any, pageName?: string, data?: Record<string, unknown>, pageConfig?: PageConfig) { // ....... if (!isUndefined(data)) { config.data = data } // .......}

React在commit阶段会调用HostConfig里的appendInitialChild方法完成页面挂载,在Taro中则继续调用:appendInitialChild —> appendChild —> insertBefore —> enqueueUpdate。

// taro-react/src/reconciler.tsappendInitialChild (parent, child) { parent.appendChild(child)},appendChild (parent, child) { parent.appendChild(child)},// taro-runtime/src/dom/node.tspublic appendChild (newChild: TaroNode) { return this.insertBefore(newChild)}public insertBefore<T extends TaroNode> (newChild: T, refChild?: TaroNode | null, isReplace?: boolean): T { // 忽略了大部分代码 this.enqueueUpdate({ path: newChild._path, value: this.hydrate(newChild) }) return newChild}

这里看到最终调用enqueueUpdate方法,传入一个对象,值为 path 和 value,而 value 值是hydrate方法的结果。

hydrate方法我们可以翻译成“注水”,函数 hydrate 用于将虚拟 DOM(TaroElement 或 TaroText)转换为小程序组件渲染所需的数据格式(MiniData)。

回想一下小程序员生的 data 里都是我们页面需要的 state,而 taro 的hydrate方法返回的 miniData 是把 state 外面在包裹上我们页面的 node 结构值。举例来看,我们一个 helloword 代码所hydrate的 miniData 如下(可以在小程序IDE中的 ”AppData“ 标签栏中查看到完整的data数据结构):

{ "root": { "cn": [ { "cl": "index", "cn": [ { "cn": [ { "nn": "8", "v": "Hello world!" } ], "nn": "4", "sid": "_AH" }, { "cn": [ { "nn": "8", "v": "HHHHHH" } ], "nn": "2", "sid": "_AJ" }, { "cl": "blue", "cn": [ { "nn": "8", "v": "Page bar: " }, { "cl": "red", "cn": [ { "nn": "8", "v": "red" } ], "nn": "4", "sid": "_AM" } ], "nn": "4", "sid": "_AN" } ], "nn": "2", "sid": "_AO" } ], "uid": "pages/index/index?$taroTimestamp=1691064929701" }, "__webviewId__": 1}

这里的字段含义解释一下 :(我想这里缩写是可能尽可能让每一次setData的内容更小。)

Container = 'container',Childnodes = 'cn',Text = 'v',NodeType = 'nt',NodeName = 'nn',// AttrtibutesStyle = 'st',Class = 'cl',Src = 'src

我们获取到以上的 data 数据,去执行enqueueUpdate函数,enqueueUpdate函数内部执行performUpdate函数,performUpdate函数最终执行 ctx.setData,ctx 是小程序的实例,也就是执行我们熟悉的 setData 方法把上面hydrate的 miniData赋值给 root,这样就渲染了小程序的页面数据。

// taro-runtime/src/dom/root.tspublic enqueueUpdate (payload: UpdatePayload): void { this.updatePayloads.push(payload) if (!this.pendingUpdate && this.ctx) { this.performUpdate() }}public performUpdate (initRender = false, prerender?: Func) { // ..... while (this.updatePayloads.length > 0) { const { path, value } = this.updatePayloads.shift()! if (path.endsWith(Shortcuts.Childnodes)) { resetPaths.add(path) } data[path] = value } // ....... if (initRender) { // 初次渲染,使用页面级别的 setData normalUpdate = data } // ........ ctx.setData(normalUpdate, cb)}

整体流程可以概括为:当在React中调用 this.setState 时,React内部会执行reconciler,进而触发 enqueueUpdate 方法,如下图:

React 代码如何跑在小程序上?

事件处理

事件注册

在HostConfig接口中,有一个方法 finalizeInitialChildren,在这个方法里会调用updateProps。这是挂载页面阶段时间的注册时机。updateProps 会调用 updatePropsByPayload 方法。

finalizeInitialChildren (dom, type: string, props: any) { updateProps(dom, {}, props) //....},

在HostConfig接口中,有一个方法 commitUpdate,用于在react的commit阶段更新属性:

commitUpdate (dom, updatePayload, _, oldProps, newProps) { updatePropsByPayload(dom, oldProps, updatePayload) updateFiberProps(dom, newProps)},

进一步的调用方法:updatePropsByPayload => setProperty => setEvent。

// taro-react/src/props.tsfunction setEvent (dom: TaroElement, name: string, value: unknown, oldValue?: unknown) { const isCapture = name.endsWith('Capture') let eventName = name.toLowerCase().slice(2) if (isCapture) { eventName = eventName.slice(0, -7) } const compName = capitalize(toCamelCase(dom.tagName.toLowerCase())) if (eventName === 'click' && compName in internalComponents) { eventName = 'tap' } // 通过addEventListener将事件注册到dom中 if (isFunction(value)) { if (oldValue) { dom.removeEventListener(eventName, oldValue as any, false) dom.addEventListener(eventName, value, { isCapture, sideEffect: false }) } else { dom.addEventListener(eventName, value, isCapture) } } else { dom.removeEventListener(eventName, oldValue as any) }}

进一步的看看dom.addEventListener做了什么?addEventListener是类TaroEventTarget的方法:

export class TaroEventTarget { public __handlers: Record<string, EventHandler[]> = {} public addEventListener (type: string, handler: EventHandler, options?: boolean | AddEventListenerOptions) { type = type.toLowerCase() // 省略很多代码 const handlers = this.__handlers[type] if (isArray(handlers)) { handlers.push(handler) } else { this.__handlers[type] = [handler] } }}

可以看到事件会注册到dom对象上,最终会放入到 dom 内部变量 __handlers 中保存。

事件触发

// base.wxml<template name="tmpl_0_7"> <view hover-class="{{xs.b(i.p1,'none')}}" hover-stop-propagation="{{xs.b(i.p4,!1)}}" hover-start-time="{{xs.b(i.p2,50)}}" hover-stay-time="{{xs.b(i.p3,400)}}" bindtouchstart="eh" bindtouchmove="eh" bindtouchend="eh" bindtouchcancel="eh" bindlongpress="eh" animation="{{i.p0}}" bindanimationstart="eh" bindanimationiteration="eh" bindanimationend="eh" bindtransitionend="eh" style="{{i.st}}" class="{{i.cl}}" bindtap="eh" id="{{i.uid||i.sid}}" data-sid="{{i.sid}}" > <block wx:for="{{i.cn}}" wx:key="sid"> <template is="{{xs.a(c, item.nn, l)}}" data="{{i:item,c:c 1,l:xs.f(l,item.nn)}}" /> </block> </view></template>

上面是base.wxml其中的一个模板,可以看到,所有组件中的事件都会由 eh 代理。在createPageConfig时,会将 config.eh 赋值为 eventHandler。

// taro-runtime/src/dsl/common.tsfunction createPageConfig(){ const config = {...} // config会作为小程序 Page() 的入参 config.eh = eventHandler config.data = {root:{cn:[]}} return config}

eventHandler 最终会触发 dom.dispatchEvent(e)。

// taro-runtime/src/dom/element.tsclass TaroElement extends TaroNode { dispatchEvent(event){ const listeners = this.__handlers[event.type] // 取出回调函数数组 for (let i = listeners.length; i--;) { result = listener.call(this, event) // event是TaroEvent实例 } }}

至此,react 代码终于是可以完美运行在小程序环境中。

还要提到一点的是,Taro3 在 h5 端的实现也很有意思,Taro在 H5 端实现一套基于小程序规范的组件库和 API 库,在这里就不展开说了。

总结

Taro 3从之前的重编译时,到现在的重运行时,解决了架构问题,可以用 react、vue 甚至 jQuery 来写小程序,但也带来了一些性能问题。

为了解决性能问题,Taro 3 也提供了预渲染和虚拟列表等功能和组件。

但从长远来看,计算机硬件的性能越来越冗余,如果在牺牲一点可以容忍的性能的情况下换来整个框架更大的灵活性和更好的适配性,并且能够极大的提升开发体验,也是值得的。

作者:孟祥辉

来源:微信公众号:哈啰技术

出处:https://mp.weixin.qq.com/s/134VAXPJczElvdYzNFcHhA

相关新闻

联系我们
联系我们
公众号
公众号
在线咨询
分享本页
返回顶部