headless 组件库实现系列(2)core

328 阅读12分钟

原文链接

headless ui 理念下的组件由两部分组成:

  1. core

所有状态处理逻辑的核心,集成各个分散的功能点,且能按需载入,提供定制接口,最终并入一个统一的实例进行管理。

  1. adapter

接入具体渲染结构。由于我们的 headless 组件库不依赖渲染框架,所以必然需要实现接入具体框架的逻辑,包括 state 接入,rerender 触发。

今天先来看下 core instance 部分的基本组成情况。

以下内容基本基于 TanStack Table

类型友好

在进入核心解析前,我想先提下 core 中实现逻辑组合并正确提示类型的方式。我们都很熟悉使用 class 或 Object 上的一系列方法实现 js 中的组合与继承,但是同时要求接入 ts 并类型安全,提示友好,实际较难做到。

首先 TanStack Table 中使用了一个虚拟属性类型的概念,实现了类型层面的组合,并提示友好,主要实现如下:

antd 使用较为广泛,国内命名都会尽量参考,这里方便理解,可将命名对应到 antd Table 的命名中:

data -> dataSource

Row -> Record

export type Overwrite<T, U extends { [TKey in keyof T]?: any }> = Omit<
  T,
  keyof U
> &
  U;

/** 用于展开联合类型 */
type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;

/** 泛型类型组合,罗列需要提示的重要内容 */
export type TableGenerics = {
  Row?: any;
};

interface TableOptions<TGenerics extends TableGenerics> {
  data: TGenerics["Row"][];
}

interface Table<TGenerics extends TableGenerics> {
  generic: TGenerics; // 用一个类型层的属性存放按需载入的类型信息
  options: TableOptions<TGenerics>;
}

/** 协助覆写类型方法*/
interface Helpers<T> {
  setRowType<RecordType>(): Table<Overwrite<T, { Row: RecordType }>>;
}

function createTable<T>(): Id<Table<T> & Helpers<T>> {
  const table: Table<T> = {
    generic: undefined as any, // any 断言避开 generic 检查
    options: {
      data: [],
    },
  };

  const helpers: Helpers<T> = {
    setRowType() {
      return table as any; // 仅做类型层面的处理,函数体内断言跳过检测
    },
  };

  return Object.assign(table, helpers);
}

const table = createTable();
const table2 = createTable().setRowType<{ a: 1 }>();

可以看到 table 的类型在调用 setRowType 后提示中隐藏了内部细节,重点展示出了 record 的类型部分,外部使用可以更好地获得相关信息了。

起初没搞懂为啥一定要这样写,后面意识到,这是通过 每个函数 按需传递 泛型参数组合 中的一部分,如果不使用单函数控制单个类型,则需要在 Table 泛型处同时传递多个泛型参数,无法做到可选,且存在顺序问题。

通过每次对类型的覆写,最终可以实现按需的类型参数传递。另外由于Table泛型参数只有一个,内部泛型传递也十分方便,内部取用则按key取用:

interface TableOptions<TGenerics extends TableGenerics> {
  data: TGenerics["Record"][];
}

后面在进行功能组合时,对类型处理也是类似的方案,执行后重写 overwrite Table 实例的类型。

features

组件的很多功能是碎片化的,按需的功能块,为了支持某一块功能,需要定义一些额外的 state 属性,一些操作方法。Js 中没有像 Trait 这样的形式来很好地表示功能块,用类(class)进行实现时,类型的定义会繁琐且不易使用(因为属性得先可选,载入后必选),类本身不仅包含逻辑,也包含类型,所以必须在逻辑中处理各种 undefined 的情况。

直接采用对象组合(Object.assign)的方式,来实现我们想要的效果,再借助功能接入函数返回值的类型断言,获得正确的外部提示(上面提到的 generic 重写)。目前这样的方式应该是 ts 中实现组合,并能在外部使用时正确提示类型的最优形式了。

