如何在TypeScript中实现类似Tiptap的方法链式调用?以计算器场景为例
Great question! Let's break down how to implement this flexible chaining pattern in TypeScript—mirroring Tiptap's editor.chain().focus().toggleItalic().run() API. We'll start with a basic calculator implementation, then expand it to support extensible commands just like Tiptap's extension system.
Core Pattern Recap
First, let's align on what makes Tiptap's chain API work:
chain()creates an isolated context that collects pending commands- Each command method (like
toggleItalic()) adds an operation to a queue and returns the same context for continuous chaining run()executes all queued commands in order and returns the final result
For our calculator, we want the exact same behavior: calculator.chain().setValue(10).add(3).sub(12).run() should return 1 when we call run().
Step 1: Basic Calculator Implementation
Let's build a minimal working version first. We'll use an isolated state and command queue for each chain call to avoid cross-contamination.
Define Types
First, set up TypeScript types for our state and commands:
// Holds the calculator's current value (isolated per chain) type CalculatorState = { value: number; }; // A command is a function that modifies the calculator state type CalculatorCommand = (state: CalculatorState) => void;
Build the Calculator
Implement the calculator object with the chain API:
const calculator = { chain() { // Initialize fresh state and command queue for this chain const state: CalculatorState = { value: 0 }; const commands: CalculatorCommand[] = []; // The chainable object that exposes our commands const chainable = { setValue(num: number) { commands.push((state) => { state.value = num; }); return chainable; // Return self to enable chaining }, add(num: number) { commands.push((state) => { state.value += num; }); return chainable; }, sub(num: number) { commands.push((state) => { state.value -= num; }); return chainable; }, run() { // Execute all queued commands in sequence commands.forEach(cmd => cmd(state)); return state.value; } }; return chainable; } }; // Test it out! console.log(calculator.chain().setValue(10).add(3).sub(12).run()); // Output: 1
Each chain() call creates its own state and queue, so multiple chains can run independently without interfering with each other.
Step 2: Extensible Version (Like Tiptap's Extensions)
Tiptap's real power comes from custom extensions that add new commands. Let's replicate this by making our calculator extensible with type-safe commands.
Define Extensible Types
Use TypeScript interfaces and generics to support type-safe extensions:
// Base interface for calculator commands (empty, designed for extension) interface CalculatorCommands {} // Map command functions to chainable methods (preserves type hints) type ChainableCommands<T extends CalculatorCommands> = { [K in keyof T]: T[K] extends (...args: infer Args) => void ? (...args: Args) => Chainable<T> : never; }; // Full chainable type: includes all commands + the run() method type Chainable<T extends CalculatorCommands> = ChainableCommands<T> & { run: () => number; };
Create a Factory for Extensible Calculators
Build a factory function that accepts extensions to add new commands dynamically:
function createCalculator<T extends CalculatorCommands>( extensions?: Record<keyof T, (state: CalculatorState) => (...args: any[]) => void> ) { return { chain(): Chainable<T> { const state: CalculatorState = { value: 0 }; const commands: CalculatorCommand[] = []; // Base commands (setValue, add, sub) const baseCommands = { setValue: (state: CalculatorState) => (num: number) => { state.value = num; }, add: (state: CalculatorState) => (num: number) => { state.value += num; }, sub: (state: CalculatorState) => (num: number) => { state.value -= num; } }; // Merge base commands with user-provided extensions const allCommands = { ...baseCommands, ...extensions } as Record<keyof ChainableCommands<T>, any>; // Build the chainable object const chainable: any = { run() { commands.forEach(cmd => cmd(state)); return state.value; } }; // Attach all command methods to the chainable object Object.keys(allCommands).forEach(key => { chainable[key] = (...args: any[]) => { commands.push(allCommands[key](state)(...args)); return chainable; }; }); return chainable as Chainable<T>; } }; }
Add Custom Extensions (e.g., Multiply & Divide)
Define a new set of commands and create an advanced calculator:
// Define new multiplication/division commands interface MultiplicativeCommands extends CalculatorCommands { mul: (num: number) => void; div: (num: number) => void; } // Create a calculator with the new commands const advancedCalculator = createCalculator<MultiplicativeCommands>({ mul: (state) => (num: number) => { state.value *= num; }, div: (state) => (num: number) => { if (num === 0) throw new Error("Division by zero!"); state.value /= num; } }); // Test the extended calculator console.log(advancedCalculator.chain().setValue(10).mul(2).div(5).run()); // Output: 4
Key Principles
- Isolated Context: Each
chain()call creates a fresh state and queue, so chains don't interfere with one another. - Command Queuing: Commands are stored until
run()is called, ensuring ordered execution. - Type-Safe Extensions: Using TypeScript interfaces and generics lets us add new commands with full auto-completion and type checks, just like Tiptap.
- No Class Required: We used plain objects and functions here, matching Tiptap's non-class-based implementation.
内容的提问来源于stack exchange,提问作者Ishan Chhabra

