(數據科學學習手札106)Python+Dash快速web應用開發——回調交互篇(下)

本文示例代碼已上傳至我的Github倉庫//github.com/CNFeffery/DataScienceStudyNotes

1 簡介

   這是我的系列教程Python+Dash快速web應用開發的第五期,在上一期的文章中,我們針對Dash中有關回調的一些技巧性的特性進行了介紹,使得我們可以更愉快地為Dash應用編寫回調交互功能。

  而今天的文章作為回調交互系統性內容的最後一期,我將帶大家get一些Dash中實際應用效果驚人的高級回調特性,系好安全帶,我們起飛~

圖1

2 Dash中的高級回調特性

2.1 控制部分回調輸出不更新

  在很多應用場景下,我們給某個回調函數綁定了多個Output(),這時如果這些Output()並不是每次觸發回調都需要被更新,那麼就可以根據Input()值的不同,來配合dash.no_update作為對應Output()的返回值,從而實現部分Output()不更新,譬如下面的例子:

app1.py

import dash
import dash_bootstrap_components as dbc
import dash_html_components as html
from dash.dependencies import Input, Output
import time

app = dash.Dash(__name__)

app.layout = html.Div(
    dbc.Container(
        [
            html.Br(),
            html.Br(),
            html.Br(),
            dbc.Row(
                dbc.Col(
                    dbc.Button('按鈕',
                               color='primary',
                               id='button',
                               n_clicks=0)
                )
            ),
            html.Br(),
            dbc.Row(
                [
                    dbc.Col('尚未觸發', id='record-1'),
                    dbc.Col('尚未觸發', id='record-2'),
                    dbc.Col('尚未觸發', id='record-n')
                ]
            )
        ]
    )
)


@app.callback(
    [Output('record-1', 'children'),
     Output('record-2', 'children'),
     Output('record-n', 'children'),
     ],
    Input('button', 'n_clicks'),
    prevent_initial_call=True
)
def record_click_event(n_clicks):
    if n_clicks == 1:
        return (
            '第1次點擊:{}'.format(time.strftime('%H:%M:%S', time.localtime(time.time()))),
            dash.no_update,
            dash.no_update
        )

    elif n_clicks == 2:
        return (
            dash.no_update,
            '第2次點擊:{}'.format(time.strftime('%H:%M:%S', time.localtime(time.time()))),
            dash.no_update
        )

    elif n_clicks >= 3:
        return (
            dash.no_update,
            dash.no_update,
            '第3次及以上點擊:{}'.format(time.strftime('%H:%M:%S', time.localtime(time.time()))),
        )


if __name__ == '__main__':
    app.run_server(debug=True)

圖2

  可以觀察到,我們根據n_clicks數值的不同,在對應各個Output()返回值中對符合條件的部件進行更新,其他的都用dash.no_update來代替,從而實現了局部更新,非常實用且簡單。

2.2 基於模式匹配的回調

  這是Dash在1.11.0版本開始引入的新特性,它所實現的功能是將多個部件綁定組織在同一個id屬性下,這聽起來有一點抽象,我們先從一個形象的例子來出發:

  假如我們要開發一個簡單的記賬應用,它通過第一排若干Input()部件及一個Button()部件來記錄並提交每筆賬對應的相關信息,並且在最下方輸出已記錄賬目金額之和:

app2.py

import dash
import dash_bootstrap_components as dbc
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output, State, ALL
import re

app = dash.Dash(__name__)

app.layout = html.Div(
    [
        html.Br(),
        html.Br(),
        dbc.Container(
            dbc.Row(
                [
                    dbc.Col(
                        dbc.InputGroup(
                            [
                                dbc.InputGroupAddon("金額", addon_type="prepend"),
                                dbc.Input(
                                    id='account-amount',
                                    placeholder='請輸入金額',
                                    type="number",
                                ),
                                dbc.InputGroupAddon("元", addon_type="append"),
                            ],
                        ),
                        width=5
                    ),
                    dbc.Col(
                        dcc.Dropdown(
                            id='account-type',
                            options=[
                                {'label': '生活開銷', 'value': '生活開銷'},
                                {'label': '人情往來', 'value': '人情往來'},
                                {'label': '醫療保健', 'value': '醫療保健'},
                                {'label': '旅遊休閑', 'value': '旅遊休閑'},
                            ],
                            placeholder='請選擇類型:'
                        ),
                        width=5
                    ),
                    dbc.Col(
                        dbc.Button('提交記錄', id='account-submit'),
                        width=2
                    )
                ]
            )
        ),
        html.Br(),
        dbc.Container([], id='account-record-container'),
        dbc.Container('暫無記錄!', id='account-record-sum')
    ]
)


@app.callback(
    Output('account-record-container', 'children'),
    Input('account-submit', 'n_clicks'),
    [State('account-record-container', 'children'),
     State('account-amount', 'value'),
     State('account-type', 'value')],
    prevent_initial_call=True
)
def update_account_records(n_clicks, children, account_amount, account_type):
    '''
    用於處理每一次的記賬輸入並渲染前端記錄
    '''
    if account_amount and account_type:
        children.append(dbc.Row(
            dbc.Col(
                '【{}】類開銷【{}】元'.format(account_type, account_amount)
            ),
            # 以字典形式定義id
            id={'type': 'single-account_record', 'index': children.__len__()}
        ))

        return children


@app.callback(
    Output('account-record-sum', 'children'),
    Input({'type': 'single-account_record', 'index': ALL}, 'children'),
    prevent_initial_call=True
)
def refresh_account_sum(children):
    '''
    對多部件集合single-account_record下所有賬目記錄進行求和
    '''
    return '賬本總開銷:{}'.format(sum([int(re.findall('\d+',
                                                 child['props']['children'])[0])
                                  for child in children]))

if __name__ == '__main__':
    app.run_server(debug=True)

圖3

  上面這個應用中,體現出的模式匹配內容即為開頭從dash.dependencies引入的ALL,它是Dash模式匹配中的一種模式,而我們在回調函數update_account_records()中為已有記賬記錄追加新紀錄時,使用到:

# 以字典形式定義id
id={'type': 'single-account_record', 'index': children.__len__()}

  這裡不同於以前我們採取的id=某個字符串的定義方法,換成字典之後,其type鍵值對用來記錄唯一id信息,每一次新紀錄追加時type值都相等,因為它們被組織為同id部件集合,而鍵值對index則用於在type值相同的一個部件集合下,區分出不同的獨立部件元素。

  因為將傳統的唯一id部件替換成同id部件集合,所以我們後面的回調函數refresh_account_sum()的輸入元素只需要定義單個Input()即可,再在函數內部按照不同的index值取出需要的集合內各成員記錄值,非常便於我們書寫出簡練清爽的Dash代碼,便於之後進一步的修改與重構。

  你可以通過最下面打印出的每次refresh_account_sum()所接收到的children參數json格式結果來弄清我是如何在return值的地方取出歷史記賬金額並計算的。

  而除了上面介紹的一股腦返回所有集合內成員部件的ALL模式之外,還有另一種更有針對性的MATCH模式,它應用於結合內成員部件可交互輸入值的情況,譬如下面這個簡單的例子,我們定義一個簡單的用於查詢省份行政代碼的應用,配合MATCH模式來實現彼此成對獨立輸出:

app3.py

import dash
import dash_bootstrap_components as dbc
import dash_html_components as html
from dash.dependencies import Input, Output, State, MATCH
import dash_core_components as dcc

app = dash.Dash(__name__)

app.layout = html.Div(
    [
        html.Br(),
        html.Br(),
        html.Br(),
        dbc.Container(
            [
                dbc.Row(
                    dbc.Col(
                        dbc.Button('新增查詢', id='add-item', outline=True)
                    )
                ),
                html.Hr()
            ]
        ),
        dbc.Container([], id='query-container')
    ]
)

region2code = {
    '北京市': '110000000000',
    '重慶市': '500000000000',
    '安徽省': '340000000000'
}


@app.callback(
    Output('query-container', 'children'),
    Input('add-item', 'n_clicks'),
    State('query-container', 'children'),
    prevent_initial_call=True
)
def add_query_item(n_clicks, children):
    children.append(
        dbc.Row(
            [
                dbc.Col(
                    [
                        # 生成index相同的dropdown部件與文字輸出部件
                        dcc.Dropdown(id={'type': 'select-province', 'index': children.__len__()},
                                     options=[{'label': label, 'value': label} for label in region2code.keys()],
                                     placeholder='選擇省份:'),
                        html.P('請輸入要查詢的省份!', id={'type': 'code-output', 'index': children.__len__()})
                    ]
                )
            ]
        )
    )

    return children