每个组件创建后都会生成一个实例,使用 react 这类渲染框架时,实例的概念被模糊了(组件通过 props 与外部通信,hooks组织逻辑)。而使用 headless 组件时,需要时刻注意到这一点,我们所有的状态,数据,方法都是从构造后的实例中调用,也通过这个实例进行修改,修改后触发 rerender 的行为也需要我们自行控制,这一部分主要在 adapter 中,而实例则提供了一系列 onXxx 事件用于协助处理。

在 TanStack Table 中使用 features 代表上面提到的功能块(Cell, Column 这些也是独立功能块,但未被归类为 feature,他们包含的内容是相同的,只是这部分功能块需要实际渲染出来,多了些定制渲染的内容)。

这些功能块是独立实现的,最终会整合在 table 实例上,他们的状态也会合并到实例的 state 属性中统一管理。

这里看一下简化的 Pagination 类型结构就会清晰一些:

/** Pagination 相关状态 */
export type PaginationState = {
  pageIndex: number;
  pageSize: number;
};
export type PaginationTableState = {
  pagination: PaginationState;
};
export type PaginationInitialTableState = {
  pagination?: Partial<PaginationState>;
};

/** Pagination 相关配置 */
export type PaginationOptions<TGenerics extends TableGenerics> = {
  pageCount?: number;
  onPaginationChange?: OnChangeFn<PaginationState>;
};

export type PaginationDefaultOptions = {
  onPaginationChange: OnChangeFn<PaginationState>;
};

/** Pagination 需要额外提供挂载到实例上的操作方法 */
export type PaginationInstance<TGenerics extends TableGenerics> = {
  setPagination: (updater: Updater<PaginationState>) => void;
  setPageCount: (updater: Updater<number>) => void;
  getPageOptions: () => number[];
  previousPage: () => void;
  nextPage: () => void;
  getPageCount: () => number;
};

export type TableFeature = {
  /** 获取默认配置 */
  getDefaultOptions?: (instance: any) => any;
  /** 获取初始状态 */
  getInitialState?: (initialState?: PaginationTableState) => any;
  /** 挂载方法到实例 */
  createInstance?: (instance: any) => any;
};

引出的概念有:

  1. state:用于渲染的状态数据;
  2. options:类似于props,也可以理解为配置,主要为一些事件回调,和协助状态判断的数据;
  3. instance:最终会挂载到 table 实例上的一系列方法

每个组件在使用时都是通过外部配置来初始化的,初始时计算 initialStates 生成 state。state、options这些内容涉及大量数据转化。

关于数据转化,我们会有这样的问题,为什么需要数据转化?数据转化成了什么?

由于外部数据,往往是比较简单的结构(dataSource 数组),但内部在使用时,可能有需要快速取值的数据结构,有能表达嵌套关系的数据结构,或纯平铺快速获取数量的结构,所以需要有这样的计算函数,来实现结构转化。

而当我们需要增加一个新功能时,往往还需要额外的便于计算的结构,此时我们可能就要重写默认的 getCoreRowModel 方法,这样对默认实现的覆写,也是组件库的需要实现定制内容时修改核心的一种方式。

对于 headless 核心我们只要定义好用于定制的接口类型,转化函数类型,限定好实现的边界,后续的覆写定制就会比较方便。

TanStatck Table 中的外部数据,除一些特定内容如 columns,data,其他基本都是在 options 参数中传递,options 中的内容会被转化为初始状态数据(initialXxx),初始状态数据用于 reset 时恢复,以及一些其他的内部状态数据。

另外在 react 中就有这样的概念,凡是直接影响视图渲染的都为状态,需要设置为 state,需要谨慎操作,而不影响视图渲染的,或仅在初始化时设定的,则为副作用数据/纯数据,保存相对随意一些,可直接用 ref 保存。

