Альтернативный взгляд на Infinity grid

Добрый день, в данной статье я расскажу об альтернативном взгляде на проблему 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 , удаленные и локальные сортировки ровно как и остальные базовые фичи стора должны работать штатно.

 

 

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *