请选择 进入手机版 | 继续访问电脑版

教程:使用React创建电子表格

前端开发  / React.js  / 倒序浏览   © 著作权归作者本人所有

#楼主# 2020-5-17

跳转到指定楼层

如何使用React构建简单的Google表格或Excel克隆

如果您不熟悉这些主题,则可能需要查看它们以获取对这些主题的介绍。

第一步

首先,我们将详细说明要构建的内容。我们将创建一个Table组件,该组件将具有固定数量的行。每行具有相同数量的列,并且将在每个列中加载一个Cell组件。

我们将能够选择任何单元格,并在其中键入任何值。此外,我们将能够在这些单元格上执行公式,从而有效地创建一个不会丢失Excel或Google Sheets anything内容的电子表格``。

这是一个小样的gif:

本教程首先介绍电子表格的基本构建块,然后再添加更多高级功能,例如:

  • 增加了计算公式的能力
  • 优化性能
  • 将内容保存到本地存储

建立一个简单的电子表格

如果您尚未create-react-app安装,那么现在是执行此操作的好时机:

npm install -g create-react-app

然后让我们开始

npx create-react-app spreadsheet
cd spreadsheet
npm start

React应用程序将在localhost:3000以下时间启动:

我们现在应该关注的是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;

让我们抹掉这段代码的大部分内容,仅将其替换为Table组件的简单呈现即可。我们为它传递2个属性:xy数和行数。

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组件将依次将此数据传递给多个Cell组件,我们将在稍后介绍。

我们使用y行号作为键属性,这是区分多个行所必需的。

我们将方法作为道具传递给每个Row组件handleChangedCell。当一行调用此方法时,它将传递一个(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坐标组合作为键,并使用prop向下传递该单元格值的当前状态,value={props.rowData[x] || ''}如果未设置,则默认为空字符串。

现在,让我们深入了解单元格,它是电子表格的核心(也是最后一个)组件!

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

/**
 * Cell represents the atomic element of a table
 */
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
  }

  /**
   * Calculates a cell's CSS values
   */
  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,
}

这里有点讨论!但是首先,您应该最终可以在浏览器中看到某些内容,并且这些内容似乎已经可以正常工作:

数量不多,但是我们已经可以编辑单元格的内容了。

让我们检查一下代码。

在构造函数中,我们设置了一些稍后需要的内部状态属性,并且还this.display基于props.valuerender()方法中使用的初始化了该属性。我们为什么要这样做?因为稍后再添加将表数据存储在本地存储中的选项时,我们将能够使用值而不是空值来初始化单元格。

此刻,props.value将始终具有空值,因此所有单元格都初始化为空。

Cell值更改时,我将updateCells事件升级为Table强制更新整个组件的事件。

Cell选择a时,我触发selected状态,该状态用于添加一些CSS属性(轮廓)。可以将其留给CSS作业,但是我决定将其作为状态属性考虑在内,以便以后可以选择控制多个单元格的选择。

Cell选择a时,它将发出一个unselectAll普通的JS事件,该事件允许同级单元进行通信。这也有助于清除页面上多个表实例的选择,我认为这是一种良好的行为,也是用户的一种自然的壮举。

Cell可以单击或双击A ,并且我引入了一个计时器来防止这两个事件之间发生冲突。单击一个单元格将其选中,而双击允许通过将span通常用于将表呈现的input字段切换到字段中来进行编辑,并且您可以输入任何值。

因此,包装一个render Table会呈现一个y Row组件列表,这些x Cell组件又依次渲染每个组件。

在当前的实现Row中,代理仅不过是一个代理而已。它负责创建Cell组成一行的,但除此之外,它只是将事件沿层次结构传递给Tablevia props

公式介绍

此时,电子表格很不错,而且一切正常,但是真正的强大之处在于能够执行公式:求和值,引用其他单元格等等。

我决定使用这个非常漂亮的库来处理Excel公式:https//github.com/handsontable/formula-parser,这样我们就可以免费与最受欢迎的公式完全兼容,而不必自己编写代码。

该库似乎非常活跃,并且具有良好的测试套件,因此我们可以自己运行测试以检查是否出了问题。

我们可以运行npm install hot-formula-parser,然后使用重新启动我们的应用npm start

我们从上至下进行了第一个应用程序剖析,现在让我们从底部开始。

