likes
comments
collection
share

taro-solid我做了什么事

作者站长头像
站长
· 阅读数 4

前情提要

近况

小程序端的解决

简简单单把h函数删除咯,清清爽爽,关机~。由于我们之前的插件,书写形式是采用小程序原生的标签:

<view class="h-xl flex flex-col">
  <view class="w-full h-90 flex flex-nowrap items-center border border-gray-4 border-solid rounded-lg my-5 px-2 box-border">
    <text class="mr-2 pr-2 border-r-style-solid border border-gray-2">
      +86
    </text>
    <input
      placeholder="请输入手机号"
      type="number"
    />
  </view>
  <button>
    登录
  </button>
</view>

而这也只能在小程序端使用,就不太符合taro跨端的理念,所以说我们还是得走引入@tarojs/components包的组件进行使用,但是究竟如何让他能够顺利编译,我想到了2个方案。

套娃

在小程序端,我们引用@tarojs/components包的组件其实都是string类型的标签,在solid编译的时候,由于babel已经解析这里是使用component编译,导致报错,那么只要将这份导出多套一层,使其成为组件,不就好了?

export const View = (props) => <view {...props}>{props.children}</view>

export const Text = (props) => <text {...props}>{props.children}</text>

从结论上来说,这是能成功编译的,不过他存在一个问题,在solid经babel转换后的源码,他会多加get children,这无论在编译体积,还是运行时性能,都会存在一定的损耗,特别是组件层级嵌套过深的时候,这个带来的影响就比较明显,而且咱们还是运行在小程序端。所以这个方案也是最低选择项。

taro-solid我做了什么事

babel

又迎来我们常驻嘉宾babel,最近老跟他打交道了属于是,在之前我们的解决策略是,在babel进行小程序组件收集,让他能够顺利编译使用原生的小程序标签。 那么我们是否可以思路反转一下,我还是使用jsx组件的引入写法,但是在babel的transformElement的时候,识别当前的节点是否是@tarojs/components里的,然后转换为小程序的原生标签,让其走createElement的分支,那么编译后的结果,从理论上来说,是跟写原生小程序标签是一致的,性能上也是最优解。但是有个问题,就是如果用户写的组件跟@tarojs/components里的一样,那岂不是编译错乱了? babel转换组件处理后编译结果:

taro-solid我做了什么事

处理流程图:

transformElement
tsx单文件入口
收集组件
当前节点node是否是小程序组件
改变tagName为原生标签
继续往下执行
文件transform完成收集组件缓存清空
使用createElement分支创建元素

解决组件重名

为了解决用户使用的组件命名是跟@tarojs/components里的导出命名一致:

import { Button } from '@tarojs/components'
import View from '@/components/MyView'

我们可以对import的语法进行分析,只要收集'@tarojs/components'里import进来的组件就好,在babel里也是能做到这个的,具体代码如下:

visitor: {
  JSXElement: transformJSX,
  JSXFragment: transformJSX,
  Program: {
    enter: (path, { opts }) => {
      const taroComponentsMap = (path.hub.file.metadata.taroComponentsMap ||= new Set())
      for (const stmt of path.get('body')) {
        if (t.isImportDeclaration(stmt.node)) {
          // 是否是@tarojs/components的包
          if (isTaroComponent(stmt.node.source.value)) {
            stmt.node.specifiers.forEach((specifier) => {
              // 包体导出的变量名
              const importedName = specifier.imported.name
              taroComponentsMap.add(importedName)
            })
          }
        }
      }
      if (merged.validate) path.traverse(JSXValidator)
    },
    exit: postprocess
  }
}

在enter中,可以获取所有import进来的语句,基于此,对于别的import路径组件,即使重名,也不会对其进行收集。

解决组件别名

除了需要解决组件重名外,还存在另一个隐患:

import { Button as MyButton } from '@tarojs/components'

组件别名,使用上述方法收集到的组件是MyButton,在transformElement处理分支的时候,MyButton并不在可用列表中,那这个Button的编译就会被遗漏掉了,所以我们还得改写上述的方法:

