本文档详细介绍了在React Native项目中接入ECharts图表的完整步骤,包括依赖安装、组件配置、数据获取、图表渲染等各个环节。
确保已安装以下工具:
# 安装图表核心库
npm install echarts@^5.6.0
# 安装React Native图表渲染器
npm install @wuba/react-native-echarts@^2.0.3
# 安装SVG支持(图表渲染需要)
npm install react-native-svg@^15.8.0
# 安装日期处理库(用于图表时间轴格式化)
npm install date-fns@^4.1.0
这个过程是这样的:
# 如果需要健康数据(如心率图表示例)
npm install react-native-health@^1.19.0
# 如果需要更多图表类型
npm install @types/echarts
依赖包 | 版本 | 作用 |
---|---|---|
echarts |
^5.6.0 | 图表核心库,提供丰富的图表类型 |
@wuba/react-native-echarts |
^2.0.3 | React Native适配的ECharts渲染器 |
react-native-svg |
^15.8.0 | SVG渲染支持,图表显示必需 |
date-fns |
^4.1.0 | 日期处理工具,用于时间轴格式化 |
创建文件:app/components/HeartRateChart.tsx
import { useRef, useEffect, useMemo } from "react"
import { StyleProp, View, ViewStyle } from "react-native"
import { echarts } from "../utils/echarts" // 导入集中的 echarts 实例
import SvgChart from "@wuba/react-native-echarts/svgChart"
import { useHealthData } from "../hooks/useHealthData"
import { HealthValue } from "react-native-health"
import { format } from "date-fns"
import { ECharts } from "echarts/core"
// 不再需要在此处注册组件,已在 app/utils/echarts.ts 中集中处理
interface HeartRateChartProps {
style?: StyleProp<ViewStyle>
}
const CHART_HEIGHT = 300
const CHART_WIDTH = 350
export function HeartRateChart(props: HeartRateChartProps) {
const { style } = props
const chartRef = useRef<any>(null)
const { heartRateSamples, fetchHeartRateSamples, permissionsGranted } = useHealthData({
requestPermissions: true,
autoFetch: false,
})
// 数据获取
useEffect(() => {
if (permissionsGranted) {
const endDate = new Date()
const startDate = new Date()
startDate.setDate(endDate.getDate() - 1)
fetchHeartRateSamples(startDate, endDate)
}
}, [permissionsGranted, fetchHeartRateSamples])
// 图表配置
const chartOption = useMemo(() => {
if (!heartRateSamples || heartRateSamples.length === 0) {
return {}
}
const data = heartRateSamples.map((sample: HealthValue) => [
new Date(sample.startDate),
sample.value,
])
return {
tooltip: {
trigger: "axis",
formatter: (params: any) => {
const param = params[0]
const date = new Date(param.axisValue)
const value = param.data[1]
return `${format(date, "MM-dd HH:mm")}
心率: ${value}`
},
},
xAxis: {
type: "time",
axisLabel: {
formatter: (value: number) => {
return format(new Date(value), "HH:mm")
},
},
},
yAxis: {
type: "value",
name: "心率 (bpm)",
min: (value: { min: number }) => Math.floor(value.min / 10) * 10,
},
series: [
{
data,
type: "line",
smooth: true,
showSymbol: false,
lineStyle: {
width: 2,
},
},
],
grid: {
left: "12%",
right: "5%",
bottom: "10%",
top: "10%",
},
}
}, [heartRateSamples])
// 图表初始化和更新
useEffect(() => {
let chartInstance: ECharts | undefined
if (chartRef.current && Object.keys(chartOption).length > 0) {
chartInstance = echarts.init(chartRef.current, "light", {
renderer: "svg",
width: CHART_WIDTH,
height: CHART_HEIGHT,
})
chartInstance.setOption(chartOption)
}
return () => {
chartInstance?.dispose()
}
}, [chartOption])
return (
<View style={style}>
<SvgChart ref={chartRef} />
</View>
)
}
在 app/components/index.ts
中添加导出:
export * from "./HeartRateChart"
创建文件:app/hooks/useHealthData.ts
import { useState, useCallback, useEffect } from "react"
import AppleHealthKit, { HealthKitPermissions, HealthValue } from "react-native-health"
interface HealthDataState {
stepCount: number | null
heartRateSamples: HealthValue[]
isLoading: boolean
error: string | null
permissionsGranted: boolean
}
interface UseHealthDataOptions {
requestPermissions?: boolean
autoFetch?: boolean
}
export const useHealthData = (options: UseHealthDataOptions = {}) => {
const { requestPermissions = true, autoFetch = true } = options
const [healthData, setHealthData] = useState<HealthDataState>({
stepCount: null,
heartRateSamples: [],
isLoading: false,
error: null,
permissionsGranted: false,
})
// 初始化 HealthKit 并请求权限
const initializeHealthKit = useCallback(async () => {
if (!requestPermissions) return
setHealthData((prev) => ({ ...prev, isLoading: true, error: null }))
const permissions = {
permissions: {
read: [
AppleHealthKit.Constants.Permissions.Steps,
AppleHealthKit.Constants.Permissions.HeartRate,
AppleHealthKit.Constants.Permissions.StepCount,
],
write: [],
},
} as HealthKitPermissions
return new Promise<void>((resolve, reject) => {
AppleHealthKit.initHealthKit(permissions, (error: string) => {
if (error) {
const errorMessage = `无法授予 HealthKit 权限: ${error}`
console.error("HealthKit 权限请求失败:", errorMessage)
setHealthData((prev) => ({
...prev,
isLoading: false,
error: errorMessage,
permissionsGranted: false,
}))
reject(new Error(errorMessage))
return
}
setHealthData((prev) => ({
...prev,
isLoading: false,
permissionsGranted: true,
}))
resolve()
})
})
}, [requestPermissions])
// 获取心率样本
const fetchHeartRateSamples = useCallback(
async (startDate?: Date, endDate?: Date) => {
if (!healthData.permissionsGranted) {
const errorMsg = "HealthKit 权限未授予,无法获取心率数据"
console.error(errorMsg)
throw new Error(errorMsg)
}
setHealthData((prev) => ({ ...prev, isLoading: true, error: null }))
const heartRateOptions = {
startDate: (
startDate || new Date(new Date().getTime() - 24 * 60 * 60 * 1000)
).toISOString(),
endDate: (endDate || new Date()).toISOString(),
}
return new Promise<HealthValue[]>((resolve, reject) => {
AppleHealthKit.getHeartRateSamples(
heartRateOptions,
(err: string, results: HealthValue[]) => {
if (err) {
const errorMessage = `获取心率样本时出错: ${err}`
console.error(errorMessage)
setHealthData((prev) => ({
...prev,
isLoading: false,
error: errorMessage,
}))
reject(new Error(errorMessage))
return
}
setHealthData((prev) => ({
...prev,
heartRateSamples: results,
isLoading: false,
}))
resolve(results)
},
)
})
},
[healthData.permissionsGranted],
)
// 获取所有健康数据
const fetchAllHealthData = useCallback(async () => {
if (!healthData.permissionsGranted) {
return
}
try {
await fetchHeartRateSamples()
} catch (error) {
console.error("获取健康数据时出错:", error)
}
}, [fetchHeartRateSamples, healthData.permissionsGranted])
// 清除错误
const clearError = useCallback(() => {
setHealthData((prev) => ({ ...prev, error: null }))
}, [])
// 初始化
useEffect(() => {
if (requestPermissions) {
initializeHealthKit()
.then(() => {
setTimeout(() => {
if (autoFetch) {
fetchAllHealthData()
}
}, 100)
})
.catch((error) => {
console.error("初始化 HealthKit 失败:", error)
})
}
}, [requestPermissions, autoFetch, initializeHealthKit, fetchAllHealthData])
return {
// 状态
...healthData,
// 方法
initializeHealthKit,
fetchHeartRateSamples,
fetchAllHealthData,
clearError,
}
}
在 app/hooks/index.ts
中添加导出:
export * from "./useHealthData"
const baseChartOption = {
// 提示框配置
tooltip: {
trigger: "axis",
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderColor: "rgba(255, 255, 255, 0.2)",
textStyle: {
color: "#fff",
},
},
// 网格配置
grid: {
left: "12%",
right: "5%",
bottom: "10%",
top: "10%",
containLabel: true,
},
// 动画配置
animation: true,
animationDuration: 1000,
animationEasing: "cubicOut",
}
const timeAxisConfig = {
xAxis: {
type: "time",
axisLabel: {
formatter: (value: number) => format(new Date(value), "HH:mm"),
color: "#666",
fontSize: 12,
},
axisLine: {
lineStyle: {
color: "#ddd",
},
},
splitLine: {
show: true,
lineStyle: {
color: "#f0f0f0",
type: "dashed",
},
},
},
}
const valueAxisConfig = {
yAxis: {
type: "value",
name: "心率 (bpm)",
nameTextStyle: {
color: "#666",
fontSize: 12,
},
axisLabel: {
color: "#666",
fontSize: 12,
},
axisLine: {
lineStyle: {
color: "#ddd",
},
},
splitLine: {
show: true,
lineStyle: {
color: "#f0f0f0",
type: "dashed",
},
},
min: (value: { min: number }) => Math.floor(value.min / 10) * 10,
},
}
const seriesConfig = {
series: [
{
data: chartData,
type: "line",
smooth: true,
showSymbol: false,
lineStyle: {
width: 2,
color: "#ff6b6b",
},
areaStyle: {
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: "rgba(255, 107, 107, 0.3)" },
{ offset: 1, color: "rgba(255, 107, 107, 0.1)" },
],
},
},
},
],
}
创建文件:app/screens/DemoShowroomScreen/demos/DemoHeartRateChart.tsx
import { HeartRateChart } from "../../../components"
import { View, StyleSheet } from "react-native"
import { Text } from "../../../components"
export const DemoHeartRateChart = () => {
return (
<View style={styles.container}>
<Text preset="heading" style={styles.title}>
24小时心率图表
</Text>
<HeartRateChart />
</View>
)
}
const styles = StyleSheet.create({
container: {
alignItems: "center",
padding: 20,
},
title: {
marginBottom: 20,
textAlign: "center",
},
})
在 app/screens/DemoShowroomScreen/DemoShowroomScreen.tsx
中:
import { FC } from "react"
import { Screen } from "../../components"
import { DemoHeartRateChart } from "./demos"
export const DemoShowroomScreen: FC = function DemoShowroomScreen() {
return (
<Screen preset="fixed" safeAreaEdges={["top"]}>
<DemoHeartRateChart />
</Screen>
)
}
在 app/screens/DemoShowroomScreen/demos/index.ts
中:
export * from "./DemoHeartRateChart"
在 app/i18n/zh.ts
中添加:
const zh = {
// ... 其他翻译
demoShowroomScreen: {
// ... 其他翻译
demoHeartRateChart: "心率图表",
demoHeartRateChartDesc: "一个显示心率随时间变化的图表。",
},
}
在 app/i18n/en.ts
中添加:
const en = {
// ... 其他翻译
demoShowroomScreen: {
// ... 其他翻译
demoHeartRateChart: "Heart Rate Chart",
demoHeartRateChartDesc: "A chart showing heart rate over time.",
},
}
const chartOption = useMemo(() => {
// 图表配置逻辑
}, [data, theme])
useEffect(() => {
let chartInstance: ECharts | undefined
if (chartRef.current && data.length > 0) {
chartInstance = echarts.init(chartRef.current, "light", {
renderer: "svg",
width: CHART_WIDTH,
height: CHART_HEIGHT,
})
chartInstance.setOption(chartOption)
}
return () => {
chartInstance?.dispose()
}
}, [chartOption])
const MemoizedChart = React.memo(HeartRateChart)
const chartOption = useMemo(() => {
if (!data || data.length === 0) {
return {}
}
// 图表配置
}, [data])
useEffect(() => {
if (permissionsGranted) {
fetchData()
}
}, [permissionsGranted])
{isLoading && <LoadingSpinner />}
{error && <ErrorMessage error={error} onRetry={fetchData} />}
const [chartSize, setChartSize] = useState({ width: 350, height: 300 })
useEffect(() => {
const updateSize = () => {
const { width } = Dimensions.get('window')
setChartSize({
width: width - 40, // 减去padding
height: 300,
})
}
updateSize()
Dimensions.addEventListener('change', updateSize)
return () => {
Dimensions.removeEventListener('change', updateSize)
}
}, [])
const chartOption = useMemo(() => ({
// ... 其他配置
backgroundColor: theme.colors.background,
textStyle: {
color: theme.colors.text,
},
}), [theme, data])
为了避免在多个组件中重复初始化ECharts模块导致 [ReferenceError: Property 'document' doesn't exist]
等问题,建议创建一个中心化的echarts.ts
文件来统一管理ECharts实例和模块注册。
创建 app/utils/echarts.ts
:
import * as echarts from "echarts/core"
import { SVGRenderer } from "@wuba/react-native-echarts/svgChart"
import {
LineChart,
BarChart,
GaugeChart,
CustomChart,
} from "echarts/charts"
import {
GridComponent,
TooltipComponent,
LegendComponent,
} from "echarts/components"
// 注册所有需要的组件
echarts.use([
SVGRenderer,
LineChart,
BarChart,
GaugeChart,
CustomChart,
GridComponent,
TooltipComponent,
LegendComponent,
])
export { echarts }
在组件中使用:
import { echarts } from "../utils/echarts" // 导入集中的 echarts 实例
// ...
// 不再需要 echarts.use(...)
问题:图表组件渲染但图表内容不显示
解决方案:
LineChart
)和组件(如 TooltipComponent
)都已在 app/utils/echarts.ts
中导入并使用 echarts.use()
注册。这是最常见的原因,尤其是在添加新图表类型后。
组件或其父容器具有明确的 width
和 height
样式。没有尺寸,图表将无法渲染。setOption
的配置对象中的 series.data
格式是否符合 ECharts 的要求。useEffect
中 echarts.init
的部分添加 try...catch
来捕获任何初始化时抛出的错误。示例:
// 1. 检查 app/utils/echarts.ts
echarts.use([
SVGRenderer,
LineChart, // 确保已添加
GridComponent,
TooltipComponent,
])
// 2. 在组件中设置明确的容器尺寸
<SvgChart ref={chartRef} style={{ width: 350, height: 300 }} />
问题:数据更新后图表没有重新渲染
解决方案:
const chartOption = useMemo(() => {
// 图表配置
}, [data, theme]) // 确保包含所有依赖
useEffect(() => {
if (chartInstance && chartOption) {
chartInstance.setOption(chartOption, true) // 第二个参数为true表示完全替换
}
}, [chartOption])
问题:图表渲染导致性能问题
解决方案:
// 数据采样
const sampledData = data.length > 1000
? data.filter((_, index) => index % 10 === 0)
: data
问题:组件卸载后图表实例未正确清理
解决方案:
useEffect(() => {
let chartInstance: ECharts | undefined
if (chartRef.current) {
chartInstance = echarts.init(chartRef.current)
chartInstance.setOption(chartOption)
}
return () => {
if (chartInstance) {
chartInstance.dispose()
}
}
}, [chartOption])
通过以上步骤,您可以在React Native项目中成功接入ECharts图表。关键要点包括:
这个解决方案提供了一个完整的图表集成框架,可以根据具体需求进行扩展和定制。