使用React來建立一個簡單的Google Sheets或Excel的克隆版本。

用React創建一個能工作且可配置重用的試算表React組件,可以支持所有計算需要 🙂

相關內容

此教程涵蓋以下主題,我已編寫了相應的指南:

如果你對這些主題還不熟悉,你可能會想先查看一下這些指南來入門。

第一步

此教程的代碼可在GitHub上找到:https://github.com/flaviocopes/react-spreadsheet-component

首先,我們將詳細介紹我們要構建的内容。我們將創建一個Table組件,它將具有固定數量的行。每一行都有相同數量的列,每一列都是一個Cell組件。

我們將能夠選擇任何單元格,並在其中輸入任何值。此外,我們還能對這些單元格執行公式,從而創建一個工作中的試算表,完全不會輸在Excel或Google Sheets😏</sarcasm>

下面是一個小Demo動畫:

該教程首先介紹了試算表的基本組件,然後進入更高級的功能,比如:

  • 添加公式計算的能力
  • 優化性能
  • 將內容保存到本地存儲中

構建簡單的試算表

如果你還沒有安裝create-react-app,那麼現在就可以安裝了:

npm install -g create-react-app

然後運行:

npx create-react-app spreadsheet
cd spreadsheet
npm start

React應用程序將在localhost:3000上啟動:

這個操作在spreadsheet文件夾中創建了很多文件:

我們現在只需要關注App.js。該文件默認包含以下代碼:

import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';

class App extends Component {
    render() {
        return (
            <div className="App">
                <header className="App-header">
                    <img src={logo} className="App-logo" alt="logo" />
                    <h1 className="App-title">Welcome to React</h1>
                </header>
                <p className="App-intro">
                    To get started, edit <code>src/App.js</code> and save to reload.
                </p>
            </div>
        );
    }
}

export default App;

我們將這些源代码清除掉,只需用一個簡單的渲染表組件的方法來替換它:

import React from 'react'
import Table from './components/Table'

const App = () => (
    <div style={{ width: 'max-content' }}>
        <Table x={4} y={4} />
    </div>
)

export default App

下面是Table組件,我們將其存儲在components/Table.js中:

import React from 'react'
import PropTypes from 'prop-types'
import Row from './Row'

export default class Table extends React.Component {
    constructor(props) {
        super(props)

        this.state = {
            data: {},
        }
    }

    handleChangedCell = ({ x, y }, value) => {
        const modifiedData = Object.assign({}, this.state.data)
        if (!modifiedData[y]) modifiedData[y] = {}
        modifiedData[y][x] = value
        this.setState({ data: modifiedData })
    }

    updateCells = () => {
        this.forceUpdate()
    }

    render() {
        const rows = []

        for (let y = 0; y < this.props.y + 1; y += 1) {
            const rowData = this.state.data[y] || {}
            rows.push(
                <Row
                    handleChangedCell={this.handleChangedCell}
                    updateCells={this.updateCells}
                    key={y}
                    y={y}
                    x={this.props.x + 1}
                    rowData={rowData}
                />,
            )
        }
        return (
            <div>
                {rows}
            </div>
        )
    }
}

Table.propTypes = {
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired,
}

Table組件管理其自己的狀態。在其render()方法中,它創建了一個Row組件列表,並將每個Row組件所關心的狀態(即該行數據)傳遞給它們。

我們使用y作為key屬性,這是必需的,以區分多個行。

我們將handleChangedCell方法作為prop傳遞給每個Row組件。當一行調用此方法時,它會傳遞一個(x, y)元組表示行,以及插入其內的新值,然後我們將相應地更新狀態。

讓我們來看看Row組件,它被存儲在components/Row.js中:

import React from 'react'
import PropTypes from 'prop-types'
import Cell from './Cell'

