Добрый день, в данной статье я расскажу об альтернативном взгляде на проблему infinity grid в classic toolkit. По стандарту ExtJS использует для загрузки следующей страницы store три ключевых параметра: start, page, limit , которые указывают backend’y на определенную область данных. Зачастую, при использовании микросервисной архитектуры на бекенде, нет возможности за разумное время отдать ни общее количество записей (totalCount), ни определенную область данных. Давайте немного отвлечемся и посмотрим как работает twitter, и как именно он загружает списки твитов…
Сразу бросается в глаза флаг has_more_items, который определяет остались ли ещё данные. Если такой же подход применить в ExtJS, тогда не будет надобности в параметрах page, start. У нас останется лишь limit и необходимо будет ввести новый параметр count.
- limit — будет определять сколько записей нам необходимо (по сути это наш pageSize)
- count — будет определять сколько записей у нас уже имеется
Поднимаем backend для нашего примера
Итак, нам понадобиться mysql (я использую MariaDB), создадим тестовую базу example, и импортируем туда таблицу articles с тестовыми данными. Дамп базы с тестовыми данными можно скачать тут.
Теперь нам необходим backend, я выбрал Python3, установим необходимые зависимости
pip install Flask flask-mysql flask-restful flask-cors
Сам код сервера, заранее скажу что использовать его в production нельзя, там есть и SQL-Injection, и другие очевидные ошибки, но для примера нам вполне сгодиться.
from flask import Flask, jsonify, request from flask_cors import CORS, cross_origin from flaskext.mysql import MySQL from webargs import fields from webargs.flaskparser import parser app = Flask(__name__) CORS(app, support_credentials=True) mysql = MySQL() # MySQL configurations app.config['MYSQL_DATABASE_USER'] = 'example_user' app.config['MYSQL_DATABASE_PASSWORD'] = 'QWVixbwjszxkQ8Xc' app.config['MYSQL_DATABASE_DB'] = 'example' app.config['MYSQL_DATABASE_HOST'] = 'mysql.server' mysql.init_app(app) articles_args = { 'count': fields.Int(missing=0, required=True), 'limit': fields.Int(missing=25, required=True) } @app.route('/articles', methods=['GET']) @cross_origin(supports_credentials=True) def articles(): args = parser.parse(articles_args, request) cur = mysql.connect().cursor() cur.execute('select * from articles limit {},{}'.format(args['count'], args['limit'])) r = [dict((cur.description[i][0], value) for i, value in enumerate(row)) for row in cur.fetchall()] return jsonify({ 'has_more': True if len(r) == args['limit'] else False, # тут ошибка, но для нас не критично 'data': r, 'success': True }) if __name__ == '__main__': app.run()
Запустим его, если все хорошо, тогда на 5000/tcp порту поднимется webserver, который по роуту /articles
будет обслуживать наш будущий grid.
Создаем frontend
Создадим тестовое приложение
sencha -sdk \path\to\framework generate app -classic App \path\to\application
Далее создадим альтернативный proxy.ajax , модель Article, наш Grid и ViewModel к нему.
Ext.define('App.components.data.proxy.InfinityAjax', { extend: 'Ext.data.proxy.Ajax', alias: 'proxy.infinityajax', doRequest: function(operation) { var me = this, writer = me.getWriter(), request = me.buildRequest(operation), method = me.getMethod(request), jsonData, params; if (writer && operation.allowWrite()) { request = writer.write(request); } // Нужно обновить параметры let current_params = request.getParams(); current_params.count = operation.getInternalScope().getTotalCount(); request.setParams(current_params); request.setConfig({ binary : me.getBinary(), headers : me.getHeaders(), timeout : me.getTimeout(), scope : me, callback : me.createRequestCallback(request, operation), method : method, useDefaultXhrHeader : me.getUseDefaultXhrHeader(), disableCaching : false // explicitly set it to false, ServerProxy handles caching }); if (method.toUpperCase() !== 'GET' && me.getParamsAsJson()) { params = request.getParams(); if (params) { jsonData = request.getJsonData(); if (jsonData) { jsonData = Ext.Object.merge({}, jsonData, params); } else { jsonData = params; } request.setJsonData(jsonData); request.setParams(undefined); } } if (me.getWithCredentials()) { request.setWithCredentials(true); request.setUsername(me.getUsername()); request.setPassword(me.getPassword()); } return me.sendRequest(request); }, });
Ext.define('App.model.Article', { extend: 'App.model.Base', requires: [ 'App.components.data.proxy.InfinityAjax' ], idProperty: 'id', identifier: 'uuid', proxy: { type: 'infinityajax', appendId: false, url: 'http://127.0.0.1:5000/articles', reader: { type: 'json', rootProperty: 'data' }, noCache: false, //to remove param "_dc" pageParam: false, // to remove param "page" startParam: false, //to remove param "start" limitParam: 'limit', }, fields: [ { name: 'message', type: 'string' }, { name: 'note', type: 'string' }, { name: 'title', type: 'string' } ] });
Ext.define('App.view.main.Grid', { extend: 'Ext.grid.Panel', xtype: 'app-main', requires: [ 'App.view.main.MainController', 'App.view.main.MainModel' ], viewModel: 'main', bind: '{articles}', columns: { defaults: { flex: false, sortable: false, editor: false, align: 'center', minWidth: 30, width: 60 }, items: [{ xtype: 'rownumberer', text: '#', align: 'left', maxWidth: 60, sortable: false, hideable: false, menuDisabled: true }, { text: 'title', dataIndex: 'title', align: 'left', width: 200 }, { text: 'message', dataIndex: 'message', width: 180, flex: true }, { text: 'Note', dataIndex: 'note', width: 80 }] } });
Ext.define('App.view.main.MainModel', { extend: 'Ext.app.ViewModel', alias: 'viewmodel.main', stores: { articles: { model: 'App.model.Article', pageSize: 100, autoLoad: true } } });
Теперь если вы все сделали правильно, вы можете запустить приложение и увидеть что наш grid успешно загрузился, а если что-то пошло не так, вы можете взять пример работающего приложения в репозитории в конце статьи.
Теперь у нас остались три основные проблемы:
- Нет автоматической подгрузки новой страницы при достижении скрола нижней части экрана
- При загрузке новой страницы не правильно высчитываются rownumberer
- В store не обновляется поле totalCount
Добавляем подгрузку следующей страницы
Для этого мы напишем отдельный плагин, в качестве конфигурации он будет принимать один параметр loadingZone
, который определяет за сколько пикселей до конца скролла начинать подгрузку следующей страницы. Работает немного странно, но это же концепт 🙂
Ext.define('App.components.grid.plugin.LazyLoad', { extend: 'Ext.plugin.Abstract', alias: 'plugin.lazyload', config: { loadingZone: 200, isLoading: false }, init: function(grid) { var me = this, view = grid.getView(); view.on('scrollend', me.onViewScroll, me); }, onViewScroll: function(view, x, y) { let me = this, store = view.getStore(), scroller = view.getScrollable(), maxPos = scroller.getMaxPosition(); if (store.isLoading() || store.hasPendingLoad() || me.getIsLoading() || !store.getHasMore()) { return; } if ( y > maxPos.y - me.getLoadingZone()) { me.setIsLoading(true); store.nextPage({ callback: function() { view.refresh(); me.setIsLoading(false); } }); } return false; } });
Исправляем rownumberer
Для этого мы напишем свой rownumberer, так как в существующем используются формулы основанные на pageSize, а нам нужны абсолютные значения
Ext.define('App.components.grid.column.RowNumberer', { extend: 'Ext.grid.column.RowNumberer', alias: 'widget.infinityrownumberer', defaultRenderer: function(value, metaData, record, rowIdx, colIdx, dataSource, view) { var me = this, rowspan = me.rowspan, page = dataSource.currentPage, result = record ? view.store.indexOf(record) : value - 1; if (metaData && rowspan) { metaData.tdAttr = 'rowspan="' + rowspan + '"'; } // Нам уже не нужна эта логика // if (page > 1) { // result += (page - 1) * dataSource.pageSize; // } return result + 1; }, });
Исправляем totalCount
Тут более менее корректное решение каждый раз при получении ответа синхронизировать флаг has_more
, и проставлять totalCount
как сумму текущих записей + полученные.
Ext.define('App.components.data.InfinityStore', { extend: 'Ext.data.Store', alias: 'store.infinitystore', config: { hasMore: true, clearOnPageLoad: false }, onProxyLoad: function(operation) { var me = this, resultSet = operation.getResultSet(), records = operation.getRecords(), successful = operation.wasSuccessful(); if (me.destroyed) { return; } if (resultSet) { // Нужно правильно взводить флаг if (Ext.decode(operation.getResponse().responseText).has_more) { me.setHasMore(true); } else { me.setHasMore(false); } me.totalCount = me.getTotalCount() + resultSet.getTotal(); } if (successful) { records = me.processAssociation(records); me.loadRecords(records, operation.getAddRecords() ? { addRecords: true } : undefined); me.attachSummaryRecord(resultSet); } else { me.loading = false; } if (me.hasListeners.load) { me.fireEvent('load', me, records, successful, operation); } me.callObservers('AfterLoad', [records, successful, operation]); }, previousPage: function(options) { Ext.raise('Not be implemented'); }, loadPage: function(page, options) { var me = this; if (me.currentPage >= page) { Ext.raise('page number should be more'); } if (me.getHasMore()) me.callParent(arguments); }, reload: function() { var me = this; me.currentPage = 0; me.totalCount = 0; me.nextPage(); } });
Эпилог
Рабочее приложение вы можете найти в моем репозитории. Следует отметить, что InfinityStore не поддерживает загрузку предыдущих страниц относительно currentPage
, удаленные и локальные сортировки ровно как и остальные базовые фичи стора должны работать штатно.