@app.callback(
    Output({'type': 'code-output', 'index': MATCH}, 'children'),
    Input({'type': 'select-province', 'index': MATCH}, 'value')
)
def refresh_code_output(value):

    if value:
        return region2code[value]
    else:
        return dash.no_update

if __name__ == '__main__':
    app.run_server(debug=True)

圖4

  可以看到,在refresh_code_output()前應用MATCH模式匹配後,我們點擊某個部件時,只有跟它index匹配的部件才會打印出相對應的輸出,非常的方便~

2.3 多輸入情況下獲取部件觸發情況

  在很多應用場景下,我們的某個回調可能擁有多個Input輸入,但學過前面的內容我們已經清楚,不管有幾個Input,只要其中有一個部件其輸入屬性發生變化,都會觸發本輪迴調,但是如果我們就想知道究竟是哪個Input觸發了本輪迴調該怎麼辦呢?

  這在Dash中可以通過dash.callback_context來方便的實現,它只能在回調函數中被執行,從而獲取回調過程的諸多上下文信息,先從下面這個簡單的例子出發看看dash.callback_context到底給我們帶來了哪些有價值的信息:

app4.py

import dash
import dash_html_components as html
import dash_bootstrap_components as dbc
from dash.dependencies import Input, Output
import json

app = dash.Dash(__name__)

app.layout = html.Div(
    dbc.Container(
        [
            html.Br(),
            html.Br(),
            html.Br(),
            dbc.Row(
                [
                    dbc.Col(dbc.Button('A', id='A', n_clicks=0)),
                    dbc.Col(dbc.Button('B', id='B', n_clicks=0)),
                    dbc.Col(dbc.Button('C', id='C', n_clicks=0))
                ]
            ),
            dbc.Row(
                [
                    dbc.Col(html.P('按鈕A未點擊', id='A-output')),
                    dbc.Col(html.P('按鈕B未點擊', id='B-output')),
                    dbc.Col(html.P('按鈕C未點擊', id='C-output'))
                ]
            ),
            dbc.Row(
                dbc.Col(
                    html.Pre(id='raw-json')
                )
            )
        ]
    )
)


@app.callback(
    [Output('A-output', 'children'),
     Output('B-output', 'children'),
     Output('C-output', 'children'),
     Output('raw-json', 'children')],
    [Input('A', 'n_clicks'),
     Input('B', 'n_clicks'),
     Input('C', 'n_clicks')],
    prevent_initial_call=True
)
def refresh_output(A_n_clicks, B_n_clicks, C_n_clicks):

    # 獲取本輪迴調狀態下的上下文信息
    ctx = dash.callback_context

    # 取出對應State、最近一次觸發部件以及Input信息
    ctx_msg = json.dumps({
        'states': ctx.states,
        'triggered': ctx.triggered,
        'inputs': ctx.inputs
    }, indent=2)

    return A_n_clicks, B_n_clicks, C_n_clicks, ctx_msg

if __name__ == '__main__':
    app.run_server(debug=True)

圖5

  可以看到,我們安插在回調函數里的dash.callback_context幫我們記錄了從訪問Dash開始,到最近一次執行回調期間,對應回調的輸入輸出信息變化情況、最近一次觸發信息,非常的實用,可以支撐起很多複雜應用場景。

2.4 在瀏覽器端執行回調過程

  Dash雖然很方便,使得我們可以完全不用書寫js代碼就可以實現各種回調交互,但把所有的交互響應計算過程都交給服務端來做,省事倒是很省事,但會給服務器帶來不小的計算和網絡傳輸壓力。

  因此很多容易頻繁觸發且與主要的數值計算無關的交互行為,完全可以搬到瀏覽器端執行,既快速又不吃服務器的計算資源,這也是當初JavaScript被發明的一個重要原因,而在Dash中,也為略懂js的用戶提供了在瀏覽器端執行一些回調的貼心功能。

  從一個很簡單的點擊按鈕,實現部分網頁內容的打開與關閉出發,這裡我們提前使用到dbc.Collapse部件,用於將所包含的網頁內容與其它按鈕部件的點擊行為進行綁定:

app5.py

import dash
import dash_bootstrap_components as dbc
import dash_html_components as html
from dash.dependencies import Input, Output, State

app = dash.Dash(__name__)

app.layout = html.Div(
    dbc.Container(
        [
            html.Br(),
            html.Br(),
            html.Br(),
            dbc.Button('服務端回調', id='server-button'),
            dbc.Collapse('服務端摺疊內容', id='server-collapse'),
            html.Hr(),
            dbc.Button('瀏覽器端回調', id='browser-button'),
            dbc.Collapse('瀏覽器端摺疊內容', id='browser-collapse'),
        ]
    )
)


@app.callback(
    Output('server-collapse', 'is_open'),
    Input('server-button', 'n_clicks'),
    State('server-collapse', 'is_open'),
    prevent_initial_call=True
)
def server_callback(n_clicks, is_open):
    return not is_open

# 在dash中定義瀏覽器端回調函數的特殊格式
app.clientside_callback(
    """
    function(n_clicks, is_open) {
        return !is_open;
    }
    """,
    Output('browser-collapse', 'is_open'),
    Input('browser-button', 'n_clicks'),
    State('browser-collapse', 'is_open'),
    prevent_initial_call=True
)

if __name__ == '__main__':
    app.run_server(debug=True)

  可以看到,服務端回調我們照常寫,而瀏覽器端回調通過傳入一個非常簡單的js函數,在每次回調時接受輸入並輸出is_open的邏輯反值,從而實現了摺疊內容的打開與關閉切換:

function(n_clicks, is_open) {
        return !is_open;
}

  便實現了瀏覽器端回調!

圖6

  而如果你想要執行的瀏覽器端js回調函數代碼有點長,還可以按照下圖格式,把你的大段js回調函數代碼放置於assets目錄下對應路徑里的js腳本中:

圖7

  接着再在dash中按照下列格式編寫關聯輸入輸出與上述js回調的簡短語句即可:

app.clientside_callback(
    ClientsideFunction(
        namespace='命名空間名稱',
        function_name='對應js回調函數名'
    ),
    '''
    按順序組織你的Output、Input以及State... ...
    '''
)

  下面我們直接以大家喜聞樂見的數據可視化頂級框架echarts為例,來寫一個根據不同輸入值切換渲染出的圖表類型,注意請從官網把依賴的echarts.min.js下載到我們的assets路徑下對應位置,它會在我們的Dash應用啟動時與所有assets下的資源一起自動被載入到瀏覽器中:

app6.py

import dash
import dash_bootstrap_components as dbc
import dash_html_components as html
import dash_core_components as dcc
from dash.dependencies import Input, Output, ClientsideFunction

app = dash.Dash(__name__)

# 編寫一個根據dropdown不同輸入值切換對應圖表類型的小應用
app.layout = html.Div(
    dbc.Container(
        [
            html.Br(),
            dbc.Row(
                dbc.Col(
                    dcc.Dropdown(
                        id='chart-type',
                        options=[
                            {'label': '折線圖', 'value': '折線圖'},
                            {'label': '堆積面積圖', 'value': '堆積面積圖'},
                        ],
                        value='折線圖'
                    ),
                    width=3
                )
            ),
            html.Br(),
            dbc.Row(
                dbc.Col(
                    html.Div(
                        html.Div(
                            id='main',
                            style={
                                'height': '100%',
                                'width': '100%'
                            }
                        ),
                        style={
                            'width': '800px',
                            'height': '500px'
                        }
                    )
                )
            )
        ]
    )
)

app.clientside_callback(
    # 關聯自編js腳本中的相應回調函數
    ClientsideFunction(
        namespace='clientside',
        function_name='switch_chart'
    ),
    Output('main', 'children'),
    Input('chart-type', 'value')
)

if __name__ == '__main__':
    app.run_server(debug=True)

圖8

  效果十分驚人,從此我們使用Dash不僅僅可以使用Python生態的工具,還可以配合對前端內容支持更好的js,起飛!


  至此我們的Dash回調交互三部曲已結束,接下來的文章我將開始帶大家遨遊豐富的各種Dash前端部件,涵蓋了網頁部件、數據可視化圖表以及地圖可視化等內容,敬請期待這場奇妙之旅吧~

  以上就是本文的全部內容,歡迎在評論區與我進行討論。

Tags: