p5.js 和 React:如何在更新组件的道具之一时更新组件内的草图

p5.js and React: how to update a sketch inside a component when one of the component's props is updated

我正在开发一个集成 p5.js 和 React 的项目。我的项目由 App.js 和两个子组件组成:View1.js 和 View2.js。我想将信息从 View1 传递到 View2,并将更改的信息反映在 View 2 的草图中。我的问题是,虽然我可以将数据从 View 1 传递到 View 2,但我不知道如何使用更新草图新值。

我认为部分问题可能是因为View 2组件中的草图是实例模式,所以一旦初始化,它就不会改变。本质上,我正在寻找一种方法来 reinitialize/refresh 组件内部的草图,每当组件的一个道具的值发生变化时,草图就会使用最新的值。我知道 p5-react-wrapper 确实有办法处理这个问题,但理想情况下,我想只用 p5 库来做到这一点。

有人知道怎么做吗?我在下面包含了一个我想做的事情的例子(尽管它比我的实际项目要复杂一些)。

App.js

import React, { useState } from 'react';
import View1 from "./components/View1.js";
import View2 from './components/View2.js';

function App() {

  const [data, setData] = useState(0);

  const firstViewToParent = (num) => {setData(num);}

  return (
    <div className="App">
      <View1
        firstViewToParent={firstViewToParent}
      />
      <View2
        dataFromSibling={data}
      />

    </div>
  );
}

export default App;

View1.js

import React, { useEffect } from "react";
import p5 from 'p5';

const View1 = props => {

    const Sketch = (p) => {

        p.setup = () => {
            p.createCanvas(400, 400);
        }

        p.draw = () => {
            p.background(0);
        }

        p.mouseClicked = () => {
            // Passing information from the child up to the parent
            props.firstViewToParent(p.mouseX);

        }
    }

    useEffect(() => {
        new p5(Sketch);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    return (
        <></>
    );
}

export default View1;

View2.js

import React, { useEffect } from "react";
import p5 from 'p5';

const View2 = props => {

    const Sketch = (p) => {

        p.setup = () => {
            p.createCanvas(400, 400);
        }

        p.draw = () => {
            p.background(255, 0, 0);
            // Want to be able to access updated information from sibling 
           // component inside the sketch
            p.print(props.dataFromSibling);
        }
    }

    useEffect(() => {
        new p5(Sketch);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    return (
        <></>
    );
}

export default View2;
  1. 您的“视图”组件需要在应插入 p5.js canvas 的位置呈现容器元素。
  2. 因为您将空数组传递给 useEffect 函数调用,所以您的 View2 效果没有得到更新。
  3. 传递给您的组件函数的 props 对象 从未发生变化。props 更改时传递一个新的 props 对象对组件函数的新调用。
  4. 当effects需要更新的时候会调用它们的cleanup函数,然后再调用初始化函数。因此,在清理过程中删除草图很重要。事实上,你应该总是这样做,因为如果你的整个组件被删除,你想让 p5.js 知道在它的 canvas 元素从 DOM.
  5. 中删除之前

这是一个工作示例:

// This is in place of an import statement
const { useRef, useState, useEffect } = React;

const View1 = props => {
  const containerRef = useRef();
  
  const Sketch = (p) => {
    p.setup = () => {
      p.print("View1 Initializing");
      p.createCanvas(200, 100);
    }

    p.draw = () => {
      p.background(150);
    }

    p.mouseClicked = () => {
      p.print('click!');
      // Passing information from the child up to the parent
      props.firstViewToParent(p.map(p.mouseX, 0, p.width, 0, 255));
    }
  }

  useEffect(
    () => {
      // make sure the p5.js canvas is a child of the component in the DOM
      new p5(Sketch, containerRef.current);
    },
    // This empty list tells React that this effect never needs to get re-rendered!
    []
  );

  // Note: you're going to want to defined an element to contain the p5.js canvas
  return (
    <div ref={containerRef}></div>
  );
}

const View2 = props => {
  console.log('view2');
  const containerRef = useRef();
  
  const Sketch = (p) => {
    p.setup = () => {
      p.print("View2 Initializing");
      p.createCanvas(200, 100);
    }

    p.draw = () => {
      p.background(props.dataFromSibling);
    }
  }

  useEffect(
    () => {
      let inst = new p5(Sketch, containerRef.current);
      
      // Cleanup function! Without this the new p5.js sketches
      // generated with each click will just appear one after the other.
      return () => inst.remove();
    },
    // Let React know that this effect needs re-rendering when the dataFromSibling prop changes
    [props.dataFromSibling]
  );

  return (
    <div ref={containerRef}></div>
  );
}

function App() {
  const [data, setData] = useState(0);

  const firstViewToParent = (num) => {
    console.log('handling firstViewToParent callback');
    setData(num);
  };

  return (
    <div className="App">
      <View1 firstViewToParent={firstViewToParent}/>
      <View2 dataFromSibling={data} />
    </div>
  );
}

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);
.App {
  display: flex;
  flex-direction: row;
}
<html>

<head>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/p5@1.4.0/lib/p5.js"></script>
</head>

<body>
  <div id="root"></div>
</body>

</html>

附录:更新而不删除

所以我很好奇:如果您想更新现有的 p5.js 草图,该草图是使用 useEffect() 创建的而不用 删除并重新创建整个 p5.js素描。好吧,免责声明:我不是 ReactJS 专家,但我认为答案是肯定的。基本上您需要执行以下操作:

  1. 创建一个新的状态 variable/set 方法来存储和设置草图本身。
  2. 在创建草图的 useEffect 调用中:
    1. 仅在尚不存在时创建草图
    2. 创建草图时,使用set方法将其存储在状态变量中。
    3. 不要return清理函数
  3. 添加另一个效果,当更新存储草图引用的状态变量时更新
    1. 这个效果不应该创建任何东西,它应该只是return一个清理函数
    2. 这个清理函数应该删除草图,如果它存在(使用上述状态变量)
  4. 在您的草图创建函数中,创建一个局部变量来存储来自 props
  5. 的初始输入数据
  6. 向草图对象添加一个函数(通过分配 p.updateFn = ...)来更新所述局部变量
  7. 在创建草图的 useEffect 调用中,如果草图已经存在,则使用来自 props 的新值调用所述更新函数。

我知道有点复杂,但这里有一个例子:

// This is in place of an import statement
const { useRef, useState, useEffect } = React;

const View1 = props => {
  const containerRef = useRef();
  
  const Sketch = (p) => {
    p.setup = () => {
      p.print("View1 Initializing");
      p.createCanvas(200, 100);
    }

    p.draw = () => {
      p.background(150);
    }

    p.mouseClicked = (e) => {
      // It turns out that p5.js listens for clicks anywhere on the page!
      if (e.target.parentElement === containerRef.current) {
        p.print('click!');
        // Passing information from the child up to the parent
        props.firstViewToParent(p.map(p.mouseX, 0, p.width, 0, 255));
      }
    }
  }

  useEffect(
    () => {
      // make sure the p5.js canvas is a child of the component in the DOM
      let sketch = new p5(Sketch, containerRef.current);
      
      return () => sketch.remove();
    },
    // This empty list tells React that this effect never needs to get re-rendered!
    []
  );

  // Note: you're going to want to defined an element to contain the p5.js canvas
  return (
    <div ref={containerRef}></div>
  );
}

const View2 = props => {
  console.log('view2');
  const containerRef = useRef();
  const [sketch, setSketch] = useState(undefined);
  
  const Sketch = (p) => {
    let bgColor = props.dataFromSibling;
    p.setup = () => {
      p.print("View2 Initializing");
      p.createCanvas(200, 100);
    }

    p.draw = () => {
      p.background(bgColor);
    }
    
    p.updateBackgroundColor = function(value) {
      bgColor = value;
    }
  }

  useEffect(
    () => {
      if (!sketch) {
        // Initialize sketch
        let inst = new p5(Sketch, containerRef.current);
      
        setSketch(inst);
      } else {
        // Update sketch
        sketch.updateBackgroundColor(props.dataFromSibling);
      }
      
      // We cannot return a cleanup function here, be cause it would run every time the dataFromSibling prop changed
    },
    // Let React know that this effect needs re-rendering when the dataFromSibling prop changes
    [props.dataFromSibling]
  );
  
  useEffect(
    () => {
      // This effect is only responsible for cleaning up after the previous one 
      return () => {
        if (sketch) {
          console.log('removing sketch!');
          // Removing p5.js sketch because the component is un-mounting
          sketch.remove();
        }
      };
    },
    // This effect needs to be re-initialized *after* the sketch gets created
    [sketch]
  );

  return (
    <div ref={containerRef}></div>
  );
}

function App() {
  const [data, setData] = useState(0);
  const [showSketch, setShowSketch] = useState(true);

  const firstViewToParent = (num) => {
    console.log('handling firstViewToParent callback');
    setData(num);
  };

  return (
    <div className="App">
      <View1 firstViewToParent={firstViewToParent}/>
      
      {showSketch && <View2 dataFromSibling={data} />}
      <button onClick={() => setShowSketch(showSketch ? false : true )}>
        Toggle
      </button>
    </div>
  );
}

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);
.App {
  display: flex;
  flex-direction: row;
}
<html>

<head>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/p5@1.4.0/lib/p5.js"></script>
</head>

<body>
  <div id="root"></div>
</body>

</html>

如果这违反了任何 ReactJS 最佳实践或在某些情况下会被破坏,希望比我更了解 React 的人会插话。

附录 #2:useLayoutEffect

我最近了解到,这种向 DOM 添加新元素的效果通常应使用 useLayoutEffect 而不是 useEffect 进行初始化。这是因为 useEffect 与 DOM 的更新并行运行并且不会阻止渲染,而 useLayoutEffect 会阻止渲染。因此,当您修改 useEffect 中的 DOM(通过告诉 p5.js 创建一个 canvas 元素)时,可能会导致页面中的对象闪烁或突然从它们所在的位置移动初步定位。一旦我有更多时间对此进行测试,我将更新上面的代码示例。