Реальный доступ к результату согласования виртуальной DOM

Я обновляю часть страницы с помощью стандартного механизма this.setState. Я хочу захватить элементы, которые были изменены на веб-странице и обеспечить визуальную обратную связь с пользователем.

Скажем, у нас есть компонент RichText который получает реквизит data. Чтобы сделать богатый текст, он делегирует рендер меньшим компонентам, таким как Paragraph, Header, BulletPoints, Text и т.д. Конечным результатом является правильно выделенный богатый текст.

Более поздние реквизиты data изменяются (например, сокет). В результате этого Paragraph можно добавить или изменить текст, или все может перемещаться. Я хочу предоставить визуальную обратную связь пользователю, просто выделив HTML-узлы, которые были изменены.

В двух словах я хочу получить то, что показывает инспектор Chrome, когда вы смотрите на дерево HTML. Он мигает изменениями DOM.

ReactJS знает, что изменилось. В идеале я хотел бы получить доступ к этим знаниям.

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

Формат (упрощенная версия)

{
  content: [{
    type: 'Document',
    content: [{
      type: 'Paragraph',
      content: [{
        type: 'Text', 
        text: 'text text'
      }, {
        type: 'Reference', 
        content: 'text text'
      },
    ]}, {
        type: 'BulletPoints', 
        content: [{
          type: 'ListEntry', content: [{
            type: 'Paragraph', content: [{
              type: 'Text', 
              text: 'text text'
            }, {
              type: 'Reference', 
              content: 'text text'
            }]
          }]
        }]
      }]

Мое текущее решение

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

Поэтому я захватываю DOM перед setState и после setState. Затем я использую HtmlTreeWalker, чтобы определить первое различие (игнорируя некоторые элементы, когда я иду по дереву).

Ответы

Ответ 1

У реактивов уже есть аддон для этих ситуаций. ReactCSSTransitionGroup

ReactCSSTransitionGroup - это высокоуровневый API, основанный на ReactTransitionGroup, и является простым способом выполнения переходов и анимаций CSS, когда компонент React входит или выходит из DOM. Это вдохновило отличную библиотеку ng-animate.

Вы можете легко анимировать элементы, входящие или выходящие из определенного родителя.

var ReactCSSTransitionGroup = React.addons.CSSTransitionGroup;

const nextId = (() => {
  let lastId = 0;
  return () => ++lastId;
})();

class TodoList extends React.Component {
  constructor(props) {
    super(props);
    this.state = {items: [
      {id: nextId(), text: 'hello'}, 
      {id: nextId(), text: 'world'}, 
      {id: nextId(), text: 'click'}, 
      {id: nextId(), text: 'me'}
    ]};
    this.handleAdd = this.handleAdd.bind(this);
  }

  handleAdd() {
    const newItems = this.state.items.concat([
      {id: nextId(), text: prompt('Enter some text')}
    ]);
    this.setState({items: newItems});
  }

  handleRemove(toRemove) {
    let newItems = this.state.items.filter(item => item.id !== toRemove.id);
    this.setState({items: newItems});
  }

  render() {
    const items = this.state.items.map((item) => (
      <div key={item.id} onClick={() => this.handleRemove(item)}>
        {item.text}
      </div>
    ));

    return (
      <div>
        <button className="add-todo" onClick={this.handleAdd}>Add Item</button>        
        <ReactCSSTransitionGroup
          transitionName="example"
          transitionEnterTimeout={500}
          transitionLeaveTimeout={300}>
          {items}
        </ReactCSSTransitionGroup>
      </div>
    );
  }
}

ReactDOM.render(<TodoList/>, document.getElementById("app"));
.example-enter {  
  background-color: #FFDCFF;
  color: white;
}

.example-enter.example-enter-active {
  background-color: #9E1E9E;  
  transition: background-color 0.5s ease;
}

.example-leave {
  background-color: #FFDCFF;
  color: white;
}

.example-leave.example-leave-active {
  background-color: #9E1E9E;  
  transition: background-color 0.3s ease;
}

.add-todo {
  margin-bottom: 10px;
}
<script src="https://unpkg.com/[email protected]/dist/react-with-addons.js"></script>
<script src="https://unpkg.com/[email protected]/dist/react-dom.js"></script>

<div id="app"></div>

Ответ 2

Последнее редактирование

Хорошо, теперь вы, наконец, включили данные, необходимые для его понимания. Вы можете обрабатывать его абсолютно с помощью componentDidMount, componentWillReceiveProps и componentDidUpdate, с некоторыми переменными экземпляра, чтобы сохранить некоторое внутреннее состояние, не связанное с рендерингом в ваших "контентных" компонентах.

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

Мой стиль довольно простой, но вы можете добавить некоторые переходы CSS/анимации ключевого кадра, чтобы подсветка продолжалась только некоторое время, а не фиксировалась. Тем не менее, вопрос CSS не является реакционным. ;)

const Component = React.Component

class ContentItem extends Component {
  constructor(props){
    super(props)
    this.handleClick = this.handleClick.bind(this)
    //new by default
    this._isNew = true
    this._isUpdated = false
  }
  componentDidMount(){
    this._isNew = false
  }
  componentDidUpdate(prevProps){    
    this._isUpdated = false     
  }
  componentWillReceiveProps(nextProps){
    if(nextProps.content !== this.props.content){
      this._isUpdated = true
    }
  }
  handleClick(e){
    //hack to simulate a change in a specific content
    this.props.onChange(this.props.index)
  }
  render(){
    const { content, index } = this.props
    const newStyle = this._isNew ? 'new' : ''
    const updatedStyle = this._isUpdated ? 'updated': ''
         
    return (
      <p className={ [newStyle, updatedStyle].join(' ') }>
        { content }
        <button style={{ float: 'right' }} onClick={ this.handleClick}>Change me</button>
      </p>
     )
  }
}

class Document extends Component {
  constructor(props){
    super(props)
    this.state = {
      content: [
        { type: 'p', content: 'Foo' },
        { type: 'p', content: 'Bar' }
      ]
    }
    this.addContent = this.addContent.bind(this)
    this.changeItem = this.changeItem.bind(this)
  }
  addContent(){
    //mock new content being added
    const newContent = [ ...this.state.content, { type: 'p', content: 'Foo (created at) ${new Date().toLocaleTimeString()}' }]
    this.setState({ content: newContent })
  }
  changeItem(index){
    //mock an item being updated
    const newContent = this.state.content.map((item, i) => {
      if(i === index){
        return { ...item, content: item.content + ' Changed at ' + new Date().toLocaleTimeString() }
      }
      else return item
    })
    this.setState({ content: newContent })
  }
  render(){
    return (
      <div>
        <h1>HEY YOU</h1>
        <div className='doc'>
          {
            this.state.content.map((item, i) => 
              <ContentItem key={ i } index={ i } { ...item } onChange={ this.changeItem } />)
          }
        </div>
        <button onClick={ this.addContent }>Add paragraph</button>
      </div>
    )    
  }
}

ReactDOM.render(<Document />, document.getElementById('app'));
.new {
  background: #f00
}
.updated {
  background: #ff0
}
<div id="app"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>

Ответ 3

Я думаю, вы должны использовать componentDidUpdate

из документов:

componentDidUpdate (prevProps, prevState) вызывается сразу после обновления. Этот метод не вызывается для первоначального рендеринга.

Используйте это как возможность работать с DOM при обновлении компонента. Это также хорошее место для выполнения сетевых запросов, если вы сравниваете текущие реквизиты с предыдущими реквизитами (например, сетевой запрос может не понадобиться, если реквизит не изменился).

Вы можете сравнить, какой компонент изменился, а затем установить стиль декоратора в состоянии, который будет использоваться на вашей странице.

Ответ 4

Вы можете написать HOC, который обертывает ваши листовые компоненты в PureComponent. Затем эта обертка будет отображать завершенный компонент со специальным стилем, когда он обнаруживает изменение через componentDidUpdate. Он использует внутренний флаг для прерывания бесконечного цикла из ситуации componentDidUpdate + setState.

Здесь пример кода -

import React, {PureComponent} from "react";

let freshKid = (Wrapped, freshKidStyle) => {
    return class FreshKid extends PureComponent{
        state = {"freshKid" : true},
        componentDidUpdate(){
            if (this.freshKid){
                return;
            }
            this.freshKid = true;
            setTimeout(()=>this.setState(
                    {"freshKid" : false}, 
                    ()=>this.freshKid = false
                ), 
                100
            );
        }
        render(){
            let {freshKid} = this.state,
            {style, ..rest} = this.props,
            style = freshKid ? Object.assign({}, style, freshKidStyle) : style;

            return <Wrapped style={style} {...rest} />;
        }
    }
}

Вы можете использовать это для обертывания листового компонента, например:

let WrappedParagraph = freshKid(Paragraph, {"color":"orangered"});

Или экспортируйте все листовые компоненты, предварительно упакованные.

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

Ответ 5

Я думаю, вы должны использовать shouldComponentUpdate, насколько я знаю, только здесь вы можете точно определить ваш случай.

Вот мой пример:

class Text extends React.Component {
    constructor(props) {
        super(props);
        this.state = {textVal: this.props.text, className: ''};
    }
    shouldComponentUpdate(nextProps, nextState) {
        // Previous state equals to new state - so we have update nothing.
        if (this.state.textVal === nextProps.text) {
            this.state.className = '';
            return false;
        }
        // Here we have new state, so it is exactly our case!!!
        this.state.textVal = nextProps.text;
        this.state.className = 'blink';
        return true;
    }
    render() {
        return (<i className={this.state.className}>{this.state.textVal}</i>);
    }
}

Это только компонент Text (я оставил css и другие компоненты за сценой), я думаю, что этот код наиболее интересен, но вы можете попробовать мою рабочую версию на codepen, также здесь пример с jquery и обновлениями в цикле.

Ответ 6

Перед визуализацией компонента вам нужно будет проверить, изменились ли компоненты компонента. Если они это сделают, вам придется добавить класс к компоненту, а затем удалить этот класс после рендеринга. Добавление css transition в этот класс приведет к мигающему эффекту, например, в инструментах Chrome dev.

Чтобы обнаружить изменения свойств, вы должны использовать componentWillReceiveProps(nextProps):

componentWillReceiveProps() вызывается до того, как смонтированный компонент получит новые реквизиты. Если вам нужно обновить состояние в ответ на изменения prop (например, для его сброса), вы можете сравнить this.props и nextProps и выполнить переходы состояний, используя this.setState() в этом методе.

Этот крючок не запускается на компонентном креплении, поэтому дополнительно вам нужно установить начальное "выделенное" состояние в конструкторе.

Чтобы удалить класс после рендеринга, вам нужно сбросить состояние обратно до "не выделенного" в вызове setTimeout чтобы оно происходило за пределами стека вызовов и после рендеринга компонента.

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

class Paragraph extends React.Component {
  constructor(props) {
    super(props);
    this.state = { highlighted: true };
    this.resetHighlight();
  }

  componentWillReceiveProps(nextProps) {
    if (nextProps.text !== this.props.text) {
      this.setState({ highlighted: true });
      this.resetHighlight();
    }
  }

  resetHighlight() {
    setTimeout(() => {
      this.setState({ highlighted: false });
    }, 0);
  }

  render() {
    let classes = 'paragraph${(this.state.highlighted) ? ' highlighted' : ''}';
    return (
      <div className={classes}>{this.props.text}</div>
    );

  }
}
class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = { text: "type in me" };
  }
  handleInput(e) {
    this.setState({ text: e.target.value });
  }
  render() {
    return (
      <div className="App">
        <Paragraph text={this.state.text} />
        <input type="text" onChange={this.handleInput.bind(this)} value={this.state.text} />
      </div>
    );
  }
}