const Row = (props) => {
    const cells = []
    const y = props.y
    for (let x = 0; x < props.x; x += 1) {
        cells.push(
            <Cell
                key={`${x}-${y}`}
                y={y}
                x={x}
                onChangedValue={props.handleChangedCell}
                updateCells={props.updateCells}
                value={props.rowData[x] || ''}
            />,
        )
    }
    return (
        <div>
            {cells}
        </div>
    )
}

Row.propTypes = {
    handleChangedCell: PropTypes.func.isRequired,
    updateCells: PropTypes.func.isRequired,
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired,
    rowData: PropTypes.shape({
        string: PropTypes.string,
    }).isRequired,
}

export default Row

Table組件相同,此處我們正在構建一個Cell組件數組,並將其放在cells變量中,然後在組件中渲染它。

我們將x,y座標組合作爲key,並使用value={props.rowData[x] || ''}作為prop將該單元格值的當前狀態傳遞到它,如果未設置,則將狀態設置爲空字符串。

現在,讓我們深入到Cell中,這是我們試算表的核心(也是最後)組件!

import React from 'react'
import PropTypes from 'prop-types'

/**
 * Cell是表格的最小元素
 */
export default class Cell extends React.Component {
    constructor(props) {
        super(props)
        this.state = {
            editing: false,
            value: props.value,
        }
        this.display = this.determineDisplay(
            { x: props.x, y: props.y },
            props.value
        )
        this.timer = 0
        this.delay = 200
        this.prevent = false
    }

    /**
     * Add listener to the `unselectAll` event used to broadcast the
     * unselect all event.
     */
    componentDidMount() {
        window.document.addEventListener('unselectAll', this.handleUnselectAll)
    }

    /**
     * Before updating, execute the formula on the Cell value to
     * calculate the `display` value. Especially useful when a
     * redraw is pushed upon this cell when editing another cell
     * that this might depend upon.
     */
    componentWillUpdate() {
        this.display = this.determineDisplay(
            { x: this.props.x, y: this.props.y },
            this.state.value
        )
    }

    /**
     * Remove the `unselectAll` event listener added in
     * `componentDidMount()`.
     */
    componentWillUnmount() {
        window.document.removeEventListener(
            'unselectAll',
            this.handleUnselectAll
        )
    }

    /**
     * When a Cell value changes, re-determine the display value
     * by calling the formula calculation.
     */
    onChange = (e) => {
        this.setState({ value: e.target.value })
        this.display = this.determineDisplay(
            { x: this.props.x, y: this.props.y },
            e.target.value
        )
    }

    /**
     * Handle pressing a key when the Cell is an input element.
     */
    onKeyPressOnInput = (e) => {
        if (e.key === 'Enter') {
            this.hasNewValue(e.target.value)
        }
    }

    /**
     * Handle pressing a key when the Cell is a span element,
     * not yet in editing mode.
     */
    onKeyPressOnSpan = () => {
        if (!this.state.editing) {
            this.setState({ editing: true })
        }
    }

    /**
     * Handle moving away from a cell, stores the new value.
     */
    onBlur = (e) => {
        this.hasNewValue(e.target.value)
    }

    /**
     * Used by `componentDid(Un)Mount`, handles the `unselectAll`
     * event response.
     */
    handleUnselectAll = () => {
        if (this.state.selected || this.state.editing) {
            this.setState({ selected: false, editing: false })
        }
    }

    /**
     * Called by the `onBlur` or `onKeyPressOnInput` event handlers,
     * it escalates the value changed event, and restore the editing
     * state to `false`.
     */
    hasNewValue = (value) => {
        this.props.onChangedValue(
            {
                x: this.props.x,
                y: this.props.y,
            },
            value
        )
        this.setState({ editing: false })
    }

    /**
     * Emits the `unselectAll` event, used to tell all the other
     * cells to unselect.
     */
    emitUnselectAllEvent = () => {
        const unselectAllEvent = new Event('unselectAll')
        window.document.dispatchEvent(unselectAllEvent)
    }