我们需要时刻注意这个概念,这样有助于我们将重要的数据脱离出来,所以 TanStack Table 直接在核心实例的类型中定义了 state 属性,专门用于存放影响视图的状态。

可以看到虽说脱离渲染框架,但对于状态的管理,这里的方案与思想几乎与 react 并无二致。在上面的 features 中,就已经出现了 Updater 类型,这与 react 中 setState 的可用参数类型是相同的。

那具体到 feature 载入方式可以简化为如下:

const defaultOptions = instance._features.reduce((obj, feature) => {
    return Object.assign(obj, feature.getDefaultOptions?.(instance))
}

let initialState = coreInitialState as TableState;

instance._features.forEach(feature => {
  initialState = feature.getInitialState?.(initialState) ?? initialState
})

const coreInstance: CoreInstance<TGenerics> = {
  ...
}

Object.assign(instance, coreInstance);
  
instance._features.forEach(feature => {
    return Object.assign(instance, feature.createInstance?.(instance))
})

所以所有的 feature 都有getDefaultOptions,getInitialState 和 createInstance 解是为了提供统一接口,用于组合,拼接出 配置、状态、实例属性 等内容。

另外,这里值得注意 OnChangeFn 的类型及 Updater 的类型是相同的 :

// * same as SetStateAction
export type Updater<T> = T | ((oldValue: T) => T);

// type setState: (updater: Updater<TableState>) => void

// * onChange 与 setState 同构处理
export type OnChangeFn<T> = (updaterOrValue: Updater<T>) => void;

通常我们的 onChange 都是直接获得一个值参数,这里进行了与 setState 相同的逻辑,函数及值都进行处理,这在获得了函数组织逻辑的灵活度的同时,在内部实现了 onChange 回调和 setState 类方法的同构,算是很巧妙的一个小设计。

理解了内部逻辑的基本组成,我们再看一个 table 使用时的简单例子,看看这些内容在外部的表现形式:

import {
  createTable,
  getCoreRowModel,
  useTableInstance,
} from '@tanstack/react-table'

type Person = {
  firstName: string
  lastName: string
  age: number
  visits: number
  status: string
  progress: number
}

/** 创建实例,并指定行数据类型 */
const table = createTable().setRowType<Person>()

const defaultData: Person[] = [
  // ....
]

const defaultColumns = [
  table.createGroup({
    header: 'Name',
    footer: props => props.column.id,
    columns: [
      table.createDataColumn('firstName', {
        cell: info => info.getValue(),
      }),
      table.createDataColumn(row => row.lastName, {
        id: 'lastName',
        cell: info => info.getValue(),
        header: () => <span>Last Name</span>,
      }),
    ],
  }),
]

function App() {
  const [data, setData] = React.useState(() => [...defaultData])
  const [columns] = React.useState<typeof defaultColumns>(() => [
    ...defaultColumns,
  ])

  // * data, columns 变化(浅比较),表格实例相关内容会重置,以达到受控的效果
  const instance = useTableInstance(table, {
    data,
    columns,
    /** 此处关注下这里 */
    getCoreRowModel: getCoreRowModel(),
  })

  return (
    <div className="p-2">
      <table>
        <thead>
          {instance.getHeaderGroups().map(headerGroup => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map(header => (
                <th key={header.id} colSpan={header.colSpan}>
                  {header.isPlaceholder ? null : header.renderHeader()}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {instance.getRowModel().rows.map(row => (
            <tr key={row.id}>
              {row.getVisibleCells().map(cell => (
                <td key={cell.id}>{cell.renderCell()}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

这里注意下 getCoreRowModel 这个方法及它的使用方式,该方法内体现了一些设计细节,包括:

  1. 将外部数据转化为内部数据,支持定制;
  2. 使用缓存进行优化;

功能定制

getCoreRowModel() 执行后返回一个函数,参数为当前实例,返回计算出的 Rows 信息,包括带嵌套层级的 rows 数组,完全平铺的 flatRows 数组,以及用于快速获取的 key 为 rowId 的 rowsById 对象,实现了对 data数组 的解析,供内部使用:

import { createRow } from '../core/row'
import { TableInstance, Row, RowModel, TableGenerics } from '../types'
import { memo } from '../utils'

export function getCoreRowModel<TGenerics extends TableGenerics>(): (
  instance: TableInstance<TGenerics>
) => () => RowModel<TGenerics> {
  return instance =>
    memo(
      () => [instance.options.data],
      (
        data
      ): {
        rows: Row<TGenerics>[]
        flatRows: Row<TGenerics>[]
        rowsById: Record<string, Row<TGenerics>>
      } => {
        const rowModel: RowModel<TGenerics> = {
          rows: [],
          flatRows: [],
          rowsById: {},
        }

        const accessRows = (
          originalRows: TGenerics['Row'][],
          depth = 0,
          parent?: Row<TGenerics>
        ): Row<TGenerics>[] => {
          const rows = [] as Row<TGenerics>[]

          for (let i = 0; i < originalRows.length; i++) {
            // This could be an expensive check at scale, so we should move it somewhere else, but where?

            // Make the row
            const row = createRow(
              instance,
              instance._getRowId(originalRows[i], i, parent),
              originalRows[i],
              i,
              depth
            )

            // Keep track of every row in a flat array
            rowModel.flatRows.push(row)
            // Also keep track of every row by its ID
            rowModel.rowsById[row.id] = row
            // Push instance row into parent
            rows.push(row)

            // Get the original subrows
            if (instance.options.getSubRows) {
              row.originalSubRows = instance.options.getSubRows(
                originalRows[i],
                i
              )

              // Then recursively access them
              if (row.originalSubRows?.length) {
                row.subRows = accessRows(row.originalSubRows, depth + 1, row)
              }
            }
          }

          return rows
        }

        rowModel.rows = accessRows(data)

        return rowModel
      },
      {
        key: process.env.NODE_ENV === 'development' && 'getRowModel',
        debug: () => instance.options.debugAll ?? instance.options.debugTable,
        onChange: () => {
          instance._autoResetPageIndex()
        },
      }
    )
}

作者对该方法的解释是:

This required option is a factory for a function that computes and returns the core row model for the table. It is called once per table instance and should return a new function which will calculate and return the row model for the table.

A default implementation is provided via any table adapter's { getCoreRowModel } export.

简略为内部需要提供一个方法,让 data -> RowModel,在 data 不变的情况下该方法只会被执行一次,getCoreRowModel 为默认提供的计算方式。

上面提到,核心实例中存在大量需要从配置中转化的逻辑,为了提供预置/默认功能,会提供 getCoreRowModel 这样的官方方法,而如果我们此时添加了额外的 feature,需要从 data 得到包含其他数据结构的 RowModel,我们可以传递自行实现的内容,最终达到功能定制的效果。

缓存优化

getCoreRowModel 中使用了一个 memo 函数,起到的效果是 instance.options.data 引用变化,函数重新执行返回新结果,否则返回第一次执行的结果,也就是我们的 data 不变化, 每次调用 instance.getCoreRowModel() ,不会造成额外的消耗,会直接返回值。

这样的计算优化会被大量使用,不依赖 react,但是又采用 immutable 判断引用的方式来组成逻辑,无法获得类似 memo、useMemo 等支持,所以这里其实算是对原 react 提供的相关功能/概念的一种自实现。

原理就是对依赖数组进行浅对比,只是 deps 数组从一个函数调用获得,另外提供了 onChange,也就是依赖变化,返回新结果时的回调注册:

export function memo<TDeps extends readonly any[], TResult>(
  getDeps: () => [...TDeps],
  fn: (...args: NoInfer<[...TDeps]>) => TResult,
  opts: {
    onChange?: (result: TResult) => void
  }
): () => TResult {
  let deps: any[] = []
  let result: TResult | undefined

  return () => {
    const newDeps = getDeps()

    // 依赖浅对比
    const depsChanged =
      newDeps.length !== deps.length ||
      newDeps.some((dep: any, index: number) => deps[index] !== dep)

    if (!depsChanged) {
      return result!
    }

    deps = newDeps

    result = fn(...newDeps)
      
    opts?.onChange?.(result)

    return result!
  }
}

其他

meta

在实例中挂载,起到 context 的作用,只是提供了一种语义标准:

Think of this option as an arbitrary "context" for your table. This is a great way to pass arbitrary data or functions to your table instance without having to pass it to every thing the table touches. A good example is passing a locale object to your table to use for formatting dates, numbers, etc or even a function that can be used to updated editable data like in the editable-data example

受控

另外在 react 组件库中,有一个受控的概念,即外部配置(props)变化,组件内部状态同步变化。组件状态变更主要在 adapter 中实现。

The state option can be used to optionally control part or all of the table state. The state you pass here will merge with and overwrite the internal automatically-managed state to produce the final state for the table. You can also listen to state changes via the onStateChange option.

总结

可以看到,实现 headless 核心部分的实现不是特别复杂,但是类型安全及提示友好会花较多功夫。

设计一个 headless 核心重点是先理清内部属性分属的概念,如哪些数据是配置,哪些是状态,以及如何设计一套接口统一的逻辑组合方式,在合适的位置提供定制功能,又不至于抽象泄漏,最后还有一些具体的特定领域功能实现,如 context,memo 等,提供必要的功能拓展及性能优化。

大量的权衡及设计尤其考验实现人的技术功底和逻辑审美,TanStack Table 的设计就在简洁的同时又提供了强大的功能,值得我们学习。

所以此文表面是 core instance 基本构成介绍,实际是 TanStack Table 源码解析了(😅)。基本以个人总结,结合官方文档提到的重点为主,其中的含义诠释仅供参考,欢迎交流指正。

另外在研究的过程中发现,早期的 headless ui 实际还是会基于框架(如 react)实现,只是着重一个无 ui 的概念,方便外部提供定制样式,现今的 headless ui 更多目的是无 ui,且脱离渲染框架。

tips

如果你也想对 TanStack/Table 进行调试,可以按照以下步骤:

yarn
yarn dev // 时间较长
lerna run build // examples 包生成

// 启动examples中的一项进行调试
// .vscode/launch.json
{
  // https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "name": "start",
      "request": "launch",
      "protocol": "inspector",
      "runtimeExecutable": "${env:NVM_BIN}/npm",
      "port": 9002,
      "args": ["run-script", "start"],
      "cwd": "${workspaceFolder}/examples/react/basic",
      "console": "integratedTerminal",
      "stopOnEntry": false,
      "sourceMaps": true,
      "outFiles": [
        "${workspaceFolder}/packages/table-core/**/*.js",
        "${workspaceFolder}/packages/react-table/**/*.js",
        "!**/node_modules/**"
      ],
      "skipFiles": [
        "${workspaceFolder}/**/node_modules/**/*.js",
        "<node_internals>/**"
      ]
    }
  ]
}

启动的链接点击后进入调试浏览器,但 table-core 内部无法正确映射到源码,

主要由于该组件库在使用时都是只依赖 @tanstack/react-table,而该包内部依赖@tanstack/table-core,

{
  "name": "@tanstack/react-table",
  "dependencies": {
    "@tanstack/table-core": "8.0.6"
  },
}

@tanstack/react-table 打出的文件,是直接依赖的 build/esm 中的文件,这就导致了调试时,也只能跳到 build/esm/inde.js 且不会读取该文件的 source-map,这从 @tanstack/react-table 的 soucemap 中也可以看到:

暂时不清楚有什么好的办法可以解决。