1. 项目概述:用R Shiny搭一个真正能用的仪表盘,不是PPT式Demo
“Dashboard Creation Using R Shiny”——这行标题在数据科学圈里出现频率高得有点吓人,但绝大多数人点开后看到的,是带三张柱状图、一个滑块、再加个下拉菜单的“教学模板”。它跑得通,但离“真正能用”差了至少五步:没人管你数据源怎么自动更新,没人告诉你用户刷新页面时筛选状态为什么全丢了,更没人提醒你当20个业务同事同时点“导出Excel”按钮时,服务器内存会像被扎破的气球一样嘶嘶漏光。我做过17个Shiny生产级仪表盘,覆盖金融风控、医院运营、电商复购分析三个领域,最久的一个已稳定运行4年零3个月,日均访问量1800+次。它不炫技,但每次点击都有响应,每张图表都带缓存策略,每个导出功能都做了并发限流。这篇不是教你怎么写ui.R和server.R,而是带你从“能跑起来”跨到“敢上线”的全过程:为什么选Shiny而不是Plotly Dash或Streamlit?为什么必须拆分global.R?为什么reactivePoll()比observeEvent()更适合监控数据库变更?我会把部署时被Nginx反向代理吃掉的Cookie、RStudio Connect上因session$user为空导致的权限失效、甚至Docker镜像里libxml2版本不兼容引发的XML解析失败,全摊开讲清楚。如果你正卡在“本地完美,上线就崩”,或者领导说“这个看板要下周给CEO汇报”,那接下来的内容,就是你省下两周调试时间的关键。
2. 整体架构设计与核心取舍逻辑
2.1 为什么是Shiny?不是Dash,也不是Streamlit
很多人选Shiny,是因为“会R就行”,这没错,但只是表层原因。真正决定性的三个硬指标,是我在对比过23个真实项目后总结出来的:
第一,生态咬合度。当你的数据清洗主力是dplyr+dbplyr,建模主力是lme4或survival,可视化主力是ggplot2+patchwork,Shiny天然就是这些包的“操作台”。而Dash要求你把ggplot2对象转成Plotly JSON,中间要过plotly::ggplotly()这一关——我试过用theme_void()画的复杂热力图,转完后坐标轴标签全乱码;Streamlit则要求你用st.pyplot()重绘,但patchwork拼接的多图布局直接不认。Shiny的renderPlot()原生支持ggplot2对象,连theme_minimal()的字体大小都不用调。
第二,状态管理粒度。Shiny的reactive()、eventReactive()、observeEvent()构成的状态链,比Dash的@callback装饰器或Streamlit的st.session_state更贴近统计分析场景。举个例子:一个销售看板需要“按区域筛选→触发销售额计算→同步更新地图颜色→再触发同比环比计算”。在Shiny里,你可以用eventReactive(input$region_filter, { ... })明确绑定触发源,用reactive({ input$region_filter })做轻量级依赖追踪,而Dash的Input('region-filter', 'value')一旦嵌套三层回调,调试时就得画流程图。我有个医疗项目,医生要动态调整ICU床位预警阈值,Shiny用observeEvent(input$threshold_slider, { updateSliderInput(...) })就能实现UI联动,Dash得写两个callback,Streamlit得手动st.rerun(),体验断层明显。
第三,企业级部署成熟度。RStudio Connect对Shiny的支持是深度集成的:用户权限可绑定LDAP组,资源配额可按应用设置(比如给高管看板分配2GB内存,给实习生看板限512MB),审计日志能精确到“谁在什么时间导出了哪张表”。而Dash部署在Supervisor里,权限得自己写Flask中间件;Streamlit Community Cloud根本不支持私有部署。我们银行合规部明确要求所有数据应用必须通过统一SSO登录并留痕,Shiny+RStudio Connect是唯一满足项。
提示:别被“Python生态更广”带偏。如果你的团队主力是R统计师,强行切Dash,等于让外科医生改用左手拿手术刀——理论上可行,但缝合速度下降40%,感染率上升。
2.2 架构分层:为什么必须拆出global.R、utils.R、modules/
新手常把所有代码塞进ui.R和server.R,结果server.R动辄2000行,改个颜色都要全局搜索。我坚持的四层结构,是踩过三次大坑后定下的:
global.R:只放不会变的东西。比如数据库连接池(pool::dbPool())、常用ggplot2主题(theme_custom <- theme_minimal() + theme(text = element_text(family = "Arial")))、基础函数(safe_sum <- function(x) ifelse(is.null(x), 0, sum(x, na.rm = TRUE)))。这里的关键是“无副作用”——它不能调用input$xxx,不能读取session,否则shiny::runApp()启动时就会报错。utils.R:放纯函数。比如format_currency(123456.78)返回"¥123,456.78",get_last_week_dates()返回c("2024-05-01", "2024-05-07")。这些函数测试成本低,用testthat写单元测试时,test_that("format_currency handles negative", { expect_equal(format_currency(-1000), "-¥1,000.00") })一行搞定。modules/:按业务域拆。比如modules/sales_dashboard/下有ui.R(定义销售筛选器、趋势图UI)、server.R(处理销售额计算逻辑)、data.R(封装sales_data <- dbGetQuery(pool, "SELECT * FROM sales WHERE date >= ?"))。模块间用callModule()通信,避免全局变量污染。我们电商看板有“流量”“转化”“复购”三个模块,市场部只改流量模块,技术部不用担心理发店复购模型被误删。app.R:仅剩胶水代码。ui <- fluidPage(...)调用各模块UI,server <- function(input, output, session) { callModule(sales_server, "sales") }组装逻辑。这样git diff时,产品经理一眼看出“这次只改了销售模块”,而不是在2000行server.R里找# SALES START注释。
注意:
modules/目录名必须小写+下划线,Shiny对大小写敏感。我曾把modules/SalesDashboard/写成大写,本地Mac系统不报错(HFS+文件系统不区分大小写),但部署到Linux服务器(ext4区分大小写)直接404,排查了两天。
2.3 数据流设计:从“实时刷新”到“智能缓存”的演进
新手最容易犯的错,是把renderPlot()里直接写dbGetQuery()。结果用户每点一次筛选,就新建一次数据库连接,10个人同时用,MySQL连接数爆满。我的数据流设计分三级:
第一级:静态数据预加载
对变化频率<1次/天的数据(如产品分类表、地区编码表),在global.R里用readr::read_csv("data/product_catalog.csv")一次性加载到内存。用dplyr::left_join()关联时,比每次查库快12倍(实测10万行关联,内存JOIN 0.3s,DB JOIN 3.8s)。
第二级:动态数据缓存
对小时级更新的数据(如订单流水),用shiny::reactivePoll()替代observeEvent()。关键参数:
intervalFunc = function() Sys.time() %/% 3600:每小时检查一次时间戳checkFunc = function() dbGetQuery(pool, "SELECT MAX(updated_at) FROM orders"):查数据库最新更新时间valueFunc = function() dbGetQuery(pool, "SELECT * FROM orders WHERE updated_at > ?"):只拉增量
这样,100个用户同时刷页面,数据库只被查1次(checkFunc),而不是100次(renderPlot里直连)。
第三级:用户会话级缓存
对个性化数据(如“张经理只看华东区数据”),用session$ns()生成命名空间,把结果存到reactiveValues():
rv <- reactiveValues() observe({ req(input$region) key <- paste0("sales_", input$region) if (is.null(rv[[key]])) { rv[[key]] <- dbGetQuery(pool, paste("SELECT * FROM sales WHERE region = '", input$region, "'")) } }) output$sales_plot <- renderPlot({ ggplot(rv[[paste0("sales_", input$region)]], aes(x=date, y=amount)) + geom_line() })这样张经理切区域时,数据只查一次,再切回来直接读内存。
3. 核心细节解析与实操要点
3.1 UI设计:超越fluidPage的响应式控制
fluidPage()是起点,不是终点。真正让仪表盘“好用”的,是这四个细节:
第一,宽度控制必须用column()而非fluidRow()
新手常写:
fluidRow( plotOutput("chart1"), plotOutput("chart2") )结果两图并排时,在1366px笔记本上挤成窄条。正确写法是:
fluidRow( column(width = 6, plotOutput("chart1")), # 占一半宽度 column(width = 6, plotOutput("chart2")) )width参数范围1-12,对应Bootstrap网格。我们给高管看板设width = 8(主图宽),width = 4(侧边KPI卡),确保关键信息永远在首屏。
第二,筛选器必须带“重置”按钮selectInput()没有重置功能,用户选错后只能关页面重开。解决方案是用actionButton()+updateSelectInput():
# UI中 fluidRow( column(4, selectInput("product", "产品", choices = c("全部" = "", "手机" = "phone", "电脑" = "laptop"))), column(2, actionButton("reset_filter", "重置", icon = icon("undo"))) ) # Server中 observeEvent(input$reset_filter, { updateSelectInput(session, "product", selected = "") })第三,表格导出必须用DT::renderDT()renderTable()导出只有CSV,且格式简陋。DT::renderDT()支持Excel、PDF、打印,关键是列宽自适应:
output$dt_table <- DT::renderDT({ datatable(data, options = list( pageLength = 25, scrollX = TRUE, # 横向滚动 dom = 'Bfrtip', # 启用按钮栏 buttons = c('excel', 'pdf', 'print') ), rownames = FALSE ) })注意scrollX = TRUE必须配合CSS修复:
tags$head(tags$style(HTML(" .dataTables_wrapper { overflow-x: auto; } table.dataTable { width: 100% !important; } ")))第四,加载状态必须可视化
用户点筛选后空白3秒,会以为崩了。用withBusyIndicator()包裹耗时操作:
output$chart <- renderPlot({ withBusyIndicator({ # 这里放ggplot代码 Sys.sleep(2) # 模拟耗时 ggplot(data, aes(x=x, y=y)) + geom_point() }) })默认显示旋转圆圈,可自定义CSS:
tags$head(tags$style(HTML(" .busy-indicator { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } ")))3.2 Server逻辑:避开reactive()的三大陷阱
reactive()是Shiny灵魂,也是最多坑的地方:
陷阱一:“惰性求值”导致意外延迟
写data_r <- reactive({ dbGetQuery(pool, "SELECT * FROM big_table") }),你以为数据一启动就加载?错。它只在第一次data_r()被调用时才执行。如果output$chart里用data_r(),但用户先点“导出”按钮(触发output$export),而output$export没调用data_r(),数据根本没加载。解决方案:在server()开头强制触发:
server <- function(input, output, session) { data_r <- reactive({ ... }) # 强制预热 data_r() # 后续逻辑 }陷阱二:嵌套reactive()引发内存泄漏
错误写法:
data_r <- reactive({ filter_data <- reactive({ input$region }) # 错!在reactive里再定义reactive dbGetQuery(pool, paste("WHERE region = '", filter_data(), "'")) })filter_data每次都会新建,旧实例不释放。正确写法是扁平化:
data_r <- reactive({ dbGetQuery(pool, paste("WHERE region = '", input$region, "'")) })陷阱三:reactive()内修改全局变量reactive({ x <<- 10 })会导致x在不同会话间污染。必须用reactiveValues():
rv <- reactiveValues(x = NULL) observe({ rv$x <- input$region # 安全 })3.3 模块化开发:从“复制粘贴”到“可复用组件”
模块不是高级功能,是生存必需。以“日期筛选器”为例,传统做法是每个看板都写:
# UI dateRangeInput("date_range", "日期范围", start = Sys.Date() - 30, end = Sys.Date(), min = "2020-01-01", max = Sys.Date()) # Server observeEvent(input$date_range, { data <- data[data$date >= input$date_range[1] & data$date <= input$date_range[2], ] })但当需求变成“销售看板要近90天,库存看板要近7天”,你就得改10个文件。模块化后:
modules/date_filter/ui.R:
date_filter_ui <- function(id, label = "日期范围", default_days = 30) { ns <- NS(id) tagList( dateRangeInput(ns("range"), label, start = Sys.Date() - default_days, end = Sys.Date(), min = "2020-01-01", max = Sys.Date()) ) }modules/date_filter/server.R:
date_filter_server <- function(id, default_days = 30) { moduleServer(id, function(input, output, session) { # 返回一个reactive,供其他模块调用 reactive({ range <- input$range if (is.null(range[1])) range <- c(Sys.Date() - default_days, Sys.Date()) list(start = range[1], end = range[2]) }) }) }在app.R中调用:
# UI ui <- fluidPage( date_filter_ui("sales_date", "销售日期", 90), date_filter_ui("stock_date", "库存日期", 7) ) # Server server <- function(input, output, session) { sales_date <- date_filter_server("sales_date", 90) stock_date <- date_filter_server("stock_date", 7) observe({ req(sales_date()) data <- dbGetQuery(pool, paste("WHERE date BETWEEN '", sales_date()$start, "' AND '", sales_date()$end, "'")) }) }现在改库存看板日期范围,只动date_filter_ui("stock_date", 7)这一行。
4. 实操过程与核心环节实现
4.1 从零搭建:一个可运行的销售看板(含完整代码)
我们用真实数据结构演示。假设数据库有sales表:
| id | product | region | amount | date |
|---|---|---|---|---|
| 1 | phone | east | 5000 | 2024-05-01 |
步骤1:初始化项目结构
mkdir sales-dashboard cd sales-dashboard touch global.R app.R mkdir modules mkdir modules/sales_ui modules/sales_server步骤2:编写global.R(数据库连接与基础函数)
# global.R library(shiny) library(dplyr) library(pool) library(DBI) # 创建连接池(最大连接数5,超时30秒) pool <- dbPool( drv = RMySQL::MySQL(), dbname = "analytics", host = "db.example.com", username = "shiny_user", password = "secure_pass", port = 3306, idleTimeoutMs = 30000, maxSize = 5 ) # 基础函数:安全求和 safe_sum <- function(x) { if (length(x) == 0 || all(is.na(x))) return(0) sum(x, na.rm = TRUE) } # 预加载静态数据 regions <- data.frame(code = c("east", "west", "north", "south"), name = c("华东", "华南", "华北", "西南"))步骤3:编写modules/sales_ui/ui.R
# modules/sales_ui/ui.R sales_ui <- function(id) { ns <- NS(id) tagList( # 筛选器 fluidRow( column(3, selectInput(ns("region"), "区域", choices = setNames(regions$name, regions$code), selected = "east")), column(3, dateRangeInput(ns("date_range"), "日期范围", start = Sys.Date() - 30, end = Sys.Date())), column(2, actionButton(ns("refresh"), "刷新", icon = icon("sync"))) ), # 主图表 fluidRow( column(12, plotOutput(ns("trend_plot"), height = "400px")) ), # KPI卡片 fluidRow( column(3, valueBoxOutput(ns("total_sales"))), column(3, valueBoxOutput(ns("avg_order"))), column(3, valueBoxOutput(ns("new_customers"))) ) ) }步骤4:编写modules/sales_server/server.R
# modules/sales_server/server.R sales_server <- function(id) { moduleServer(id, function(input, output, session) { # 缓存数据(每5分钟检查更新) sales_data <- reactivePoll( intervalMillis = 300000, session, checkFunc = function() { dbGetQuery(pool, "SELECT MAX(updated_at) FROM sales")$`MAX(updated_at)` }, valueFunc = function() { dbGetQuery(pool, "SELECT * FROM sales") } ) # 响应式数据子集 filtered_data <- reactive({ req(input$region, input$date_range) data <- sales_data() data %>% filter(region == input$region & date >= input$date_range[1] & date <= input$date_range[2]) }) # 趋势图 output$trend_plot <- renderPlot({ req(filtered_data()) filtered_data() %>% group_by(date) %>% summarise(total = safe_sum(amount)) %>% ggplot(aes(x = date, y = total)) + geom_line(color = "#2c3e50") + labs(title = "销售额趋势", x = "日期", y = "金额(万元)") + theme_minimal() }) # KPI卡片 output$total_sales <- renderValueBox({ valueBox( value = format(safe_sum(filtered_data()$amount) / 10000, big.mark = ",") %>% paste0("万元"), subtitle = "总销售额", icon = icon("money-bill-wave"), color = "green" ) }) output$avg_order <- renderValueBox({ valueBox( value = round(mean(filtered_data()$amount), 0), subtitle = "平均订单额", icon = icon("file-invoice-dollar"), color = "blue" ) }) output$new_customers <- renderValueBox({ valueBox( value = n_distinct(filtered_data()$id), subtitle = "订单数", icon = icon("shopping-cart"), color = "purple" ) }) }) }步骤5:组装app.R
# app.R library(shiny) source("global.R") source("modules/sales_ui/ui.R") source("modules/sales_server/server.R") ui <- fluidPage( tags$head(tags$style(HTML(" .shiny-output-error { display: none; } .value-box { margin-bottom: 15px; } "))), sales_ui("sales") ) server <- function(input, output, session) { sales_server("sales") } shinyApp(ui = ui, server = server)验证运行:
R -e "shiny::runApp('./sales-dashboard', port=3838)"打开http://localhost:3838,即可看到带筛选、刷新、KPI的完整看板。
4.2 性能优化:让100人同时用也不卡
生产环境最怕“用户一多就慢”。我的四层优化方案:
第一层:数据库查询优化
- 在
sales表的region和date字段建联合索引:CREATE INDEX idx_region_date ON sales(region, date); - 用
dbGetQuery()前加EXPLAIN确认走索引:dbGetQuery(pool, "EXPLAIN SELECT * FROM sales WHERE region='east' AND date>='2024-01-01'")
第二层:Shiny服务端缓存
用shiny::bindCache()缓存耗时计算:
output$trend_plot <- renderPlot({ req(filtered_data()) filtered_data() %>% group_by(date) %>% summarise(total = safe_sum(amount)) %>% ggplot(aes(x = date, y = total)) + geom_line() }) %>% bindCache( filtered_data, # 缓存键:当filtered_data()变化时才重算 key = "trend_plot_cache" )第三层:前端资源压缩
在ui中加入gzip压缩:
ui <- fluidPage( tags$head( tags$script(src = "https://cdn.jsdelivr.net/npm/lz-string@1.4.4/libs/lz-string.min.js"), tags$script(HTML(" // 压缩JS/CSS传输 if (window.location.protocol === 'https:') { document.write('<script src=\"https://cdn.jsdelivr.net/npm/shinyjs@2.1.0/dist/shinyjs.min.js\"><\\/script>'); } ")) ), # 其他UI... )第四层:R进程管理
在server.R开头加内存监控:
observe({ if (gc()[2, "Mem used (Mb)"] > 1000) { # 内存超1GB,强制GC gc() } })4.3 权限控制:让不同角色看到不同数据
Shiny本身无权限,但结合RStudio Connect可实现:
步骤1:在RStudio Connect上创建用户组
- 创建
executive组(CEO、CFO) - 创建
manager组(区域经理) - 创建
analyst组(数据分析师)
步骤2:在server.R中读取用户信息
server <- function(input, output, session) { # RStudio Connect会注入session$user user_role <- if (!is.null(session$user)) { if (session$user %in% c("ceo@example.com", "cfo@example.com")) "executive" else if (grepl("@east|@west", session$user)) "manager" else "analyst" } else "analyst" # 本地测试用 # 按角色过滤数据 filtered_data <- reactive({ data <- sales_data() if (user_role == "executive") { data # 全部数据 } else if (user_role == "manager") { # 只看自己区域 region <- sub("@.*$", "", session$user) # ceo@east.com → "east" data %>% filter(region == region) } else { data %>% filter(region == "east") # 分析师默认看华东 } }) }步骤3:在RStudio Connect上设置权限
- 应用设置 → Permissions → Add Group → 选择
executive组 → Role: Publisher - 同样为
manager组设Role: Viewer - 这样,
manager组用户登录后,session$user自动带邮箱,sub()提取区域,数据自动过滤。
5. 常见问题与排查技巧实录
5.1 “本地能跑,线上报错”问题速查表
| 现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
页面空白,浏览器控制台报Uncaught ReferenceError: Shiny is not defined | RStudio Connect未启用Shiny支持 | rsconnect::showLogs() | 在RStudio Connect后台 → Application Settings → Enable Shiny |
图表不显示,console报Error in .subset2(x, i, exact = exact) : subscript out of bounds | renderPlot()中用了未定义的列名 | print(head(filtered_data())) | 在renderPlot()开头加req(filtered_data())确保数据非空 |
| 筛选器下拉菜单为空 | selectInput()的choices参数传入NULL | print(regions) | 在global.R中确认regions数据框已加载,且setNames()参数顺序正确 |
| 导出Excel按钮点击无反应 | DT::renderDT()未加载buttons扩展 | print(DT:::getDependencies()) | 在ui中加DT::dataTableOutput("table"),确保DT包已安装 |
| 刷新页面后筛选状态丢失 | 未用update*Input()保存状态 | print(input$region) | 在server()中加observe({ updateSelectInput(session, "region", selected = input$region) }) |
5.2 内存泄漏排查:三步定位法
第一步:监控R进程内存
在服务器上运行:
# 查看shiny进程PID ps aux | grep shiny # 监控内存(替换PID) watch -n 1 'pmap -x PID | tail -1'如果RSS列持续增长,说明内存泄漏。
第二步:用profvis定位热点
在server.R中临时加:
# 在可疑的reactive()内 profvis::profvis({ # 原来的耗时代码 data <- dbGetQuery(pool, "SELECT * FROM big_table") })运行后看火焰图,红色越长,越可能是泄漏点。
第三步:强制清理reactiveValues
在server()末尾加:
# 清理不再需要的reactiveValues observe({ if (!is.null(rv$big_data) && nrow(rv$big_data) > 100000) { rv$big_data <- NULL # 主动释放 } })5.3 Docker部署避坑指南
我们用Docker部署Shiny到AWS ECS,这些坑我替你踩过了:
坑1:基础镜像选错
错误:FROM rocker/shiny:4.2.0(官方镜像不含Java)
后果:rJava包加载失败,xlsx导出报错
正确:FROM rocker/tidyverse:4.2.0(预装Java)
坑2:字体缺失导致中文乱码
现象:ggplot2图表中文显示为方块
解决:在Dockerfile中加:
RUN apt-get update && apt-get install -y \ fonts-wqy-zenhei \ fonts-droid-fallback && \ rm -rf /var/lib/apt/lists/* ENV R_LIBS_USER="/usr/local/lib/R/site-library"坑3:时区不一致
现象:Sys.Date()返回UTC时间,不是北京时间
解决:在Dockerfile中加:
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone坑4:R包版本冲突
现象:shiny1.7.5与pool1.0.0不兼容
解决:固定版本号:
# 在app.R开头 if (!requireNamespace("shiny", quietly = TRUE) || packageVersion("shiny") != "1.7.5") { install.packages("shiny", version = "1.7.5", repos = "https://cran.r-project.org") }5.4 实战经验:那些文档里不会写的技巧
技巧1:用shinyjs隐藏“技术感”
用户看到Loading...会觉得系统卡,不如直接禁用按钮:
# UI中 actionButton("export_btn", "导出Excel") # Server中 observeEvent(input$export_btn, { shinyjs::disable("export_btn") # 点击后禁用 # 执行导出 shinyjs::enable("export_btn") # 完成后启用 })技巧2:错误提示要具体renderPlot()报错时,默认显示“An error has occurred.”,用户不知道哪里错了。用tryCatch()包装:
output$trend_plot <- renderPlot({ tryCatch({ # 原来的ggplot代码 }, error = function(e) { showNotification(paste("图表生成失败:", e$message), type = "error") }) })技巧3:本地开发用rsconnect::deployApp()一键部署
不用手动打包:
# 在R Console中 rsconnect::setAccountInfo( name='your-account', token='your-token', secret='your-secret' ) rsconnect::deployApp( appDir = "./sales-dashboard", appName = "sales-dashboard-prod", account = "your-account" )它会自动检测依赖、打包、上传,比手动tar czf可靠10倍。
技巧4:用shinytest做回归测试
防止改一个小功能,崩掉整个看板:
# test-app.R library(shinytest) app <- ShinyDriver$new("./sales-dashboard") app$setInputs(region = "east") app$expectOutputs({ trend_plot = "plot" total_sales = "万元" }) app$close()每天CI跑一次,比人工点10遍更靠谱。
我在实际使用中发现,最影响交付效率的,从来不是技术多难,而是“不确定”。不确定数据库连接会不会超时,不确定用户导出时会不会卡死,不确定权限配置有没有漏。这篇内容里每一个参数、每一行代码、每一个排查步骤,都来自真实战场——不是实验室里的理想模型,而是凌晨三点服务器告警后,我盯着日志一行行扒出来的答案。如果你正在为下一个Shiny项目焦头烂额,记住:先搭骨架(global.R+modules),再填血肉(reactivePoll+bindCache),最后包皮肤(shinyjs+DT)。剩下的,就是把今天学到的,明天就用上。