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() }, }, ]}
问题原因分析
- 时序不匹配:首次加载时,图表
ready事件触发时dataArray还是初始模拟数据,后续真实数据加载完成后图表重渲染,但之前绑定的select监听器未更新,导致点击时访问旧数据或监听器失效;直接绑定select事件时,图表可能未完全初始化,监听器绑定失败。 - 监听器丢失:组件内多个
useState更新触发重渲染,Google Chart组件重新创建实例,但旧监听器未重新绑定,切标签页返回时触发图表重新初始化,监听器才被正确绑定。 - 状态冗余:
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
相关产品推荐
相关产品推荐