ReactDOM.render(
  <App />,
  document.getElementById('root')
);
.paragraph {
  background-color: transparent;
  transition: 1s;
}

.paragraph.highlighted {
  background-color: red;
  transition: 0s;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<div id="root"></div>

Ответ 7

Вы можете присоединить передачу обратного вызова в качестве ссылки на узел, и он будет вызываться с узлом DOM каждый раз, когда узел DOM будет создан/заново создан.

Вы можете использовать общий обратный вызов для отслеживания созданных узлов.

Ответ 8

К сожалению, React не обеспечивает перехват изменений состояния пользователя из внешнего компонента. Вы можете использовать componentDidUpdate(prevProps, nextProps) чтобы получать уведомления о изменениях состояния вашего компонента, но вам нужно сохранить ссылку на предыдущую созданную DOM и сравнить ее вручную с новой DOM (например, с помощью dom-compare). Я думаю, что вы уже справляетесь со своим текущим решением.

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

mutationObserver.js

const MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;

const observer = new MutationObserver(function(mutations) {
  mutations.forEach(function(mutation) {
    if (mutation.addedNodes) {
      mutation.addedNodes.forEach(showMutationLayer);
    }
  });
});

const showMutationLayer = (node) => {
  const mutationLayer = document.createElement('div');
  mutationLayer.style.position = 'absolute';
  mutationLayer.style.border = '2px solid red';
  document.body.appendChild(mutationLayer);
  if (node.nodeType === Node.TEXT_NODE) {
    node = node.parentNode;
  } 
  if (node.nodeType !== Node.ELEMENT_NODE) {
    return;
  }
  const { top, left, width, height } = getCoords(node);
  mutationLayer.style.top = '${top}px';
  mutationLayer.style.left = '${left}px';
  mutationLayer.style.width = '${width}px';
  mutationLayer.style.height = '${height}px';
  setTimeout(() => {
    document.body.removeChild(mutationLayer);
  }, 500);
};

function getCoords(elem) { // crossbrowser version
    const box = elem.getBoundingClientRect();
    const body = document.body;
    const docEl = document.documentElement;
    const scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop;
    const scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft;
    const clientTop = docEl.clientTop || body.clientTop || 0;
    const clientLeft = docEl.clientLeft || body.clientLeft || 0;
    const top  = box.top +  scrollTop - clientTop;
    const left = box.left + scrollLeft - clientLeft;
    return { 
      top: Math.round(top), 
      left: Math.round(left), 
      width: box.width,
      height: box.height
    };
}

export default {
   init(container) {
     observer.observe(container, {
       attributes: true,
       childList: true,
       characterData: true,
       subtree: true
     });
   } 
}

main.js

import React from 'react';
import {render} from 'react-dom';
import App from './App.js';
import mutationObserver from './mutationObserver.js';

const appContainer = document.querySelector('#app');

// Observe mutations when you are in a special 'debug' mode
// for example
if (process.env.NODE_ENV === 'debug') {
   mutationObserver.init(appContainer);
}

render(<App />, appContainer);

Преимущества этого метода: вам не нужно изменять каждый из ваших компонентов, чтобы наблюдать за изменениями. Вы также не изменяете созданные DOM компоненты (слой находится вне элемента #app). Легко включить/отключить эту функцию, чтобы сохранить производительность вашего приложения.

Смотрите это в действии в этой скрипке (вы можете редактировать стиль слоя, добавляя переход CSS для более приятного слоя)

Ответ 9

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

Я установил класс "уведомлять-изменил" css для компонентов и/или элементов, которые я хочу отслеживать. Мой наблюдатель прислушивается к изменениям и проверяет, имеет ли измененный dom и/или его родители класс "уведомлять-изменить". Если условие согласовано, оно просто добавляет класс "in" для отмеченного элемента "уведомлять-изменить", чтобы начать эффект затухания. И удаляет класс "in" в определенные временные рамки

(function () {
    const observer = new MutationObserver(function (mutations) {
        mutations.forEach(function (m) {
            let parent = m.target && m.target.parentNode;
            while (parent) {
                if (parent.classList && parent.classList.contains('notify-change')) {
                    break;
                }
                parent = parent.parentNode;
            }
            if (!parent) return;
            parent.classList.add('in');
            setTimeout(function () {
                try {
                    parent.classList.remove('in');
                } catch (err) {
                }
            }, 300);
        });
    });
    observer.observe(document.body, {subtree: true, characterData: true, characterDataOldValue: true});
})();

// testing

function test(){
  Array.from(document.querySelectorAll(".notify-change"))
  .forEach(e=>
    e.innerHTML = ["lorem", "ipsum", "sit" , "amet"][Math.floor(Math.random() * 4)]
  );
}

setInterval(test, 1000);
test();
.notify-change {
  transition: background-color 300ms ease-out;
  background-color:transparent;
}

.notify-change.in{
  background-color: yellow !important;
}
<div>Lorem ipsum dolor sit amet, eu quod duis eius sit, duo commodo impetus an, vidisse cotidieque an pro. Usu dicat invidunt et. Qui eu <span class="notify-change">Ne</span> impetus electram. At enim sapientem ius, ubique labore copiosae sea eu, commodo persecuti instructior ad his. Mazim dicit iisque sit ea, vel te oblique delenit.

Quo at <span class="notify-change">Ne</span> saperet <span class="notify-change">Ne</span>, in mei fugit eruditi nonumes, errem clita volumus an sea. Elitr delicatissimi cu quo, et vivendum lobortis usu. An invenire voluptatum his, has <span class="notify-change">Ne</span> incorrupte ad. Sensibus maiestatis necessitatibus sit eu, tota veri sea eu. Mei inani ocurreret maluisset <span class="notify-change">Ne</span>, mea ex mentitum deleniti.

Quidam conclusionemque sed an. <span class="notify-change">Ne</span> omnes utinam salutatus ius, sea quem necessitatibus no, ad vis antiopam tractatos. Ius cetero gloriatur ex, id per nisl zril similique, est id iriure scripta. Ne quot assentior theophrastus eum, dicam soleat eu ius. <span class="notify-change">Ne</span> vix nullam fabellas apeirian, nec odio convenire ex, mea at hinc partem utamur. In cibo antiopam duo.

Stet <span class="notify-change">Ne</span> no mel. Id sea adipisci assueverit, <span class="notify-change">Ne</span> erant habemus sit ei, albucius consulatu quo id. Sit oporteat argumentum ea, eam pertinax constituto <span class="notify-change">Ne</span> cu, sed ad graecis posidonium. Eos in labores civibus, has ad wisi idque.

Sit dolore <span class="notify-change">Ne</span> ne, vis eu perpetua vituperata interpretaris. Per dicat efficiendi et, eius appetere ea ius. Lorem commune mea an, at est exerci senserit. Facer viderer vel in, etiam putent alienum vix ei. Eu vim congue putent constituto, ad sit agam <span class="notify-change">Ne</span> integre, his ei veritus tacimates.</div>

Ответ 10

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

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

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

Ответ 11

Вы можете создать функцию декоратора (или HOC, если вы предпочитаете термин), который использует частичное приложение для наблюдения за изменениями на основе предоставленной функции наблюдателя.

(Очень) простая ручка, чтобы продемонстрировать концепцию: http://codepen.io/anon/pen/wgjJvO?editors=0110

Основные части пера:

// decorator/HOC that accepts a change observer function
// and then a component to wrap
function observeChanges(observer) {
  return function changeObserverFactory(WrappedComponent) {
    return class ChangeObserver extends React.Component {
      constructor(props) {
        super(props)
        this.state = {
          changed: false
        }
      }

      componentWillReceiveProps(nextProps) {
        if (typeof observer === 'function') {
          observer(this.props, nextProps) && this.setState({ changed: true })
        } else if (this.props !== nextProps) {
          this.setState({ changed: true })
        }
      }

      componentDidUpdate() {
        if (this.state.changed) {
          setTimeout(() => this.setState({ changed: false }), 300)
        }
      }

      render() {
        return <WrappedComponent {...this.props} changed={this.state.changed} />
      }
    }
  }
}

// a simple component for showing a paragraph
const Paragraph = ({ changed, text }) => (
  <p className={'${changed ? 'changed' : ''}'}>{text}</p>
)

// a decorated change observer version of the paragraph,
// with custom change observer function
const ChangingParagraph = observeChanges(
  (props, nextProps) => props.text !== nextProps.text
)(Paragraph)

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

Несколько примечаний стороны:

  • вам следует избегать выполнения обновлений состояния в componentDidUpdate,
    componentWillUpdate и shouldComponentUpdate.
    componentWillReceiveProps - это место для этого.

    Если вам нужно обновить состояние в ответ на изменение проформы, используйте componentWillReceiveProps()

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