自定义公式
当现有公式不满足你需求的时候,可以通过以下多种方式来注册自定义公式。
如何通过插件配置自定义公式
按照以下步骤来实现一个自定义公式 CUSTOMSUM
,并将公式信息配置到公式插件中,随着公式插件注册到 Univer 中。
你可以新建一个 custom-function.ts
文件来专门放置自定义公式相关模块,或者直接写在 univer
初始化之前。
-
定义公式名称
首先为公式起一个名称,我们要求不能同已有公式名称重复,已有公式主要是从 Office Excel 参考。
我们把多个自定义公式搜集在一个枚举中。
/** * function name */ export enum FUNCTION_NAMES_USER { CUSTOMSUM = "CUSTOMSUM", }
-
定义国际化
定义你所需要的国际化内容,详细的字段说明请参考 如何在 Formula Engine 中添加公式 的部分。同样的,多个公式就用公式名称作为
key
值区分。/** * i18n */ export const functionEnUS = { formulaCustom: { CUSTOMSUM: { description: `You can add individual values, cell references or ranges or a mix of all three.`, abstract: `Adds its arguments`, links: [ { title: 'Instruction', url: 'https://support.microsoft.com/en-us/office/sum-function-043e1c7d-7726-4e80-8f32-07b23e057f89', }, ], functionParameter: { number1: { name: 'number1', detail: 'The first number you want to add. The number can be like 4, a cell reference like B6, or a cell range like B2:B8.', }, number2: { name: 'number2', detail: 'This is the second number you want to add. You can specify up to 255 numbers in this way.', }, }, }, }, }; export const functionZhCN = { formulaCustom: { CUSTOMSUM: { description: '将单个值、单元格引用或是区域相加,或者将三者的组合相加。', abstract: '求参数的和', links: [ { title: '教学', url: 'https://support.microsoft.com/zh-cn/office/sum-%E5%87%BD%E6%95%B0-043e1c7d-7726-4e80-8f32-07b23e057f89', }, ], functionParameter: { number1: { name: '数值1', detail: '要相加的第一个数字。 该数字可以是 4 之类的数字,B6 之类的单元格引用或 B2:B8 之类的单元格范围。', }, number2: { name: '数值2', detail: '这是要相加的第二个数字。 可以按照这种方式最多指定 255 个数字。', }, }, }, }, };
-
注册国际化
在原有的国际化对象中扩展你定义的国际化内容。
import { LocaleType, Tools } from '@univerjs/core'; import DesignEnUS from '@univerjs/design/locale/en-US'; import DocsUIEnUS from '@univerjs/docs-ui/locale/en-US'; import SheetsEnUS from '@univerjs/sheets/locale/en-US'; import SheetsUIEnUS from '@univerjs/sheets-ui/locale/en-US'; import UIEnUS from '@univerjs/ui/locale/en-US'; import { functionEnUS, functionZhCN } from "./custom-function"; export const locales = { [LocaleType.EN_US]: Tools.deepMerge( SheetsEnUS, DocsUIEnUS, SheetsUIEnUS, UIEnUS, DesignEnUS, functionEnUS ), [LocaleType.ZH_CN]: Tools.deepMerge( functionZhCN ), };
-
定义描述
公式的描述中主要是配置国际化字段,用于公式搜索提示、详情面板等。
import type { IFunctionInfo } from '@univerjs/engine-formula'; import { FunctionType } from '@univerjs/engine-formula'; /** * description */ export const FUNCTION_LIST_USER: IFunctionInfo[] = [ { functionName: FUNCTION_NAMES_USER.CUSTOMSUM, aliasFunctionName: 'formulaCustom.CUSTOMSUM.aliasFunctionName', functionType: FunctionType.User, description: 'formulaCustom.CUSTOMSUM.description', abstract: 'formulaCustom.CUSTOMSUM.abstract', functionParameter: [ { name: 'formulaCustom.CUSTOMSUM.functionParameter.number1.name', detail: 'formulaCustom.CUSTOMSUM.functionParameter.number1.detail', example: 'A1:A20', require: 1, repeat: 0, }, { name: 'formulaCustom.CUSTOMSUM.functionParameter.number2.name', detail: 'formulaCustom.CUSTOMSUM.functionParameter.number2.detail', example: 'B2:B10', require: 0, repeat: 1, }, ], }, ];
-
注册描述
注册公式插件时传入你定义的描述对象。
// univer univer.registerPlugin(UniverSheetsFormulaPlugin, { description: FUNCTION_LIST_USER, });
-
定义公式算法
编写具体的公式计算逻辑,并将算法和公式名称映射起来。
import type { ArrayValueObject, BaseValueObject, IFunctionInfo } from '@univerjs/engine-formula'; import { BaseFunction, FunctionType, NumberValueObject } from '@univerjs/engine-formula'; /** * Function algorithm */ export class Customsum extends BaseFunction { override calculate(...variants: BaseValueObject[]) { let accumulatorAll: BaseValueObject = NumberValueObject.create(0); for (let i = 0; i < variants.length; i++) { let variant = variants[i]; if (variant.isError()) { return variant; } if (accumulatorAll.isError()) { return accumulatorAll; } if (variant.isArray()) { variant = (variant as ArrayValueObject).sum(); } accumulatorAll = accumulatorAll.plus(variant as BaseValueObject); } return accumulatorAll; } } // Mapping of algorithms and names export const functionUser = [[Customsum, FUNCTION_NAMES_USER.CUSTOMSUM]];
公式算法还可以用
isAsync
标记为返回异步结果,返回的AsyncObject
中传入一个Promise
,Promise.resolve
可以接受BaseValueObject
或者ArrayValueObject
。/** * Get data asynchronously and assign it to a single formula value */ export class CustomAsyncObject extends BaseFunction { override calculate(value: BaseValueObject) { return new AsyncObject(asyncObjectFunction(value)); } override isAsync(): boolean { return true; } } async function asyncObjectFunction(value: BaseValueObject) { return new Promise((resolve: (value: BaseValueObject) => void) => { setTimeout(() => { resolve(StringValueObject.create(`Async Info: ${value.getValue()}`)); }, 1000); }); }
-
注册公式算法
在
UniverFormulaEnginePlugin
传入你定义的公式算法对象。univer.registerPlugin(UniverFormulaEnginePlugin, { function: functionUser, });
ℹ️如果
UniverFormulaEnginePlugin
在worker
中有实例化,则需要在worker
中的UniverFormulaEnginePlugin
注册公式算法,否则无法获取执行自定义公式。 -
测试
到这里就完成了自定义公式的开发,现在可以测试一下。任一空白单元格输入
=CUSTOMSUM
预期能得到公式提示。这里提供一个自定义公式 Demo,供参考。
如何在第三方插件中添加公式
如果你正在开发一个 Univer 插件,你可以直接在这个插件中新增自定义公式,方便代码在一个插件仓库中管理。
我们内部的 UniverFormulaEnginePlugin
插件提供了一个 function.service
,专门用来注册公式的描述和算法。
先新建一个插件,然后就可以开始添加自定义公式。
-
common
文件内新建custom-function.ts
文件,将公式所需要的基础模块全部写好。import type { ArrayValueObject, BaseValueObject, IFunctionInfo } from '@univerjs/engine-formula'; import { BaseFunction, FunctionType, NumberValueObject } from '@univerjs/engine-formula'; /** * function name */ export enum FUNCTION_NAMES_USER { CUSTOMSUM = 'CUSTOMSUM', } /** * i18n */ export const functionEnUS = { formulaCustom: { CUSTOMSUM: { description: `You can add individual values, cell references or ranges or a mix of all three.`, abstract: `Adds its arguments`, links: [ { title: 'Instruction', url: 'https://support.microsoft.com/en-us/office/sum-function-043e1c7d-7726-4e80-8f32-07b23e057f89', }, ], functionParameter: { number1: { name: 'number1', detail: 'The first number you want to add. The number can be like 4, a cell reference like B6, or a cell range like B2:B8.', }, number2: { name: 'number2', detail: 'This is the second number you want to add. You can specify up to 255 numbers in this way.', }, }, }, }, }; export const functionZhCN = { formulaCustom: { CUSTOMSUM: { description: '将单个值、单元格引用或是区域相加,或者将三者的组合相加。', abstract: '求参数的和', links: [ { title: '教学', url: 'https://support.microsoft.com/zh-cn/office/sum-%E5%87%BD%E6%95%B0-043e1c7d-7726-4e80-8f32-07b23e057f89', }, ], functionParameter: { number1: { name: '数值1', detail: '要相加的第一个数字。 该数字可以是 4 之类的数字,B6 之类的单元格引用或 B2:B8 之类的单元格范围。', }, number2: { name: '数值2', detail: '这是要相加的第二个数字。 可以按照这种方式最多指定 255 个数字。', }, }, }, }, }; /** * description */ export const FUNCTION_LIST_USER: IFunctionInfo[] = [ { functionName: FUNCTION_NAMES_USER.CUSTOMSUM, aliasFunctionName: 'formulaCustom.CUSTOMSUM.aliasFunctionName', functionType: FunctionType.User, description: 'formulaCustom.CUSTOMSUM.description', abstract: 'formulaCustom.CUSTOMSUM.abstract', functionParameter: [ { name: 'formulaCustom.CUSTOMSUM.functionParameter.number1.name', detail: 'formulaCustom.CUSTOMSUM.functionParameter.number1.detail', example: 'A1:A20', require: 1, repeat: 0, }, { name: 'formulaCustom.CUSTOMSUM.functionParameter.number2.name', detail: 'formulaCustom.CUSTOMSUM.functionParameter.number2.detail', example: 'B2:B10', require: 0, repeat: 1, }, ], }, ]; /** * Function algorithm */ export class Customsum extends BaseFunction { override calculate(...variants: BaseValueObject[]) { let accumulatorAll: BaseValueObject = NumberValueObject.create(0); for (let i = 0; i < variants.length; i++) { let variant = variants[i]; if (variant.isError()) { return variant; } if (accumulatorAll.isError()) { return accumulatorAll; } if (variant.isArray()) { variant = (variant as ArrayValueObject).sum(); } accumulatorAll = accumulatorAll.plus(variant as BaseValueObject); } return accumulatorAll; } } export const functionUser = [[Customsum, FUNCTION_NAMES_USER.CUSTOMSUM]];
-
controllers
文件夹下新建custom-description.controller.ts
用于注册公式国际化内容和描述。import { Disposable, LifecycleStages, Inject, LocaleService } from '@univerjs/core'; import { FUNCTION_LIST_USER, functionEnUS, functionZhCN } from '../common/custom-function'; import { IDescriptionService } from '../services/description.service'; export class CustomDescriptionController extends Disposable { constructor( @IDescriptionService private readonly _descriptionService: IDescriptionService, @Inject(LocaleService) private readonly _localeService: LocaleService ) { super(); this._initialize(); } private _initialize(): void { this._registerLocales(); this._registerCustomDescriptions(); } private _registerLocales() { this._localeService.load({ zhCN: functionZhCN, enUS: functionEnUS, }); } private _registerCustomDescriptions() { this._descriptionService.registerDescription(FUNCTION_LIST_USER); } }
-
controllers
文件夹下新建custom-function.controller.ts
用于注册公式算法。import { type Ctor } from '@univerjs/core'; import { Disposable, LifecycleStages } from '@univerjs/core'; import type { BaseFunction, IFunctionNames } from '@univerjs/engine-formula'; import { IFunctionService } from '@univerjs/engine-formula'; import { functionUser } from '../common/custom-function'; export class CustomFunctionController extends Disposable { constructor(@IFunctionService private readonly _functionService: IFunctionService) { super(); this._initialize(); } private _initialize(): void { this._registerCustomFunctions(); } private _registerCustomFunctions() { const functions: BaseFunction[] = [...functionUser].map((registerObject) => { const Func = registerObject[0] as Ctor<BaseFunction>; const name = registerObject[1] as IFunctionNames; return new Func(name); }); this._functionService.registerExecutors(...functions); } }
-
在插件入口文件
plugin.ts
中,将custom-description.controller.ts
和custom-function.controller.ts
注册到 DI 系统中。initialize(): void { // ... 其它逻辑 const dependencies: Dependency[] = [ // ... 其它模块 [CustomFunctionController], [CustomDescriptionController], ]; dependencies.forEach((dependency) => this._injector.add(dependency)); }
启动 Univer,任一空白单元格输入
=CUSTOMSUM
即可测试这个新添加的公式。
编写一个插件用来注册公式
除了通过 UniverFormulaEnginePlugin
配置的形式来注册,还可以将公式算法模块单独包装成一个插件来注册。
首先 plugin.ts
中就不需要注册 CustomFunctionController
了,同级目录新建一个 custom-function-plugin.ts
,专门用于注册 CustomFunctionController
。
import type { Dependency } from '@univerjs/core';
import { Plugin, PluginType, Inject, Injector } from '@univerjs/core';
import { FORMULA_UI_PLUGIN_NAME } from './common/plugin-name';
import { CustomFunctionController } from './controllers/custom-function.controller';
export class UniverSheetsCustomFunctionPlugin extends Plugin {
static override type = PluginType.Sheet;
constructor(@Inject(Injector) override readonly _injector: Injector) {
super(FORMULA_UI_PLUGIN_NAME);
}
initialize(): void {
const dependencies: Dependency[] = [[CustomFunctionController]];
dependencies.forEach((dependency) => this._injector.add(dependency));
}
override onReady(): void {
this.initialize();
}
}
然后在 index.ts
中导出
export { UniverSheetsCustomFunctionPlugin } from './custom-function-plugin';
注册插件
import { UniverSheetsCustomFunctionPlugin } from '@univerjs/sheets-formula';
// ... 初始化其他插件
univer.registerPlugin(UniverSheetsCustomFunctionPlugin);
如何在 Formula Engine 中添加公式
Univer 目标是兼容所有 Excel 和 Google Sheets 的公式,我们希望大家能够帮助完善这部分内容。
如何认领公式
- 从 Office Excel 函数列表 或者 Google Sheets 函数列表,先找一个你感兴趣的公式,查看是否还没有在 Univer 中实现,并且 Github issues 中没有人认领。
- 每个公式创建一个 Feature request issue。
comment 标记下
@自己 : I can solve it
,示例 [Feature] Math Formula LET。 - 等待管理员评论确认后,开始开发。
如何提交代码
- 参考我们的 贡献指南, fork Univer 项目,从 dev 切分支开发。分支名称统一为
feat/formula-[函数名称]
,比如feat/formula-sumif
。 - 开发代码,注意遵守我们的 开发规范
- PR 中标记 close 相关 issue,提交 PR,注意及时 rebase 上游分支,Reviewers 选择
Dushusir
等待代码审查和 QA 测试 - 代码审查和 QA 通过后合入 dev
如何实现公式
正式着手写代码之前,推荐大家先学习 公式的架构,对公式系统有一个全面的了解。
熟悉公式的分类
- Financial
- Date
- Math
- Statistical
- Lookup
- Database
- Text
- Logical
- Information
- Engineering
- Cube
- Compatibility
- Web
- Array
- Univer
- User
- DefinedName
详细 API 参考 FunctionType
要实现一个公式,需要添加公式描述、国际化和公式算法,以 SUMIF
函数的写法为例作为参考
-
添加函数名称
位置在 packages/engine-formula/src/functions/math/function-names.ts。
每个分类都有一个文件夹,包含一个
function-names
文件用于统一管理这个分类的所有函数名。我们先添加上函数名称,在sheets-formula
插件中会用到。注意,Excel 中一个函数可能属于多个分类,比如
FLOOR
在兼容性和数学函数中出现,我们将它归类到数学分类下。其他函数也是这样处理,以确切的分类为依据。大多数 Excel 函数已经写好了函数名。新的函数可以在末尾添加
-
国际化文件
位置在 packages/sheets-formula/src/locale/function-list/math/en-US.ts。
国际化也是一个分类一个文件。简介从 Office 函数分类页参考。
函数描述和参数描述从 Office 函数详情页参考
大部分的函数名称我们已经写好了基础的描述、简介、链接、参数结构,推荐你在此基础上进行修改,如果没有的函数需要自己加在末尾。
要求:
- 函数翻译的参数
key
使用这个函数的每个参数英文名称,比如SUMIF
,除非有错误,一般不用改动 description
参数需要综合下内容进行提取,因为有的 Excel 描述很长,需要简化abstract
和links
基本上不需要做改动aliasFunctionName
是可选参数,大部分公式不需要填写(也可以只设置某个国家的别名),暂时还未找到有公式别名文档来参考。目前找到一个函数翻译插件可能提供类似功能 Excel 函数翻译工具functionParameter
中需要为每个参数设定一个名称,我们推荐根据参数的含义进行变化,比如数值类型的key
为number
(仅有一个数值参数的时候)或者number1
、number2
(有多个数值参数的时候),范围为range
,条件为criteria
,求和范围为sumRange
,采用驼峰式命名法。对于具体的参数内容,name
英文格式就使用带下划线的格式sum_range
,其他语言采用翻译的文本,detail
全部采用翻译。- Office 函数文档中文翻译猜测用的机翻,部分翻译不容易理解,需要自己修改,一部分专用名词如下。
- 单元格参考 => 单元格引用
- 数字类型的参数统一翻译为:数值
abstract
结尾不要加句号(用在用户输入单元格时的搜索列表中,但是部分国家的语言有加句号的习惯,比如日本语,参照 Excel 的简介信息即可),description
和detail
结尾加句号(用在描述中)- 英文句子的首字母大写
- 注意所有的现有的国际化文件都需要填写,目前有中英日俄(Excel 介绍页底部可以切换语言)
- 函数翻译的参数
-
公式描述
SUMIF
属于math
分类,描述信息在 packages/sheets-formula/src/services/function-list/math.ts,这个文件负责整个math
分类所有函数。大部分的函数名称我们已经写好了基础的描述结构,推荐你在此基础上进行修改,如果没有的函数需要自己加在末尾。
以下是
SUMIF
的示例{ functionName: FUNCTION_NAMES_MATH.SUMIF, aliasFunctionName: 'formula.functionList.SUMIF.aliasFunctionName', functionType: FunctionType.Math, description: 'formula.functionList.SUMIF.description', abstract: 'formula.functionList.SUMIF.abstract', functionParameter: [ { name: 'formula.functionList.SUMIF.functionParameter.range.name', detail: 'formula.functionList.SUMIF.functionParameter.range.detail', example: 'A1:A20', require: 1, repeat: 0, }, { name: 'formula.functionList.SUMIF.functionParameter.criteria.name', detail: 'formula.functionList.SUMIF.functionParameter.criteria.detail', example: '">5"', require: 1, repeat: 0, }, { name: 'formula.functionList.SUMIF.functionParameter.sumRange.name', detail: 'formula.functionList.SUMIF.functionParameter.sumRange.detail', example: 'B1:B20', require: 0, repeat: 0, }, ], }
要求:
- 在
FUNCTION_LIST_MATH
数组中增加公式,我们建议保持和国际化文件中的顺序一致,便于管理和查找 functionName
需要引用之前定义的FUNCTION_NAMES_MATH
枚举aliasFunctionName
也是可选的,如果国际化文件中没有别名,这里也不用添加- 国际化字段注意对应好函数名和参数名,比如
functionParameter
的name
写成formula.functionList.SUMIF.functionParameter.range.name
,SUMIF
是函数名,range
是参数名 - 注意修改函数参数的信息,
example
参数示例(比如范围写"A1:A20"
,条件写">5"
),require
是否必需(1 必需,0 可选) ,repeat
是否允许重复(1 允许重复,0 不允许重复),详细说明参考文件内的接口 IFunctionParam
- 在
-
公式算法
位置在 packages/engine-formula/src/functions/math/sumif/index.ts。
在当前公式的分类文件夹下新建公式文件夹,文件夹名称与公式相同,采用短横线命名,一个公式一个文件夹。然后新建
index.ts
文件来写公式算法,公式class
的名称采用帕斯卡命名法(又叫大驼峰),认为公式是一个单词,带_
或者.
的公式认为是两个单词,比如SUMIF
=> 文件夹sumif
, 类Sumif
NETWORKDAYS.INTL
=> 文件夹networkdays-intl
, 类NetworkdaysIntl
ARRAY_CONSTRAIN
=> 文件夹array-constrain
, 类ArrayConstrain
同级新建
__tests__
来写编写单元测试。写完之后,记得在分类目录下的function-map
文件中添加公式算法和函数名映射用于注册这个函数算法。位置在 packages/engine-formula/src/functions/math/function-map.ts。
-
单元测试
位置在 packages/engine-formula/src/functions/math/sumif/__tests__/index.spec.ts
注意:
- 补充
sheetData
,根据公式计算的需要构建好cellData
,确定rowCount
、columnCount
- 手动初始化公式
new Sumif(FUNCTION_NAMES_MATH.SUMIF)
- 每个测试中手动构建好公式入参,最后
calculate
执行即可 - 单个公式的测试一般用于当前单个公式的算法,如果需要测试多个公式的嵌套,可以手动嵌套,或者到
/packages/engine-formula/src/functions/__tests__
目录下执行嵌套的复杂公式
- 补充
-
功能测试
启动 Univer 开发模式,在界面上测试公式,预先构造好数据,
- 在任一空白单元格输入
=sumif
,预期会有搜索提示列表弹出 - 确认选择
SUMIF
或者 输入=sumif(
之后,触发公式详细介绍弹窗,仔细检查介绍页内容是否完整 - 选择数据范围,确认之后触发计算,检查公式计算结果是否正确
- 在任一空白单元格输入
注意事项
-
大部分的公式规则请参考最新版本的 Excel,如果有不合理的地方,再参考 Google Sheets。
-
任何公式的入参和出参都可以是
A1
、A1:B10
,单元格内容也可能是数字、字符串、布尔值、空单元格、错误值、数组等,虽然公式教程中说明了识别固定的数据类型,但是程序上实现是需要都兼容的,调研 Excel 的时候需要把所有情况考虑到,比如=SIN(A1:B10)
,会展开一个正弦计算后的范围。-
例如
XLOOKUP
函数,要求两个入参的行或列至少又一个大小相等,这样才能进行矩阵计算。 -
例如
SUMIF
函数,大家以为是求和,但是它是可以根据第二个参数进行展开的 -
Excel 的公式计算,越来越像 numpy,比如
-
-
在公式算法中,需要检查传入参数的数量。少于必传参数数量,或者大于必传+可选参数数量,都要返回
#N/A
(这个行为在 Excel 中会被拦截,在 Google Sheets 中返回#N/A
,我们参照 Google Sheets)。 -
公式的数值计算,需要使用内置的方法,尽量不要获取值自行计算。因为公式的参数可以是值、数组、引用。可以参考已有的
sum
、minus
函数。 -
精度问题,公式引入了
big.js
,使用内置方法会调用该库,但是相比原生计算会慢接近 100 倍,所以像sin
等js
方法,尽量用原生实现。 -
需要自定义计算,使用
product
函数,适合两个入参的计算,调用map
对值自身进行迭代计算,适合对一个入参本身的值进行改变。 -
公式算法支持两种配置
needsExpandParams
和needsReferenceObject
needsExpandParams
: 函数是否需要扩展参数,主要处理类似LOOKUP
函数需要处理不同大小向量的情况needsReferenceObject
:函数是否需要传入引用对象,设置之后BaseReferenceObject
不会转化为ArrayValueObject
而是直接传入公式算法,比如OFFSET
函数
-
公式计算错误会返回固定类型的错误,比如
#NAME?
、#VALUE!
,需要对齐 Excel,因为有判断报错类型的函数ISERR
、ISNA
等,类型指定不对,结果就可能不一样。 -
类似 OFFSET、INDIRECT、ADDRESS 这种支持引用对象的函数,需要标记
isAddress
,用来判断是否需要从引用对象中获取关联的范围,参考OFFSET
函数的实现。
基础工具
ValueObjectFactory
用来自动识别参数格式创建一个参数实例,范围类型的数据用RangeReferenceObject
来创建参数实例- 数组
toArrayValueObject
可以与值直接运算,得到新的数组