visitor: {
  JSXElement: transformJSX,
  JSXFragment: transformJSX,
  Program: {
    enter: (path, { opts }) => {
      const taroComponentsMap = (path.hub.file.metadata.taroComponentsMap ||= new Map())
      for (const stmt of path.get('body')) {
        if (t.isImportDeclaration(stmt.node)) {
          // 是否是@tarojs/components的包
          if (isTaroComponent(stmt.node.source.value)) {
            stmt.node.specifiers.forEach((specifier) => {
              // 包体导出的变量名
              const importedName = specifier.imported.name
              // 当前使用的变量名 防止别名
              // import { Button as MyButton } from '@tarojs/components'
              const localName = specifier.local.name
              taroComponentsMap.set(localName, importedName)
            })
          }
        }
      }
      if (merged.validate) path.traverse(JSXValidator)
    },
    exit: postprocess
  }
}

将Set数据结构改为Map,采用localName(对应的MyButton): importedName(对应的Button),这样能够方便transformElement直接获取到源组件名。

其他问题

在使用babel处理后,由于我们把源文件'@tarojs/components'的组件在正文中并没有在使用了(变为原生标签使用),导致这个包变为了一个无引用import chunk,这在taro的收集小程序组件chunks流程中,会收集不到。所以还得对这个在taro的流程中特殊处理:

!(nameOfCallee && nameOfCallee.includes('_$createElement')) && // solidjs创建元素

大概就是在TaroComponentsExportsPlugin,再进行一次收集,solid转换后的源码中,都会用_$createElement方法进行元素创建,所以就去检索这个方法里的标签作为小程序使用到的组件即可,这在taro中也有比较多类似其他第三方组件库收集不到的处理方法,这里也是大佬给我指明在这里处理,所以就不在继续深入研究。

H5端的解决

由于taro在h5中统一都是使用stencil做的web-component,而stencil的output适配器并没有solid的实现,在solid实现web-component的话,他自己有个solid-element的库,写solid的代码,能够将其define为customElement,这2者最终导出的实现都会走defineCustomElement的原生方法,从结果上来说是一致的,要么就把各个taro的组件用solid-element实现一遍,但是这工作量也很大,并且维护成本也变高,所以这个方案也被驳回了。

stencil-solid-output

实现一个stencil-solid-output的适配器,其实taro的output导出的component,都需要自定义,那么只要去参考别的output适配器即可。

export const generateProxies = (config: Config, components: ComponentCompilerMeta[], pkgData: PackageJSON, outputTarget: OutputTargetSolid, rootDir: string): string => {
  const distTypesDir = path.dirname(pkgData.types)
  const dtsFilePath = path.join(rootDir, distTypesDir, GENERATED_DTS)
  const componentsTypeFile = relativeImport(outputTarget.proxiesFile, dtsFilePath, '.d.ts')
  const pathToCorePackageLoader = getPathToCorePackageLoader(config, outputTarget)

  const imports = `/* eslint-disable */
  /* tslint:disable */
  /* auto-generated solid proxies */
  import { createSolidComponent } from './solid-component-lib';\n`

  const generateTypeImports = () => {
    if (outputTarget.componentCorePackage !== undefined) {
      const dirPath = outputTarget.includeImportCustomElements ? `/${outputTarget.customElementsDir || 'components'}` : ''
      return `import type { ${IMPORT_TYPES} } from '${normalizePath(outputTarget.componentCorePackage)}${dirPath}';\n`
    }

    return `import type { ${IMPORT_TYPES} } from '${normalizePath(componentsTypeFile)}';\n`
  }

  const typeImports = generateTypeImports()

  let sourceImports = ''
  let registerCustomElements = ''

  return [
    imports,
    typeImports,
    sourceImports,
    registerCustomElements,
    components.map(cmpMeta => createComponentDefinition(cmpMeta, outputTarget.includeImportCustomElements)).join('\n'),
  ].join('\n') + '\n'
}

这部分代码就不需要细看了,因为我删了一部分,而且这个适配器的实现并不是重点。