在“单元格”组件中,确定项目的值时,我们运行以下determineDisplay()方法:

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

这很简单,因为它缺少大部分功能。如果仅是一个值,则确定该值很简单,但是如果我们需要基于公式计算该值,则确定起来会更复杂。公式(在我们的小电子表格中)始终以等号开头=,因此,只要我们将其视为值的第一个字符,就可以通过调用executeFormula()作为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,
  //...
}

我们正在将它从组件道具传递给子组件。这里没什么复杂的。然后功能的全部内容都移到了Table!这是因为要执行任何操作,我们必须了解表的所有状态,我们不能仅在单元格或行上运行公式:任何公式都可以引用任何其他单元格。因此,这是我们如何编辑表格以适合公式的方法:

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

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

    // When a formula contains a cell value, this event lets us
    // hook and return an error value if necessary
    this.parser.on('callCellValue', (cellCoord, done) => {
      const x = cellCoord.column.index + 1
      const y = cellCoord.row.index + 1

      // Check if I have that coordinates tuple in the table range
      if (x > this.props.x || y > this.props.y) {
        throw this.parser.Error(this.parser.ERROR_NOT_AVAILABLE)
      }

      // Check that the cell is not self referencing
      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('')
      }

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

    // When a formula contains a range value, this event lets us
    // hook and return an error value if necessary
    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)
      }
    })
  }

  //...

  /**
   * Executes the formula on the `value` usign the
   * FormulaParser object
   */
  executeFormula = (cell, value) => {
    this.parser.cell = cell
    let res = this.parser.parse(value)
    if (res.error != null) {
      return res // tip: returning `res.error` shows more details
    }
    if (res.result.toString() === '') {
      return res
    }
    if (res.result.toString().slice(0, 1) === '=') {
      // formula points to formula
      res = this.executeFormula(cell, res.result.slice(1))
    }

    return res
  }

  render() {
    //...
        <Row
          handleChangedCell={this.handleChangedCell}
          executeFormula={this.executeFormula}
          updateCells={this.updateCells}
          key={y}
          y={y}
          x={this.props.x + 1}
          rowData={rowData}
        />,
    //...
  }
}

在构造函数中,我们初始化公式解析器。我们将executeFormula()方法传递给每个Row,并在调用该方法时调用解析器。解析器发出2个事件,我们使用它们挂钩表状态来确定特定单元格(callCellValue)的值和一系列单元格(callRangeValue)的值,例如 =SUM(A1:A5)

Table.executeFormula()方法围绕解析器构建递归调用,因为如果单元格具有指向另一个标识函数的标识函数,它将解析该函数,直到获得纯值为止。这样,表中的每个单元格都可以相互链接,但是在确定循环引用时将生成INVALID值,因为该库具有一个callCellValue事件,如果该事件发生,我可以进入表状态并引发错误,如果1)公式引用表中的值2)单元格是自引用的

每个事件响应器的内部工作都有些棘手,但不要担心细节,而应关注其整体工作方式。

提高绩效

updateCells从Table传递到Cell 的prop负责重新呈现表中的所有单元格,并在Cell更改其内容时触发。

这是因为另一个单元格可能在公式中引用了我们的单元格,并且由于另一个单元格的更改,可能需要更新多个单元格。

目前,我们正在盲目更新所有单元,这需要大量渲染。想象一个大表,重新渲染所需的计算量可能很糟糕,可能会引起一些问题。

我们需要做一些事情:实现shouldComponentUpdate()in Cell。

Cell.shouldComponentUpdate()关键是要避免重新呈现整个表的性能损失:

//...

  /**
   * Performance lifesaver 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,以便在重新加载页面时,数据仍然存在。我们可以关闭浏览器,下周重新打开它,然后数据仍然在那里。

我们该怎么做?

我们需要handleChangedCell()了解Table 的方法,并将其更改为:

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道具,以便我们可以在同一个应用程序中使用多个Table组件,并且通过以下方式呈现它们,它们都将保存在自己的存储中:

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

现在,我们只需要在初始化Table组件时加载此状态,componentWillMount()方法是将方法添加到Table

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

使用道具

成为第一个评论人

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

关于作者

damonare

网站编辑

  • 主题

    267

  • 帖子

    269

  • 关注者

    0

手机版|ObjectX 超对象 |粤ICP备20005929号
Powered by  © 2019-2020版权归ObjectX 超对象