    /**
     * Handle clicking a Cell.
     */
    clicked = () => {
        // Prevent click and double click to conflict
        this.timer = setTimeout(() => {
            if (!this.prevent) {
                // Unselect all the other cells and set the current
                // Cell state to `selected`
                this.emitUnselectAllEvent()
                this.setState({ selected: true })
            }
            this.prevent = false
        }, this.delay)
    }

    /**
     * Handle doubleclicking a Cell.
     */
    doubleClicked = () => {
        // Prevent click and double click to conflict
        clearTimeout(this.timer)
        this.prevent = true

        // Unselect all the other cells and set the current
        // Cell state to `selected` & `editing`
        this.emitUnselectAllEvent()
        this.setState({ editing: true, selected: true })
    }

    determineDisplay = ({ x, y }, value) => {
        return value
    }

    calculateCss = () => {
        const css = {
            width: '80px',
            padding: '4px',
            margin: '0',
            height: '25px',
            boxSizing: 'border-box',
            position: 'relative',
            display: 'inline-block',
            color: 'black',
            border: '1px solid #cacaca',
            textAlign: 'left',
            verticalAlign: 'top',
            fontSize: '14px',
            lineHeight: '15px',
            overflow: 'hidden',
            fontFamily:
                "Calibri, 'Segoe UI', Thonburi, Arial, Verdana, sans-serif",
        }

        if (this.props.x === 0 || this.props.y === 0) {
            css.textAlign = 'center'
            css.backgroundColor = '#f0f0f0'
            css.fontWeight = 'bold'
        }

        return css
    }

    render() {
        const css = this.calculateCss()

        // column 0
        if (this.props.x === 0) {
            return (
                <span style={css}>
                    {this.props.y}
                </span>
            )
        }

        // row 0
        if (this.props.y === 0) {
            const alpha = ' abcdefghijklmnopqrstuvwxyz'.split('')
            return (
                <span
                    onKeyPress={this.onKeyPressOnSpan}
                    style={css}
                    role="presentation"
                >
                    {alpha[this.props.x]}
                </span>
            )
        }

        if (this.state.selected) {
            css.outlineColor = 'lightblue'
            css.outlineStyle = 'dotted'
        }

        if (this.state.editing) {
            return (
                <input
                    style={css}
                    type="text"
                    onBlur={this.onBlur}
                    onKeyPress={this.onKeyPressOnInput}
                    value={this.state.value}
                    onChange={this.onChange}
                    autoFocus
                />
            )
        }
        return (
            <span
                onClick={e => this.clicked(e)}
                onDoubleClick={e => this.doubleClicked(e)}
                style={css}
                role="presentation"
            >
                {this.display}
            </span>
        )
    }
}

Cell.propTypes = {
    onChangedValue: PropTypes.func.isRequired,
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired,
    value: PropTypes.string.isRequired,
}

這裡有很多東西需要討論!但首先,您應該能夠在瀏覽器中看到一些內容,且看起來已經非常好:

它還不完整,但我們已經可以編輯單元格內容了。

現在,讓我們來看看此代碼。

在構造函數中,我們設置了一些內部狀態屬性,稍後我們需要使用它們,並且我們還根據’props.value’初始化了this.display屬性,它在render()方法中使用。為什麼我們這樣做?因為稍後,當我們為表格數據添加本地存儲功能時,我們將能夠初始化單元格值而不是將其初始化為空值。

目前,props.value始終是空值,所以所有單元格都初始化為空。

當單元格的值發生變化時,我們將通過調用determineDisplay()方法在其上運行公式計算,我們將公式計算作為prop之一傳遞給Cell組件:

export default class Cell extends React.Component {
    // ...

    determineDisplay = ({ x, y }, value) => {
        if (value.slice(0, 1) === '=') {
            const res = this.props.executeFormula({ x, y }, value.slice(1))
            if (res.error !== null) {
                return 'INVALID'
            }
            return res.result
        }
        return value
    }

    // ...
}

Cell.propTypes = {
    // ...
    executeFormula: PropTypes.func.isRequired,
    // ...
}

我們從父組件獲取executeFormula()方法,因此讓我們在Row中看到它:

const Row = (props) => {
    // ...
    cells.push(
        <Cell
            key={`${x}-${y}`}
            y={y}
            x={x}
            onChangedValue={props.handleChangedCell}
            updateCells={props.updateCells}
            value={props.rowData[x] || ''}
            executeFormula={props.executeFormula}
        />,
    )
    // ...
}

Row.propTypes = {
    // ...
    executeFormula: PropTypes.func.isRequired,
    // ...
}

我們將其從組件props傳遞給其子組件。這裡沒有復雜的操作。所有功能的核心代碼都往上移到Table中!這是因為如果我們要執行任何操作,都必須了解整個表格的狀態,不能僅運行單元格或行的公式:任何公式都可能引用其他任何單元格。因此,下面是我們將Table更改為適應公式的方法:

import { Parser as FormulaParser } from 'hot-formula-parser'

export default class Table extends React.Component {
    constructor(props) {
        // ...
        this.parser = new FormulaParser()

        this.parser.on('callCellValue', (cellCoord, done) => {
            const x = cellCoord.column.index + 1
            const y = cellCoord.row.index + 1

            if (x > this.props.x || y > this.props.y) {
                throw this.parser.Error(this.parser.ERROR_NOT_AVAILABLE)
            }

            if (this.parser.cell.x === x && this.parser.cell.y === y) {
                throw this.parser.Error(this.parser.ERROR_REF)
            }

            if (!this.state.data[y] || !this.state.data[y][x]) {
                return done('')
            }

            return done(this.state.data[y][x])
        })

        this.parser.on('callRangeValue', (startCellCoord, endCellCoord, done) => {
            const sx = startCellCoord.column.index + 1
            const sy = startCellCoord.row.index + 1
            const ex = endCellCoord.column.index + 1
            const ey = endCellCoord.row.index + 1
            const fragment = []

            for (let y = sy; y <= ey; y += 1) {
                const row = this.state.data[y]
                if (!row) {
                    continue
                }

                const colFragment = []

                for (let x = sx; x <= ex; x += 1) {
                    let value = row[x]
                    if (!value) {
                        value = ''
                    }

                    if (value.slice(0, 1) === '=') {
                        const res = this.executeFormula({ x, y }, value.slice(1))
                        if (res.error) {
                            throw this.parser.Error(res.error)
                        }
                        value = res.result
                    }

                    colFragment.push(value)
                }
                fragment.push(colFragment)
            }

            if (fragment) {
                done(fragment)
            }
        })
    }

    // ...

    executeFormula = (cell, value) => {
        this.parser.cell = cell
        let res = this.parser.parse(value)
        if (res.error != null) {
            return res
        }
        if (res.result.toString() === '') {
            return res
        }
        if (res.result.toString().slice(0, 1) === '=') {
            res = this.executeFormula(cell, res.result.slice(1))
        }

        return res
    }

    // ...
}

在構造函數中,我們初始化了公式解析器。我們將executeFormula()方法傳遞給每個Row,當調用該方法時,我們調用了我們的解析器。解析器會發出兩個事件,我們使用它們來將我們的表格狀態鉤接到特定單元格值(callCellValue)和一系列單元格值(callRangeValue)上,例如=SUM(A1:A5)

Table.executeFormula()方法將解析器套用為遞歸調用,因為如果一個單元格具有指向另一個單元格的身份函數,它將解析它們,直到獲得普通值。通過這種方式,表的每個單元格都可以相互連接,但在確定存在循環引用時,它將生成一個INVALID值,因為該庫有一個callCellValue事件,允許我鉤接到表格狀態上,如果:

  1. 公式引用了表格範圍之外的值
  2. 單元格是自身引用

