Из этого туториала Вы узнаете, как создать простой клон Google Sheets или Excel с помощью React, а также как поделиться его повторно используемыми компонентами для других проектов или вашей команды с помощью Bit.

Создание урезанной версии электронной таблицы, такой как Google Sheets, действительно хороший пример демонстрации многих возможностей React.

В конце этого руководства у вас будет рабочий, настраиваемый, многоразовый компонент React для электронных таблиц, который будет обеспечивать все ваши вычисления 🙂

Кроме того, мы поделимся этими компонентами в Bit, чтобы наша команда (и мы сами) могли легко использовать их в других приложениях.

Первые шаги

Во-первых, мы подробно расскажем, что мы собираемся построить. Мы создадим компонент Table, который будет иметь фиксированное количество строк. В каждой строке одинаковое количество столбцов, и в каждый столбец мы загрузим компонент Cell.

Мы сможем выбрать любую ячейку и ввести в нее любое значение. Кроме того, мы сможем выполнять формулы для этих ячеек.

В руководстве сначала рассматриваются основные строительные блоки электронной таблицы, а затем добавляются более сложные функции, такие как:

  • использовать Bit для хранения наших компонентов
  • добавление возможности расчета формул
  • оптимизация производительности
  • сохранение контента в локальное хранилище

Установить бит

Если у вас еще нет Bit, установите его глобально, используя

npm install -g bit-bin

Создайте приложение React

Если у вас еще не установлен create-react-app, сейчас хорошее время для этого:

npm install -g create-react-app

Тогда давайте начнем с

create-react-app spreadsheet
cd spreadsheet

Эта процедура создает ряд файлов в папке spreadsheet:

Инициализировать бит

Выполните эту команду, чтобы инициализировать рабочее пространство Bit:

bit init

это добавит файлы bit.json и .bitmap. Также будет создана bit подпапка в .git скрытой папке. Это происходит, когда у вас установлен репозиторий Git (в противном случае Bit создает папку .bit в основной папке проекта), поскольку create-react-app автоматически инициализирует репозиторий Git для нас.

Запустите приложение

Запустите npm run 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" />
          <p>
            Edit <code>src/App.js</code> and save to reload.
          </p>
          <a
            className="App-link"
            href="https://reactjs.org"
            target="_blank"
            rel="noopener noreferrer"
          >
            Learn React
          </a>
        </header>
      </div>
    )
  }
}
export default App

Создайте электронную таблицу

Давайте удалим большую часть этого кода и просто заменим его простым отрисовкой компонента Table. Мы передаем ему 2 свойства: x количество столбцов и y количество строк.

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

Вот компонент таблицы, который мы храним в components/Table.js:

import React from 'react'
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 управляет своим собственным состоянием. Его метод render() создает список из Row компонентов и передает каждому из них ту часть состояния, которая их беспокоит: данные строки. Компонент Row, в свою очередь, передаст эти данные нескольким Cellcomponent, которые мы представим через минуту.

Мы используем номер строки y в качестве ключевого свойства, которое является обязательным для различения нескольких строк.

Мы передаем каждому Row компоненту handleChangedCell метод как опору. Когда строка вызывает этот метод, она передает кортеж (x, y), указывающий строку и новое значение, которое было вставлено в нее, и мы соответственно обновляем состояние.

Давайте рассмотрим компонент Row, хранящийся в components/Row.js:

import React from 'react'
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>
}
export default Row

Как и компонент Table, здесь мы создаем массив из Cell компонентов и помещаем его в переменную cells, которую визуализирует компонент.

Мы передаем комбинацию координат x, y в качестве ключа, и мы передаем в качестве опоры текущее состояние этого значения ячейки, используя value={props.rowData[x] || ''}, по умолчанию для состояния используется пустая строка, если она не установлена.

Теперь перейдем к ячейке, ключевому (и последнему) компоненту нашей таблицы. Сохраните это в src/components/Cell.js:

import React from 'react'
/**
 * 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>
    )
  }
}

Здесь достаточно много для обсуждения! Но сначала вы должны наконец увидеть что-то в своем браузере, и это что-то, кажется, уже работает неплохо:

Это немного, но мы уже можем редактировать содержимое ячеек.

Давайте изучим код.

В конструкторе мы устанавливаем некоторые свойства внутреннего состояния, которые нам понадобятся позже, а также инициализируем свойство this.display на основе props.value, которое используется в методе render (). Зачем мы это делаем? Потому что позже, когда мы добавим возможность хранить данные таблицы в локальном хранилище, мы сможем инициализировать ячейку значением вместо пустого значения.

На данный момент props.value всегда будет иметь пустое значение, поэтому все ячейки инициализируются пустыми.

Когда значение Cell изменяется, я увеличиваю событие updateCells до Table, что вызывает обновление всего компонента.

Когда выбран Cell, я запускаю состояние selected, которое использую для добавления некоторых атрибутов CSS (схемы). Это можно было бы оставить на усмотрение CSS, но я решил учесть это как свойство состояния, чтобы потом при желании контролировать выбор нескольких ячеек.

Когда выбран Cell, он генерирует unselectAll обычное JS-событие, которое позволяет одноуровневым ячейкам обмениваться данными. Это также помогает очистить выбор нескольких экземпляров таблиц на странице, что я считаю хорошим поведением и естественным подвигом UX.

Cell можно щелкнуть или дважды щелкнуть, и я ввел таймер для предотвращения конфликтов между этими двумя событиями. Щелкнув ячейку, выберите ее, а двойной щелчок позволяет редактировать, переключая span, обычно используемый для визуализации таблицы, в поле input, и вы можете ввести любое значение.

Таким образом, упаковка Table отображает список y Row компонентов, которые, в свою очередь, отображают xCell компонентов каждый.

В текущей реализации Row не более чем прокси; он отвечает за создание Cell, составляющих строку, но помимо этого он просто передает события вверх по иерархии Table через props.

Введение в формулы

Электронная таблица на данный момент хороша и все такое, но реальная сила заключается в возможности выполнять формулы: значения сумм, ссылки на другие ячейки и так далее.

Я решил использовать эту довольно красивую библиотеку, которая обрабатывает формулы Excel: https://github.com/handsontable/formula-parser, чтобы мы могли получить полную совместимость с наиболее популярными формулами бесплатно, без необходимости кодировать их самостоятельно.

Кажется, что библиотека довольно активно развивается и имеет хороший набор тестов, поэтому мы можем сами запустить тест, чтобы проверить, не что-то пойдет не так.

Мы можем запустить npm install hot-formula-parser, а затем перезапустить наше приложение с npm run start.

Мы сделали первый анализ приложения сверху вниз, теперь давайте начнем снизу.

В компоненте Cell при определении значения элемента мы запускаем метод 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
  }
  //...
}

Мы получаем executeFormula() из нашего родительского компонента, поэтому давайте посмотрим на Row.js:

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}
    />,
  )
  //...
}

Мы просто передаем его от свойств компонента его дочерним элементам. Здесь ничего сложного. Тогда вся функциональность перенесена на Table! Это потому, что для чего-либо мы должны знать все состояние таблицы, мы не можем просто запустить формулу в ячейке или в строке: любая формула может ссылаться на любую другую ячейку. Вот как мы отредактируем Table.js, чтобы он соответствовал формулам:

//...
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()method каждой строке, а при вызове вызываем наш синтаксический анализатор. Парсер генерирует 2 события, которые мы используем для подключения состояния нашей таблицы для определения значения конкретных ячеек (callCellValue) и значений диапазона ячеек (callRangeValue), например, =SUM(A1:A5).

Метод Table.executeFormula() создает рекурсивный вызов вокруг анализатора, потому что, если в ячейке есть функция идентификации, указывающая на другую функцию идентификации, она будет разрешать функции до тех пор, пока не получит простое значение. Таким образом, каждая ячейка таблицы может быть связана друг с другом, но будет генерировать значение INVALID, когда определена круговая ссылка, потому что в библиотеке есть событие callCellValue, которое позволяет мне подключиться к состоянию таблицы и вызвать ошибку, если 1 ) формула ссылается на значение из таблицы 2) ячейка ссылается на себя

Внутреннюю работу каждого респондента сложно понять, но не беспокойтесь о деталях, сосредоточьтесь на том, как это работает в целом.

Улучшить производительность

Свойство updateCells, передаваемое из таблицы в ячейку, отвечает за повторную визуализацию всех ячеек в таблице и запускается, когда ячейка изменяет свое содержимое.

Это связано с тем, что другая ячейка может ссылаться на нашу в формуле, и может потребоваться обновить несколько ячеек из-за изменения в другой ячейке.

В настоящий момент мы слепо обновляем все ячейки, что требует значительного повторного рендеринга. Представьте себе большую таблицу, и объем вычислений, необходимых для повторной визуализации, может быть достаточно плохим, чтобы вызвать некоторые проблемы.

Нам нужно что-то сделать: реализовать shouldComponentUpdate() в 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
  }
//...

Что делает этот метод: если есть значение, и это значение является формулой, да, нам нужно обновить, поскольку наша формула может зависеть от какого-либо другого значения ячейки.

Затем мы проверяем, редактируем ли мы эту ячейку, и в этом случае - да, нам нужно обновить компонент.

Во всех остальных случаях мы можем оставить этот компонент как есть и не преобразовывать его заново.

Короче говоря, мы обновляем только ячейки формулы и изменяемую ячейку.

Мы могли бы улучшить это, сохранив график зависимостей формул, который может запускать специальную повторную визуализацию зависимых ячеек одной измененной, что является оптимизацией, которая с большими объемами данных может быть спасением, но может даже вызывать задержки. сам, поэтому я остановился на этой базовой реализации.

Добавьте компоненты в Bit

Теперь мы скажем Bit отслеживать все компоненты, которые мы помещаем в папку src/components:

bit add src/components/*

Запустите bit status, чтобы увидеть текущий статус экземпляра Bit:

Нам нужно установить компилятор для рабочей области (мы будем использовать react):

bit import bit.envs/compilers/react --compiler

и мы можем собрать наши компоненты:

bit build components/cell
bit build components/row
bit build components/table

Теперь давайте объявим, что это их версия 1.0.0:

bit tag --all 1.0.0

Запустите bit status еще раз, чтобы увидеть прикрепленную версию:

затем переместите их в удаленную область Bit:

На нашей странице Bit scope теперь перечислены 3 компонента, которые мы экспортировали из нашего приложения.

Как видите, если над определением компонента есть какой-либо комментарий, который используется в качестве описания компонента:

Теперь мы можем легко импортировать любой из этих компонентов в отдельный проект с помощью npm.

Нам просто нужно добавить реестр Bit в конфигурацию npm:

npm config set '@bit:registry' https://node.bitsrc.io

и мы можем вызвать любой компонент Bit, ссылаясь на имя. Мое имя пользователя в Bit - flaviocopes, и я назвал свою область видимости spreadsheet, поэтому я могу импортировать Cellcomponent, позвонив:

npm i @bit/flaviocopes.spreadsheet.components.cell

Подведение итогов

Это все для этого урока! Мы создали приложение React, которое отображает простую электронную таблицу, и поместили повторно используемые компоненты в Bit, чтобы легко повторно использовать их в других проектах. Надеюсь, вам понравилось, и не стесняйтесь комментировать и спрашивать что угодно!

Учить больше