Я работаю над проектом Laravel, где использую Alpine.js для интерактивности и Chart.js для визуализации данных. Моя установка отлично работает с Alpine.js 2.8.2, но при обновлении до Alpine.js 3.x возникает следующая ошибка:
chart.js:19 Uncaught TypeError: Cannot read properties of null (reading 'save')
at Ie (chart.js:19:18331)
at An._drawDataset (chart.js:19:98188)
at An._drawDatasets (chart.js:19:97819)
at An.draw (chart.js:19:97350)
at chart.js:13:6948
at Map.forEach (<anonymous>)
at xt._update (chart.js:13:6727)
at chart.js:13:6620
Вот соответствующая часть моего шаблона Blade с инициализацией JavaScript:
@extends('admin.layouts.app')
@section('vendor-styles')
<link rel = "stylesheet" href = "{{ asset('plugins/flatpickr/flatpickr.min.css') }}">
@endsection
@section('breadcrumbs')
<li>
<a href = "#" class = "text-gray-500 hover:text-gray-700">Dashboard</a>
</li>
@endsection
@section('content')
<div x-data = "dashboard()" x-init = "init()">
<!-- Time Range Filter -->
<div class = "bg-white p-4 rounded shadow mb-4">
<div class = "flex space-x-4 mb-4">
<button @click = "timeRange = 'today'; customRange = false"
:class = "{ 'bg-blue-600 text-white': timeRange === 'today', 'bg-gray-200 text-gray-800': timeRange !== 'today' }"
class = "p-2 rounded">Hôm nay
</button>
<button @click = "timeRange = 'thisWeek'; customRange = false"
:class = "{ 'bg-blue-600 text-white': timeRange === 'thisWeek', 'bg-gray-200 text-gray-800': timeRange !== 'thisWeek' }"
class = "p-2 rounded">Tuần này
</button>
<button @click = "timeRange = 'thisMonth'; customRange = false"
:class = "{ 'bg-blue-600 text-white': timeRange === 'thisMonth', 'bg-gray-200 text-gray-800': timeRange !== 'thisMonth' }"
class = "p-2 rounded">Tháng này
</button>
<button @click = "timeRange = 'thisYear'; customRange = false"
:class = "{ 'bg-blue-600 text-white': timeRange === 'thisYear', 'bg-gray-200 text-gray-800': timeRange !== 'thisYear' }"
class = "p-2 rounded">Năm này
</button>
<button @click = "timeRange = 'custom'; customRange = true"
:class = "{ 'bg-blue-600 text-white': timeRange === 'custom', 'bg-gray-200 text-gray-800': timeRange !== 'custom' }"
class = "p-2 rounded">Khoảng thời gian
</button>
</div>
<div x-show = "customRange" class = "flex space-x-4">
<input x-ref = "datepicker" class = "p-2 bg-gray-200 rounded" placeholder = "Chọn khoảng thời gian">
</div>
</div>
<!-- Sales Overview -->
<div class = "bg-white p-4 rounded shadow mb-4">
<h2 class = "text-xl font-semibold mb-2">Tổng quan</h2>
<div class = "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class = "p-6 bg-green-100 rounded-lg shadow-md flex items-center">
<div class = "flex-1">
<div class = "text-lg font-medium text-green-800 flex items-center">
<i class = "fas fa-dollar-sign mr-2"></i>
Tổng doanh thu
</div>
<div class = "text-3xl font-bold text-green-900" x-text = "totalSales.revenue"></div>
</div>
{{-- <div class = "text-green-600 flex items-center">--}}
{{-- <i class = "fas fa-arrow-up text-2xl"></i>--}}
{{-- <span class = "ml-1 text-xl font-semibold">15%</span>--}}
{{-- </div>--}}
</div>
<div class = "p-6 bg-blue-100 rounded-lg shadow-md flex items-center">
<div class = "flex-1">
<div class = "text-lg font-medium text-green-800 flex items-center">
<i class = "fas fa-dollar-sign mr-2"></i>
Tổng chi phí
</div>
<div class = "text-3xl font-bold text-green-900" x-text = "totalSales.cost"></div>
</div>
{{-- <div class = "text-green-600 flex items-center">--}}
{{-- <i class = "fas fa-arrow-up text-2xl"></i>--}}
{{-- <span class = "ml-1 text-xl font-semibold">15%</span>--}}
{{-- </div>--}}
</div>
<div class = "p-6 bg-yellow-100 rounded-lg shadow-md flex items-center">
<div class = "flex-1">
<div class = "text-lg font-medium text-green-800 flex items-center">
<i class = "fas fa-dollar-sign mr-2"></i>
Tổng lợi nhuận
</div>
<div class = "text-3xl font-bold text-green-900" x-text = "totalSales.profit"></div>
</div>
{{-- <div class = "text-green-600 flex items-center">--}}
{{-- <i class = "fas fa-arrow-up text-2xl"></i>--}}
{{-- <span class = "ml-1 text-xl font-semibold">15%</span>--}}
{{-- </div>--}}
</div>
</div>
</div>
<!-- Sales Chart -->
<div class = "bg-white p-4 rounded shadow mb-4">
<h2 class = "text-xl font-semibold mb-2">Biểu Đồ Kinh Doanh</h2>
<canvas id = "salesChart" width = "400" height = "200"></canvas>
</div>
<div class = "grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Sales by Category Chart -->
<div class = "bg-white p-4 rounded shadow mb-4">
<h2 class = "text-xl font-semibold mb-2 text-center">Doanh Thu Theo Danh Mục Sản Phẩm</h2>
<canvas id = "categoryChart" width = "400" height = "200"></canvas>
</div>
<!-- Sales by Brand Chart -->
<div class = "bg-white p-4 rounded shadow mb-4">
<h2 class = "text-xl font-semibold mb-2 text-center">Doanh Thu Theo Hãng Sản Xuất</h2>
<canvas id = "brandChart" width = "400" height = "200"></canvas>
</div>
</div>
<!-- Recent Orders -->
<div class = "bg-white p-4 rounded shadow mb-4">
<h2 class = "text-xl font-semibold mb-2">Đơn Hàng Gần Đây</h2>
<table class = "w-full text-left border-collapse">
<thead>
<tr>
<th class = "p-2 border-b">Mã Đơn Hàng</th>
<th class = "p-2 border-b">Khách Hàng</th>
<th class = "p-2 border-b">Trạng Thái</th>
<th class = "p-2 border-b">Ngày Đặt</th>
<th class = "p-2 border-b">Tổng tiền</th>
</tr>
</thead>
<tbody>
<template x-for = "order in recentOrders" :key = "order.id">
<tr>
<td class = "p-2 border-b">
<a :href = "order?.url" x-text = "order?.order_code" class = "text-blue-600"></a>
</td>
<td class = "p-2 border-b" x-text = "order?.customer.name"></td>
<td class = "p-2 border-b" x-text = "order?.status"></td>
<td class = "p-2 border-b" x-text = "order?.formatted_created_at"></td>
<td class = "p-2 border-b" x-text = "order?.amount"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class = "grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Top Selling Products -->
<div class = "bg-white p-4 rounded shadow mb-4">
<h2 class = "text-xl font-semibold mb-2">Top Sản Phẩm Bán Chạy</h2>
<ul>
<template x-for = "product in topProducts" :key = "product.id">
<li class = "border-b py-2">
<span x-text = "product.name"></span> - <span x-text = "product.total_sales"></span>
</li>
</template>
</ul>
</div>
<!-- Inventory -->
<div class = "bg-white p-4 rounded shadow mb-4">
<h2 class = "text-xl font-semibold mb-2">Sản Phẩm Sắp Hết Hàng</h2>
<table class = "w-full text-left border-collapse">
<thead>
<tr>
<th class = "p-2 border-b">Tên Sản Phẩm</th>
<th class = "p-2 border-b">Màu Sắc</th>
<th class = "p-2 border-b">Số Lượng Còn Lại</th>
</tr>
</thead>
<tbody>
<template x-for = "product in lowStockProducts" :key = "product.id">
<tr>
<td class = "p-2 border-b">
<a :href = "product?.url" x-text = "product?.product_name" class = "text-blue-600"></a>
</td>
<td class = "p-2 border-b" x-text = "product?.color"></td>
<td class = "p-2 border-b" x-text = "product?.quantity"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<div class = "grid grid-cols-1 md:grid-cols-2 gap-4">
<div class = "bg-white p-4 rounded shadow mb-4">
<h2 class = "text-xl font-semibold mb-2">Trạng thái đơn hàng</h2>
<table class = "w-full text-left border-collapse">
<thead>
<tr>
<th class = "p-2 border-b">Trạng Thái</th>
<th class = "p-2 border-b">Số Đơn</th>
<th class = "p-2 border-b">Tổng Doanh Thu</th>
</tr>
</thead>
<tbody>
<template x-for = "(groupStatus, index) in orderByStatus" :key = "index">
<tr>
<td class = "p-2 border-b" x-text = "groupStatus?.status"></td>
<td class = "p-2 border-b" x-text = "groupStatus?.count"></td>
<td class = "p-2 border-b" x-text = "groupStatus?.revenue"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
@endsection
@section('vendor-scripts')
<script src = "{{ asset('plugins/chartjs/chart.js') }}"></script>
<script src = "{{ asset('plugins/flatpickr/flatpickr.min.js') }}"></script>
<script src = "{{ asset('plugins/flatpickr/lang/vn.js') }}"></script>
@endsection
@section('custom-scripts')
<script>
function formatTooltip(tooltipItem) {
let value = tooltipItem.raw;
let formattedValue = new Intl.NumberFormat('vi-VN', {
style: 'currency',
currency: 'VND'
}).format(value);
return `${tooltipItem.dataset.label}: ${formattedValue}`;
}
function dashboard() {
return {
customRange: false,
startDate: '',
endDate: '',
selectedDate: '',
totalSales: {
today: '',
week: '',
month: '',
year: '',
},
recentOrders: [],
topProducts: [],
lowStockProducts: [],
salesChartData: {
labels: [],
revenue: [],
cost: [],
profit: []
},
categoryChartData: {
labels: [],
data: []
},
brandChartData: {
labels: [],
data: []
},
salesChart: null,
categoryChart: null,
brandChart: null,
timeRange: 'today',
timeRangePicker: null,
orderByStatus: [],
init() {
this.fetchDashboardData();
this.initFlatpickr();
this.$watch('timeRange', (value) => {
if (value !== 'custom') {
this.fetchDashboardData();
}
});
this.$watch('startDate', () => {
if (this.customRange && this.startDate && this.endDate) {
this.fetchDashboardData();
}
});
this.$watch('endDate', () => {
if (this.customRange && this.startDate && this.endDate) {
this.fetchDashboardData();
}
});
this.$watch('customRange', (value) => {
if (!value) {
this.startDate = '';
this.endDate = '';
this.timeRangePicker?.clear();
}
});
},
fetchDashboardData() {
const url = `{{ route('admin.dashboardData') }}?timeRange=${this.timeRange}&startDate=${this.startDate}&endDate=${this.endDate}`;
fetch(url, {headers: {'Accept': 'application/json'}})
.then(response => response.json())
.then(data => {
this.totalSales.revenue = `${data.totalSales.revenue}`;
this.totalSales.cost = `${data.totalSales.cost}`;
this.totalSales.profit = `${data.totalSales.profit}`;
this.recentOrders = data.recentOrders;
this.topProducts = data.topProducts;
this.lowStockProducts = data.lowStockProducts;
this.orderByStatus = data.orderByStatus;
this.salesChartData.labels = data.salesChartData.labels;
this.salesChartData.revenue = data.salesChartData.revenue;
this.salesChartData.cost = data.salesChartData.cost;
this.salesChartData.profit = data.salesChartData.profit;
this.categoryChartData.labels = data.salesByCategory.labels;
this.categoryChartData.data = data.salesByCategory.data;
this.brandChartData.labels = data.salesByBrand.labels;
this.brandChartData.data = data.salesByBrand.data;
this.updateSalesChart();
this.updateCategoryChart();
this.updateBrandChart();
});
},
updateSalesChart() {
const ctx = document.getElementById('salesChart').getContext('2d');
if (this.salesChart) {
this.salesChart.destroy();
}
this.salesChart = new Chart(ctx, {
type: 'bar',
data: {
labels: this.salesChartData.labels,
datasets: [
{
label: 'Doanh thu',
data: this.salesChartData.revenue,
backgroundColor: 'rgba(75, 192, 192, 0.2)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1
},
{
label: 'Chi phí',
data: this.salesChartData.cost,
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderColor: 'rgba(255, 99, 132, 1)',
borderWidth: 1
},
{
label: 'Lợi nhuận',
data: this.salesChartData.profit,
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1,
type: 'line'
}
]
},
options: {
scales: {
y: {
beginAtZero: true
}
},
plugins: {
tooltip: {
callbacks: {
label: formatTooltip
}
}
},
}
});
},
updateCategoryChart() {
const ctx = document.getElementById('categoryChart').getContext('2d');
if (this.categoryChart) {
this.categoryChart.destroy();
}
this.categoryChart = new Chart(ctx, {
type: 'pie',
data: {
labels: this.categoryChartData.labels,
datasets: [
{
label: 'Doanh thu',
data: this.categoryChartData.data,
backgroundColor: this.categoryChartData.labels.map(() => `rgba(${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, 0.2)`),
borderColor: this.categoryChartData.labels.map(() => `rgba(${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, 1)`),
borderWidth: 1
}
]
},
options: {
plugins: {
tooltip: {
callbacks: {
label: formatTooltip
}
}
},
}
});
},
updateBrandChart() {
const ctx = document.getElementById('brandChart').getContext('2d');
if (this.brandChart) {
this.brandChart.destroy();
}
this.brandChart = new Chart(ctx, {
type: 'pie',
data: {
labels: this.brandChartData.labels,
datasets: [
{
label: 'Doanh thu',
data: this.brandChartData.data,
backgroundColor: this.brandChartData.labels.map(() => `rgba(${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, 0.2)`),
borderColor: this.brandChartData.labels.map(() => `rgba(${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, 1)`),
borderWidth: 1
}
]
},
options: {
plugins: {
tooltip: {
callbacks: {
label: formatTooltip
}
}
},
}
});
},
initFlatpickr() {
this.timeRangePicker = flatpickr(this.$refs.datepicker, {
onChange: ([startDate, endDate]) => {
if (startDate && endDate) {
this.startDate = flatpickr.formatDate(startDate, 'Y-m-d');
this.endDate = flatpickr.formatDate(endDate, 'Y-m-d');
}
},
locale: "vn",
mode: "range",
altInput: true,
conjunction: " - ",
maxDate: "today",
altFormat: "d/m/Y",
dateFormat: "Y-m-d",
});
},
}
}
</script>
@endsection
<!DOCTYPE html>
<html lang = "en">
<head>
<meta charset = "UTF-8">
<meta name = "viewport" content = "width=device-width, initial-scale=1.0">
<meta http-equiv = "Content-Security-Policy" content = "upgrade-insecure-requests">
<title>Admin Dashboard - Product Management</title>
{{-- <link rel = "stylesheet" href = "{{ asset('AdminLTE/dist/css/AdminLTE.min.css') }}">--}}
<script src = "{{ asset('plugins/tailwindcss/tailwindcss.min.css') }}"></script>
<link rel = "stylesheet" href = "{{ asset('plugins/fontawesome/css/all.min.css') }}"/>
<script src = "{{ asset('plugins/alpinejs/alpine.min.js') }}" defer></script>
@yield('vendor-styles')
@yield('custom-styles')
</head>
<body class = "bg-gray-100">
<!-- Sidebar -->
@include('admin.layouts.includes.sidebar')
<!-- Main Content -->
<div class = "flex-1 flex flex-col">
<!-- Navbar -->
@include('admin.layouts.includes.header')
<!-- Breadcrumbs -->
<nav class = "bg-gray border-b border-gray-200">
<div class = "max-w-7xl mx-auto py-3 px-4 sm:px-6 lg:px-8">
<ol class = "flex items-center space-x-4">
@yield('breadcrumbs')
</ol>
</div>
</nav>
<!-- Main Section -->
<main class = "flex-1 bg-gray-100">
<div class = "max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
@yield('content')
</div>
</main>
<!-- Footer -->
@include('admin.layouts.includes.footer')
</div>
<script src = "{{ asset('plugins/htmx/htmx.js') }}"></script>
@yield('vendor-scripts')
@yield('custom-scripts')
</body>
</html>
Диаграммы корректно отображаются в Alpine.js 2.8.2, но не работают в Alpine.js 3.x, показывая упомянутую выше ошибку. Что может быть причиной этой проблемы в Alpine.js 3.x и как ее устранить?
Будем очень признательны за любые идеи или решения.



![Безумие обратных вызовов в javascript [JS]](https://i.imgur.com/WsjO6zJb.png)


В AlpineJs v3 метод init() объекта данных, если он присутствует, выполняется автоматически при запуске.
В вашем основном <div> вы указываете x-init="init()" (вероятно, потому, что код был разработан для AlpineJs v2, который не поддерживает эту функцию), поэтому метод init() выполняется два раза, и это приводит к ошибка.
Вы можете проверить эту ситуацию, добавив console.info в init():
init() {
console.info ("init executed", Date.now());
.....
Чтобы решить проблему, удалите x-init="init()" из основного <div>.
Если проблема возникает снова, когда вы выбираете диапазон дат, это связано с тем, что вы установили $watch на обоих концах диапазона, и это снова приводит к двум быстрым последовательным вызовам fetchDashboardData().
Чтобы решить эту проблему простым способом, возможное решение состоит в том, чтобы сгруппировать ограничения дат в один объект, определить один $watch для этого объекта, а затем выполнить кумулятивное присвоение в обратном вызове datepicker:
function dashboard() {
return {
customRange: false,
// startDate: '', <~~~ removed
// endDate: '', <~~~ removed
dateRange: {startDate: '', endDate: ''}, // <~~~ added
.....
init() {
.....
//this.$watch('startDate', () => { <~~~ removed
//
// if (this.customRange && this.startDate && this.endDate) {
// this.fetchDashboardData();
// }
//});
//
//this.$watch('endDate', () => { <~~~ removed
//
// if (this.customRange && this.startDate && this.endDate) {
// this.fetchDashboardData();
// }
//});
this.$watch('dateRange', () => { // <~~~ added
if (this.customRange && this.dateRange.startDate && this.dateRange.endDate) {
this.fetchDashboardData();
}
});
.....
},
.....
initFlatpickr() {
this.timeRangePicker = flatpickr(this.$refs.datepicker, {
onChange: ([startDate, endDate]) => {
if (startDate && endDate) {
// this.startDate = flatpickr.formatDate(startDate, 'Y-m-d'); <~~~ removed
// this.endDate = flatpickr.formatDate(endDate, 'Y-m-d'); <~~~ removed
this.dateRange = { // <~~~ added
startDate: flatpickr.formatDate(startDate, 'Y-m-d'),
endDate: flatpickr.formatDate(endDate, 'Y-m-d')
}
}
},
.....
Теперь каждую ссылку на this.startDate и this.endDate необходимо заменить на this.dateRange.startDate и this.dateRange.endDate.
Все возникающие проблемы возникают из-за того, что графики быстро уничтожаются и перерисовываются, когда они еще не закончены. Поскольку по умолчанию анимация активна, построение диаграмм может занять больше времени, чем ожидалось.
В предыдущих случаях перерисовка диаграмм происходила из-за ошибок проектирования кода, а теперь проблема возникает из-за взаимодействия с пользователем, которым необходимо управлять.
Самое простое решение — отключить анимацию: добавьте ключ анимации в разделе «Параметры» объекта конфигурации каждого графика и присвойте ему значение false:
.....
options: {
animation: false,
.....
Более сложное решение — запретить ввод данных во время рисования диаграмм. Для достижения этой цели мы можем:
.....
lowStockProducts: [],
animating: {sales: false, categs: false, brand: false}, // <~~~ added
.....
.....
init() {
.....
},
animationInProgress() { // <~~~ added
return this.animating.sales || this.animating.categs || this.animating.brand;
},
.....
.....
updateSalesChart() {
.....
this.animating.sales = true; // <~~~ added
this.salesChart = new Chart(ctx, {
.....
options: {
animation: { // <~~~ added
onComplete: () => {
this.animating.sales = false;
}
},
.....
.....
<button @click = "...."
:class = "....."
.....
:disabled = "animationInProgress()" {{-- <~~~ added --}}
>
Hôm nay
</button>
.....
Чтобы быть придирчивыми, вы также можете отключить средство выбора даты, добавив $watch() в метод init():
this.$watch('animating', () => {
this.timeRangePicker.set("clickOpens", !this.animationInProgress());
});
Это связанная, но другая проблема, следует открыть новый запрос ;-) Смотрите обновление моего ответа.
Он также сталкивается с вышеуказанными ошибками, когда я выбираю диапазоны времени, такие как «сегодня», «этот месяц», «этот год» и т. д. Эти значения хранятся в состоянии «timeRange». Первые несколько раз при замене фильтра работает, но потом вылетает, или вылетает сразу, если я меняю фильтр слишком быстро. Я не сталкивался с этой проблемой в версии 2x; в версии 2x я мог переключать фильтры очень быстро, и все равно все работало нормально. Я не являюсь фронтенд-разработчиком и впервые использую Alpine.js. Я прочитал их документацию об изменениях в версии 3x, но решения так и не нашел.
Добавление вопросов к одному и тому же запросу не является хорошей практикой. Если вам все еще нужна дополнительная помощь, вы должны открыть еще один запрос. Имейте в виду, что я также преимущественно бэкэнд-разработчик :-)
Большое спасибо, это сработало, когда я добавил анимацию: false. Мне интересно, почему эта ошибка не возникла в Alpine.js 2.x. Есть ли разница в жизненном цикле или обработке DOM?
Есть некоторые различия, однако я использовал версию 2 очень мало. Возможно, если вы тоже обновили Chart.js, это может зависеть от этого.
Большое спасибо. Я следовал вашему методу, и он сработал, но когда я нажимаю кнопку фильтра по диапазону времени, он иногда выдает ошибку, как и раньше. Я использую $watch для прослушивания изменений в фильтре временного диапазона для получения данных и обновления диаграммы.