對於每個事件響應程序的內部運行方式有一些技巧需要理解,但不用擔心細節,看看它的整體運作方式即可。

改進性能

從Table傳遞給Cell的updateCellsprop是負責重新渲染表中所有的單元格,當一個Cell改變了其內容時,此時其他Cell可能會引用我們的Cell導致多個Cell需要更新。

目前,我們正在盲目地更新所有單元格,這是非常多的重新渲染。想象一下大表格需要重新渲染所需的計算量,這可能導致一些問題。

我們需要做些什麼:在Cell中實現shouldComponentUpdate()

Cell.shouldComponentUpdate()是避免在重新渲染整個表格時性能懲罰的關鍵:

/**
 * Performance saver as the cell not touched by a change can
 * decide to avoid a rerender
 */
shouldComponentUpdate(nextProps, nextState) {
    // Has a formula value? could be affected by any change. Update
    if (this.state.value !== '' && this.state.value.slice(0, 1) === '=') {
        return true
    }

    // Its own state values changed? Update
    // Its own value prop changed? Update
    if (
        nextState.value !== this.state.value ||
        nextState.editing !== this.state.editing ||
        nextState.selected !== this.state.selected ||
        nextProps.value !== this.props.value
    ) {
        return true
    }

    return false
}

該方法的功能是:如果有值,並且這個值是一個公式,那麼我們需要更新,因為我們的公式可能取決於其他單元格的值。然後,我們檢查我們是否正在編輯此單元格,在這種情況下-是的,我們需要更新組件。

在其他情況下,我們只需將該組件保持不變,不對其重新渲染。

簡而言之,只有公式單元格以及修改的單元格需要更新

我們可以通過保持公式依賴關係的圖形來改進此行為,以計算需要根據更改到單元格來更新的依賴單元格,這是一種優化操作,對於大量的數據來說,可以提升性能。但它可能會導致延遲,因此我最終使用了此基本實現。

保存表格內容

在本教程的最後一個部分,我們將介紹如何將我們在表中的數據保存到LocalStorage中,以便在重新加載頁面時,數據仍然存在。我們可以關閉瀏覽器,下一週重新打開,數據仍然會存在。

我們該如何做呢?

我們需要鉤入Table的handleChangedCell()方法,並將它從:

handleChangedCell = ({ x, y }, value) => {
    const modifiedData = Object.assign({}, this.state.data)
    if (!modifiedData[y]) modifiedData[y] = {}
    modifiedData[y][x] = value
    this.setState({ data: modifiedData })
}

變成:

handleChangedCell = ({ x, y }, value) => {
    const modifiedData = Object.assign({}, this.state.data)
    if (!modifiedData[y]) modifiedData[y] = {}
    modifiedData[y][x] = value
    this.setState({ data: modifiedData })

    if (window && window.localStorage) {
        window.localStorage.setItem(this.tableIdentifier, JSON.stringify(modifiedData))
    }
}

這樣,每次更改單元格時,我們都將狀態存儲到localStorage中。

我們在構造函數中設置了’tableIdentifier’,使用了:

this.tableIdentifier = `tableData-${props.id}`

我們使用idprop來保證在同一個應用程序中使用多個Table組件時,它們將分別保存在它們自己的存儲中,方法是以下面的方式來渲染它們:

<Table x={4} y={4} id={'1'} />
<Table x={4} y={4} id={'2'} />

現在,我們只需要在Table組件初始化時加載此狀態,通過在Table中添加componentWillMount()方法:

componentWillMount() {
    if (this.props.saveToLocalStorage && window && window.localStorage) {
        const data = window.localStorage.getItem(this.tableIdentifier)
        if (data) {
            this.setState({ data: JSON.parse(data) })
        }
    }
}

總結

教程到此結束!

不要錯過我們在教程中討論的主題的深入介紹: