You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React Google Chart Timeline首次加载点击事件失效问题求助

React中Google Chart Timeline点击事件首次加载无响应问题排查

问题现象

在React项目中使用Google Chart Timeline组件,通过chartEvents配置点击图表弹出模态框功能。首次加载/刷新页面时点击事件无响应,切换到应用内其他标签页再返回后,点击图表可正常触发事件。推测可能原因:

  • Timeline首次加载时数据未完全加载
  • 组件内useState使用过多

完整代码示例

Modal.setAppElement('#modal')

interface IItem {
  name: string,
  duration: string,
  id: string,
  startDate: Date,
  endDate: Date
}

export const options = {
  timeline: {
    groupByRowLabel: true,
    colorByRowLabel: true,
    showBarLabels: false,
    barLabelStyle: { fontSize: 18 },
  },
  tooltip: {
    isHtml: true
  }
}

export function NewGantt() {
  const [modalIsOpen, setIsOpen] = useState(false)
  const [selectedVacation, setselectedVacation] = useState({ fullname: '', duration: '', startDate: new Date(), endDate: new Date(), vacationId: '' })
  const [rows, setrows] = useState(['', '', '', new Date(), new Date(), null, 0, null])
  const [projects, setprojects] = useState<IProject[]>([])
  const [selected, setSelected] = useState(selectedOptions[0])
  const [dataArray, setDataArray] = useState([
    [
      { type: 'string', id: 'Name' },
      { type: 'string', id: 'Duration' },
      { type: 'string', role: 'tooltip', div: { html: true } },
      { type: 'string', id: 'style', role: 'style' },
      { type: 'date', id: 'Start' },
      { type: 'date', id: 'End' },
    ],
    [
      'User', '01.01.2022-02.01.2022', `
      <div style='padding: 10px'>
        <b>User:</b> user <br><hr>
        <div style='margin-top: 10px'><b>Vacation dates:</b> 01.01.2022-02.01.2022</div>
      </div>
    `, 'black', new Date('Sun Mar 12 2023 00:00:00 GMT+0700'), new Date('Sun Mar 13 2023 00:00:00 GMT+0700')
    ]
  ]) as any

  const { isLoading: isLoadingProjects, refetch: refetch } = useQuery(
    ['projects'],
    async () => {
      const res = await axios.get(`${SERVER_URI}/rest/workspace/pm/projects`, {
        headers: {
          Authorization: `${localStorage.getItem('JWT_Token')}`,
        },
      })
      if (res.data) {
        setprojects(res.data.map((el: IProject) => ({ ...el, isActive: false })))
      }
    },
    {
      refetchOnWindowFocus: false,
    },
  )

  function projectsButton(project: IProject) {
    const projectIndex = projects.findIndex(
      (pr) => pr.projectId === project.projectId,
    )
    const arr = [...projects]
    arr[projectIndex].isActive = !arr[projectIndex].isActive
    setprojects(arr)
  }

  useEffect(() => {
    const activeProjectIds = [...projects]
      .filter((pr) => pr.isActive)
      .map((pr) => pr.projectId)
      .join(',')

    if (activeProjectIds.length > 0) {
      requestOfStatus(selected.statusType, activeProjectIds)
    } else {
      requestOfStatus(selected.statusType, '')
    }
  }, [projects])
  const arr: any[][] = []

  useEffect(() => {
    const arrayObjects: any[] = [
      { type: 'string', id: 'Name' },
      { type: 'string', id: 'Duration' },
      { type: 'string', role: 'tooltip', div: { html: true } },
      { type: 'string', id: 'style', role: 'style' },
      { type: 'date', id: 'Start' },
      { type: 'date', id: 'End' },
    ]
    rows.forEach((vacation: any) => {
      const name = vacation[1]
      const startDate = new Date(vacation[2])
      const endDate = new Date(vacation[3])
      const duration = `${startDate.toLocaleDateString()} - ${endDate.toLocaleDateString()} + ${vacation[0].toString()}`
      const tooltip = `
    <div style='padding: 10px'>
      <b>Пользователь:</b> ${name}<br><hr>
      <div style='margin-top: 10px'><b>Dates of vacations:</b> ${startDate.toLocaleDateString()} - ${endDate.toLocaleDateString()}</div>
    </div>
    `
      let color: string

      if (vacation[7] === 'decisionRejected') {
        color = 'red'
      } else if (vacation[7] === 'noDecision') {
        color = 'blue'
      } else if (vacation[7] === 'decisionApproved') {
        color = 'green'
      } else {
        color = 'black'
      }

      const object = [name, duration, tooltip, color, startDate, endDate]
      arr.push(object)
    })

    const merged = [arrayObjects, ...arr]
    setDataArray(merged)
  }, [rows])

  const requestOfStatus = async (statusType: string, activeProjectIds: string) => {
    const requests = []
    const output: any[][] = []

    if (statusType === 'all') {
      requests.push(await axios.get(`${SERVER_URI}/rest/workspace/pm/vacations?stringProjectIds=${activeProjectIds}&approveType=noDecision`, {
        headers: {
          Authorization: `${localStorage.getItem('JWT_Token')}`,
        },
      }))
      requests.push(await axios.get(`${SERVER_URI}/rest/workspace/pm/vacations?stringProjectIds=${activeProjectIds}&approveType=decisionApproved`, {
        headers: {
          Authorization: `${localStorage.getItem('JWT_Token')}`,
        },
      }))
      requests.push(await axios.get(`${SERVER_URI}/rest/workspace/pm/vacations?stringProjectIds=${activeProjectIds}&approveType=decisionRejected`, {
        headers: {
          Authorization: `${localStorage.getItem('JWT_Token')}`,
        },
      }))

    } else {
      requests.push(await axios.get(`${SERVER_URI}/rest/workspace/pm/vacations?stringProjectIds=${activeProjectIds}&approveType=${statusType}`, {
        headers: {
          Authorization: `${localStorage.getItem('JWT_Token')}`,
        },
      }))
    }
    axios.all(requests).then(axios.spread((...res) => {
      const rows: any[][] = []
      for (let i = 0; i < res.length; i++) {
        const merged = [...res[i].data]
        const urls = res[i].config.url
        merged.forEach((vacation: any) => {
          const startDate = new Date(vacation.vacationStartDate)
          startDate.setDate(startDate.getDate() - 1)
          rows.push([
            vacation.vacationId, // index
            vacation.user.userDetails.fullName, // user fullname
            startDate, // start date
            new Date(vacation.vacationEndDate), // end date
            ++vacation.duration, // duration
            100, // progress
            null, // dependencies
            urls?.split('approveType=')[1].trim()
          ])
        })
      }
      const flags: never[] = [], l = rows?.length
      let i
      // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-ts-comment
      // @ts-ignore
      for (i = 0; i < l; i++) {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-ts-comment
        // @ts-ignore
        if (flags[rows[i][3]]) continue;
        // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-ts-comment
        // @ts-ignore
        flags[rows[i][3]] = true;
        // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-ts-comment
        // @ts-ignore
        output.push(rows[i]);
      }
      setrows(output)
    }))
  }
  const openModal = () => {
      setIsOpen(()=>{
        return true
      })
    }

  return (
    <>
      <Header />
      <div className='flex flex-col justify-center w-10/12 p-6 m-auto'>
        <div className='flex flex-row items-center'>
          <div className='flex flex-row'>
            {!isLoadingProjects &&
              projects &&
              (projects as [])?.map((project: IProject) => (
                <button
                  key={project.projectId}
                  onClick={() => {
                    projectsButton(project)
                  }}
                  className={`px-4 py-2 pt-0 mt-5 mr-5 font-bold text-white rounded hover:bg-gray-700 ${!project.isActive
                    ? 'bg-white border-2 text-gray-500 border-gray-500/50'
                    : 'text-white bg-gray-500'
                    }`}
                >
                  {project.projectName}
                </button>
              ))}
          </div>
          <ApproveTypeMenu onSelected={(item: { id: number, name: string, statusType: string }) => { setSelected(item) }} selected={selected} />
          <button className='px-4 py-2 pt-0 mt-5 mr-5 font-bold text-white rounded hover:bg-gray-700 bg-white border-2 text-gray-500 border-gray-500/50'>Применить фильтры</button>
        </div>
        <Chart
          chartType="Timeline"
          data={dataArray}
          width='100%'
          height="600px"
          options={options}
          chartLanguage='ru'
          chartEvents={[
            {
                  eventName: 'ready',
                  callback: (params) => {
                    const { chartWrapper, google } = params
                    const chart: any = chartWrapper.getChart()
                    google.visualization.events.addListener(chart, 'select', () => {
                        const [fullname, duration, , , startDate, endDate] = dataArray[chart.getSelection()[0].row + 1]
                        const vacationId = duration.split('+')[1].trim()
                        setselectedVacation({ fullname: `${fullname}`, duration: `${duration}`, startDate: startDate, endDate: endDate, vacationId: `${vacationId}` })
                        openModal()
                      })
                  },
                },
          ]}
          controls={[
            {
              controlType: 'CategoryFilter',
              options: {
                filterColumnIndex: 0,
                ui: {
                  allowTyping: true,
                  allowMultiple: true,
                  orientation: 'horizontal',
                  caption: 'Choose colleague',
                  showRangeValues: false,
                  label: 'Filter by colleagues',
                  labelSeparator: ' ',
                },
                filterColumnLabel: 'Column Label'
              },
              controlPosition: 'top',
            }
          ]}
        />
      </div>
      {selectedVacation.vacationId?.length > 0 && (
        <PMModal
          vacation={selectedVacation}
          isModalOpen={modalIsOpen}
          setIsModalOpen={setIsOpen}
          refetch={refetch}
        />
      )}
    </>
  );
}

无ready事件的chartEvents代码示例

chartEvents={[
{
                  eventName: 'select',
                  callback: (params) => {
                    const { chartWrapper, google } = params
                    const chart: any = chartWrapper.getChart()
                    const [fullname, duration, , , startDate, endDate] = dataArray[chart.getSelection()[0].row + 1]
                    const vacationId = duration.split('+')[1].trim()
                    setselectedVacation({ fullname: `${fullname}`, duration: `${duration}`, startDate: startDate, endDate: endDate, vacationId: `${vacationId}` })
                    openModal()
                  },
                },
]}

问题原因分析

  1. 时序不匹配:首次加载时,图表ready事件触发时dataArray还是初始模拟数据,后续真实数据加载完成后图表重渲染,但之前绑定的select监听器未更新,导致点击时访问旧数据或监听器失效;直接绑定select事件时,图表可能未完全初始化,监听器绑定失败。
  2. 监听器丢失:组件内多个useState更新触发重渲染,Google Chart组件重新创建实例,但旧监听器未重新绑定,切标签页返回时触发图表重新初始化,监听器才被正确绑定。
  3. 状态冗余:rows和dataArray为冗余状态,额外的状态更新增加了重渲染次数,加剧了监听器绑定的时序问题。

解决建议

方案1:基于数据更新重新绑定监听器

给Chart组件添加ref,利用useEffect监听dataArray变化,每次数据更新时重新绑定监听器:

import { useRef, useEffect } from 'react';

export function NewGantt() {
  const chartRef = useRef(null);

  useEffect(() => {
    if (!chartRef.current?.chartWrapper) return;
    const chart = chartRef.current.chartWrapper.getChart();
    // 移除旧监听器避免重复绑定
    google.visualization.events.removeAllListeners(chart);
    // 绑定最新监听器
    google.visualization.events.addListener(chart, 'select', () => {
      const selection = chart.getSelection();
      if (selection.length === 0) return;
      const [fullname, duration, , , startDate, endDate] = dataArray[selection[0].row + 1];
      const vacationId = duration.split('+')[1].trim();
      setselectedVacation({ fullname, duration, startDate, endDate, vacationId });
      openModal();
    });
  }, [dataArray]);

  // 给Chart组件绑定ref
  return (
    // ...其他代码
    <Chart
      ref={chartRef}
      // ...其他props
    />
  )
}

方案2:优化chartEvents绑定逻辑

用useCallback包裹回调函数,确保回调能获取最新状态,并在ready事件中每次都移除旧监听器再绑定新的:

import { useCallback } from 'react';

export function NewGantt() {
  const handleSelect = useCallback((chart) => {
    const selection = chart.getSelection();
    if (selection.length === 0) return;
    const [fullname, duration, , , startDate, endDate] = dataArray[selection[0].row + 1];
    const vacationId = duration.split('+')[1].trim();
    setselectedVacation({ fullname, duration, startDate, endDate, vacationId });
    openModal();
  }, [dataArray]);

  return (
    // ...其他代码
    <Chart
      chartEvents={[
        {
          eventName: 'ready',
          callback: (params) => {
            const { chartWrapper, google } = params;
            const chart = chartWrapper.getChart();
            google.visualization.events.removeAllListeners(chart);
            google.visualization.events.addListener(chart, 'select', () => handleSelect(chart));
          },
        },
      ]}
      // ...其他props
    />
  )
}

方案3:移除冗余状态,减少重渲染

删除rows状态,直接在requestOfStatus中生成dataArray,减少不必要的状态更新:

export function NewGantt() {
  // 移除rows状态
  const [dataArray, setDataArray] = useState<any[][]>([]);

  const requestOfStatus = async (statusType: string, activeProjectIds: string) => {
    // ...原有请求逻辑
    axios.all(requests).then(axios.spread((...res) => {
      const rows: any[][] = [];
      // ...原有数据处理逻辑
      // 直接生成dataArray
      const arrayObjects = [
        { type: 'string', id: 'Name' },
        { type: 'string', id: 'Duration' },
        { type: 'string', role: 'tooltip', div: { html: true } },
        { type
相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.16 00:41:38