使用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
事件,允許我鉤接到表格狀態上,如果:
- 公式引用了表格範圍之外的值
- 單元格是自身引用
對於每個事件響應程序的內部運行方式有一些技巧需要理解,但不用擔心細節,看看它的整體運作方式即可。
改進性能
從Table傳遞給Cell的updateCells
prop是負責重新渲染表中所有的單元格,當一個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}`
我們使用id
prop來保證在同一個應用程序中使用多個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) })
}
}
}
總結
教程到此結束!
不要錯過我們在教程中討論的主題的深入介紹: