/

教程:使用React創建試算表

教程:使用React創建試算表

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

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

相關內容

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

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

第一步

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

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

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

下面是一個小Demo動畫:

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

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

構建簡單的試算表

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

1
npm install -g create-react-app

然後運行:

1
2
3
npx create-react-app spreadsheet
cd spreadsheet
npm start

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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;

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

1
2
3
4
5
6
7
8
9
10
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中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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中,這是我們試算表的核心(也是最後)組件!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
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組件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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中看到它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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更改為適應公式的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
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()是避免在重新渲染整個表格時性能懲罰的關鍵:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 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()方法,並將它從:

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

變成:

1
2
3
4
5
6
7
8
9
10
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’,使用了:

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

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

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

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

1
2
3
4
5
6
7
8
componentWillMount() {
if (this.props.saveToLocalStorage && window && window.localStorage) {
const data = window.localStorage.getItem(this.tableIdentifier)
if (data) {
this.setState({ data: JSON.parse(data) })
}
}
}

總結

教程到此結束!

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