createSolidComponent

createSolidComponent这个方法是用来沟通stencil与solid的连接器,由于stencil导出的组件都已经变为web-component,类似以下:

<View class="index">
  <View style={{color: ${color()}}}>
    <Text>Hello world! </Text>
  </View>
  <Button onClick={() => setCls('bold')}>set class</Button>
  <Button onClick={() => setColor('red')}>set style</Button>
  <View>{Math.random()}</View>
</View>

输出在页面上的ui都会编译为web-component:

 <taro-view-core class="index">
  <taro-view-core style={`color: ${color()}`} >
    <taro-text-core>Hello world!</taro-text-core>
  </taro-view-core>
  <taro-button-core  onClick={() => setCls('bold')}>set class</taro-button-core >
  <taro-button-core onClick={() => setColor('red')}>set style</taro-button-core>
  <taro-view-core>
    {Math.random()}
  </taro-view-core>
</taro-view-core>

将这些taro的component转换为web-component就是createSolidComponent做的事情,那么到底如何做的呢?

export const createSolidComponent = <
  PropType,
  ElementType extends HTMLStencilElement,
  ExpandedPropsTypes = any
>(
  tagName: string,
  manipulatePropsFunction?: (
    originalProps: StencilSolidInternalProps<ElementType>,
    newProps: any
  ) => ExpandedPropsTypes,
  defineCustomElement?: () => void,
): Component<PropType & JSX.DOMAttributes<ElementType> & ComponentSupplementaryTypes> => {

  if (defineCustomElement !== undefined) {
    defineCustomElement()
  }

  function SolidComponentWrapper(props: { children: JSX.Element } & any) {
    const [local, other] = splitProps(props, ['children', 'ref'])
    const eventsMap = new Map()
    const reactiveKeys = []
    const getUnTrackProps = (_props: Record<string, any>) => {
      let propsToPass: typeof props = {}
      for (const key in _props) {
        if (!_props.hasOwnProperty(key)) {
          continue
        }
        if (isPropNameAnEvent(key)) {
          eventsMap.set(key, _props[key])
          continue
        }
        if (isReactiveKey(_props, key)) {
          reactiveKeys.push(key)
          continue
        }
        const propValue = _props[key]
        propsToPass[camelToDashCase(key)] = propValue
      }
      if (manipulatePropsFunction !== undefined) {
        propsToPass = manipulatePropsFunction(_props, propsToPass)
      }
      return propsToPass
    }

    const unTrackProps = getUnTrackProps(other)
    const [reactiveProps] = splitProps(other, reactiveKeys)

    const _mergeProps = mergeProps(unTrackProps, { ref: (element: HTMLElement) => {
      if (local.ref && isFunction(local.ref)) local.ref(element)
      syncEvents(element, eventsMap)
      setReactiveProps(element, reactiveProps)
    } })

    return memo(() => h(tagName, _mergeProps, local.children), true)
  }

  return SolidComponentWrapper as any
}

这里主要解决的问题如下:

  • 使用solid中的h函数创建web-components,其实就是自定义element,因为注册web-component都已经在内部注册完成
  • props的响应式属性需关联并能依赖更新ui

在利用h函数的过程中,遇到许许多多的问题,各种形态我都尝试过,还自己对children进行循环遍历,并递归调用h函数。到最后,自己看了h函数的源码后,发现他内部已经会对children进行递归处理了,我是多此一举的行为,当把h函数的正确形态书写完毕后,后面的props更新,耗费的时间就少很多,就跟之前写solid的编译器setProps很类似。

总结

这一次的taro-solid的响应式处理,虽然只是一个bug,但是这涉及到的改动也很多,很大程度上都已经不算是一个bug的处理,需要深入到了编译层面,对babel工具的使用又加深了其理解,并且在处理大型框架上,需要考虑的东西也会很多,尽量做到满足用户需求的同时,也要考虑到后期的维护拓展问题。希望taro4版本能够顺利发布,也祝taro能变得更好🎉🎉🎉