1. 项目背景与整体设计思路
最近给一个运营后台做了两个刚需功能:订单明细导出 Excel、商品资料批量导入 Excel。项目是基于 Yii 2 的老系统,PHP 版本不高不低,前端一部分是最传统的服务端渲染页面,另一部分是独立部署的 Web 前端,通过接口取数。所以这次要把 PhpSpreadsheet 的生成 Excel 和导入 Excel 在前后端未分离、前后端分离两种模式下都跑通,不能只做一个版本糊弄过去。
先说结论:PhpSpreadsheet 是目前 PHP 生态里处理 Excel 最主流的库,生成 xlsx、读取 xlsx、给单元格上样式、处理日期,全都靠它。真正费时间的不是"调用库",而是格式转换、数据校验、响应输出的各种边角问题。这篇文章把我实际调试中确认过的写法、踩过的坑,还有两种架构下的代码组织方式完整记录下来,给准备在 Yii 项目里接 Excel 功能的人一个参照。
1.1 需求拆解:导出和导入到底在解决什么问题
导出这件事,本质上是把数据库里的一批记录变成运营能直接双击打开的 Excel 文件。看起来只是"查出来、写进去",实际要处理的细节包括:表头长什么样、时间显示成什么格式、金额能不能被 Excel 直接求和、数字超长会不会变成科学计数法、一次导几万条会不会把内存打爆。
导入则刚好反过来:运营手里有一份 Excel,里面可能是别人整理的商品资料、订单状态更新,甚至是从其他系统导出的备份。我们的任务是把这些行数据读出来,逐行做格式校验和业务校验,最后写成数据库记录。这里最容易出问题的地方是:Excel 里的日期其实是一串数字、小数精度会被浮点数搞乱、空行和带格式的"假空行"混在数据里、某一行报错要不要回滚整批。
除了这两个核心能力,还需要考虑操作入口长什么样。未分离模式下,页面就是一个表单加一个按钮,点击后浏览器直接下载文件;上传则是选完文件点提交,页面刷新后显示结果。分离模式下,下载要交给前端 JS 去拉二进制流再触发保存,上传要用 FormData 走接口,错误信息也不能靠页面刷新来展示。
1.2 为什么选 PhpSpreadsheet,而不是其他方案
我是从 PHPExcel 时代用过来的。说实话 PHPExcel 当年能用,但老项目连命名空间都是乱的,官方也早就宣布停止维护。PhpSpreadsheet 就是它的继承者,设计思路几乎一脉相承:一个 Spreadsheet 对象代表整个文档,里面有 Worksheet,操作单元格就是 setCellValue。从 PHPExcel 迁过来,主要是改类名和命名空间,心智模型几乎不用变。
也有人会说,导出数据为什么不直接生成 CSV?CSV 确实简单,但有两个硬伤。一个是运营拿到的文件还要继续做数据透视、调格式、加颜色,CSV 打开后全是一堆逗号分隔文本,体验基本为零;另一个是中文编码问题,CSV 的 UTF-8 和 Excel 默认的 ANSI 不一定兼容,处理不好运营拿到的就是乱码文件。同理,之前我还见过有人用 HTML 表格伪装成 .xls 文件输出,那个方案偶尔也能打开,但 Excel 会弹格式不匹配警告,新版本 Office 甚至会直接拒绝打开。所以正经做功能,尤其是面向非技术同事的功能,还是老老实实生成真正的 xlsx 文件。
1.3 两种架构的差异决定了代码怎么写
未分离版和前后端分离版的本质区别,不在 PhpSpreadsheet 怎么用,而在文件怎么交到用户手里。
未分离版是最传统的浏览器同步请求模型:用户点击导出按钮,浏览器发起一次完整的页面请求,后端在响应头里带上 Content-Type 和 Content-Disposition,文件流通过 php://output 直接灌给浏览器,用户立刻看到下载。上传也一样,表单 POST 提交后整个页面刷新,结果通过 flash 消息展示。
分离版则要配合前端框架或者原生 JS:下载时用 fetch 或 axios 发起异步请求,后端返回的是一段二进制流,前端拿到后转成 Blob,再创建一个临时的 标签触发下载。上传时把文件塞进 FormData,走 AJAX 提交,后端返回 JSON,前端根据 code 字段决定显示成功还是失败提示。
这两种模式对后端代码的要求差别很大,尤其是错误处理和数据返回格式。所以我从一开始就没有把逻辑堆在控制器里,而是先做了一层封装。这点在后文具体展开。
2. 环境准备:安装库与设计一个通用的 Excel 服务层
2.1 Composer 安装与版本锁定
在 Yii 2 项目里装 PhpSpreadsheet 非常简单,一条命令搞定:
composer require phpoffice/phpspreadsheet这里提醒一句:很多人会把包名拼错,搜"PhpSpreadsHeet"这种带错别字的写法自然找不到,正确的包名是phpspreadsheet,注意中间没有多余的字母。命名空间也很固定,是PhpOffice\PhpSpreadsheet。
版本选择上建议大家安装时看一眼当前 PHP 版本。PhpSpreadsheet 新的大版本对 PHP 版本有要求,如果你的服务器还是 PHP 7.2 或 7.4 这种老版本,最好在安装时指定一个大版本范围,避免 composer 解析依赖时直接装上跑不动的版本。老项目升级 PHP 往往是大事,别为了一个 Excel 库去动运行环境,选定一个兼容版本之后在 composer.json 里锁住就行。
2.2 在 Yii 中把 Excel 逻辑放到服务层
很多人一提到 Yii 里写 Excel 功能,第一反应是直接在控制器里写一坨:查询、拼表头、写单元格、输出文件全部堆在一个 action 里。这种写法在小功能上看着省事,但实际上很坑。原因很简单,导出会分两种模式,导入也有不同入口,如果逻辑都堆在控制器里,后面做分离版接口时你只能复制粘贴一大段代码,改一处漏一处。
我的做法是先建一个专门的服务类,比如ExcelExportService和ExcelImportService。导出服务只负责两件事:接收业务数据、生成 Spreadsheet 对象。它不关心你是要输出给浏览器还是保存成文件,也不关心前端是什么模式。导入服务同理,只负责读取文件、解析行数据、做基础格式转换,不关心数据从哪个入口来的。
这样做的好处,等我写分离版的时候体会特别深。控制器变成薄薄的一层,只做参数接收和响应输出,核心业务逻辑完全复用。整个需求做完,未分离版和分离版的代码重复率降到最低,维护起来也清爽很多。
2.3 服务层的基本约定:行列数组作为中间格式
服务层设计里有一个关键约定:业务数据在进入 Excel 服务之前,先统一转为"行数组"格式,就是 PHP 数组,第一行是表头,后面的每一行是一条记录,一行本身又是一个索引数组,按列的顺序排列。
为什么要这样约定?因为 PhpSpreadsheet 写入单元格的 API 是setCellValue($cell, $value),需要你手动管理行号和列号。如果直接在业务查询里写这些,代码会被坐标计算占满。转成行数组之后,服务层可以用一个简单的双层循环把数据填进去,行号自动递增。导入的时候反过来,Excel 解析出来的行数组经过格式转换后,再交给业务逻辑去入库。
这个模式帮我解决了一个很实际的问题:导出的列顺序、列名经常要调整。运营说要加一列、改个表头,我只需要改行数组的拼装位置,完全不用碰 Excel 写入逻辑。导入模板也是同理,表头发生变化时,行数组的下一个字段自然对应上来,比硬编码"第几列是价格"要稳得多。
3. 未分离版:Excel 导出的全流程落地
3.1 一个完整的导出控制器长什么样
未分离版最核心的场景是:运营在订单列表页点击"导出订单",后端把符合条件的订单全部查出来生成 xlsx,浏览器直接下载。我用 Yii 2 的 controller 写一个完整例子。
先看控制器动作:
public function actionExport() { // 这里可以接收查询参数,比如时间范围、订单状态 $dateFrom = Yii::$app->request->get('dateFrom', ''); $dateTo = Yii::$app->request->get('dateTo', ''); // 查询订单,按未导出的大数据量场景处理 $query = Order::find() ->select(['order_no', 'customer_name', 'amount', 'status', 'created_at']) ->orderBy(['created_at' => SORT_DESC]); if ($dateFrom) { $query->andWhere(['>=', 'created_at', strtotime($dateFrom . ' 00:00:00')]); } if ($dateTo) { $query->andWhere(['<=', 'created_at', strtotime($dateTo . ' 23:59:59')]); } // 调用服务层生成 Excel 文件,返回 Spreadsheet 对象 $spreadsheet = Yii::createObject([ 'class' => ExcelExportService::class, 'query' => $query, ])->buildOrderSheet(); // 用临时文件方式输出,避免直接往浏览器写流时被其他输出污染 $tmpPath = tempnam(sys_get_temp_dir(), 'export_') . '.xlsx'; $writer = new Xlsx($spreadsheet); $writer->save($tmpPath); return Yii::$app->response->sendFile($tmpPath, '订单导出_' . date('YmdHis') . '.xlsx') ->deleteFileAfterSend(true); }这个写法有几个地方容易踩坑,我逐个说。
第一,临时文件是我踩出来的经验。早期我直接在控制器里用header()然后$writer->save('php://output'),后来发现只要页面里有一个调试输出、一段空行、甚至 BOM 头,生成的 xlsx 就会被打进多余字符,用户下载下来根本打不开。改用sendFile加deleteFileAfterSend(true)之后,输出交给 Yii 的 Response 组件,干净利落,临时文件也会在发送完自动清理。
第二,查询条件不能一把梭查全部。订单表往往几十万行起步,导出的本质上是一次全量导出的话,必须分批处理。后面我专门讲大数据量分批导出怎么写。
第三,文件名里的中文。Yii 的sendFile会自动处理Content-Disposition里的文件名编码,大多数现代浏览器都能正常识别。但如果你的用户群体里有比较老的浏览器,稳妥的做法是文件名用 ASCII 或者让前端下载属性来指定。这个细节我在常见问题里还会展开。
3.2 服务层内部:怎样生成一张表头精美、数据可用的工作表
上面控制器里调用的buildOrderSheet(),核心逻辑是在服务层里完成的。生成一张能看的 Excel,至少要做四件事:写入表头、写入数据、设置样式、调整列宽。
简单版本是这样的:
public function buildOrderSheet(): Spreadsheet { $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('订单明细'); // 表头 $headers = ['订单号', '客户名称', '订单金额', '订单状态', '下单时间']; foreach ($headers as $index => $header) { $column = chr(65 + $index); // A, B, C... $sheet->setCellValue($column . '1', $header); } // 数据行 $rowNum = 2; $statusMap = [ Order::STATUS_UNPAID => '未支付', Order::STATUS_PAID => '已支付', Order::STATUS_SHIPPED => '已发货', Order::STATUS_FINISHED => '已完成', ]; foreach ($this->query->each() as $order) { $sheet->setCellValueExplicit('A' . $rowNum, $order->order_no, DataType::TYPE_STRING); $sheet->setCellValueExplicit('B' . $rowNum, $order->customer_name, DataType::TYPE_STRING); $sheet->setCellValue('C' . $rowNum, (float)$order->amount); $sheet->setCellValueExplicit('D' . $rowNum, $statusMap[$order->status] ?? (string)$order->status, DataType::TYPE_STRING); $sheet->setCellValueExplicit('E' . $rowNum, date('Y-m-d H:i:s', $order->created_at), DataType::TYPE_STRING); $rowNum++; } // 表头样式 $headerRange = 'A1:E1'; $sheet->getStyle($headerRange)->getFont()->setBold(true); $sheet->getStyle($headerRange)->getFill() ->setFillType(Fill::FILL_SOLID) ->getStartColor()->setARGB('FFD9EAD3'); // 列宽 $sheet->getColumnDimension('A')->setWidth(22); $sheet->getColumnDimension('B')->setWidth(18); $sheet->getColumnDimension('C')->setWidth(14); $sheet->getColumnDimension('D')->setWidth(12); $sheet->getColumnDimension('E')->setWidth(20); // 冻结首行,方便运营滚动看表头 $sheet->freezePane('A2'); return $spreadsheet; }这里有几个必须注意的细节。
订单号必须用setCellValueExplicit加DataType::TYPE_STRING写入。原因很简单,订单号通常是一长串数字,如果直接setCellValue,Excel 会把它当成数字,超过一定位数就自动显示成科学计数法,比如1.23457E+17。写进去的时候明明没问题,用户打开一看全乱套了。同理客户名称如果开头有=号或者特殊字符,也可能被 Excel 解析成公式,强制指定文本类型可以避免这类问题。
金额列我特意没转字符串,直接用(float)$order->amount写入。因为如果先number_format再写入,Excel 里存的就是文本,运营想用 SUM 求和会算不出来。正确做法是存数值,再通过设置 format code 来控制显示,比如#,##0.00。这样底层是真正的数字,显示又能带千分位和小数点。用的时候别忘了加这一行:
$sheet->getStyle('C2:C' . ($rowNum - 1)) ->getNumberFormat() ->setFormatCode('#,##0.00');3.3 大数据量导出:一次查全部会直接把内存打爆
导出功能最常见的翻车场景就是数据量大。几万行以内其实问题不大,但订单表动辄几十万行,如果直接Order::find()->all()拿模型数组,每个模型对象占用的内存都不小,几十万个对象加在一起,PHP 的内存上限根本扛不住。
Yii 2 提供了现成的批处理方案:batch()和each()。它们做的事情是每次从数据库取一小批,比如 100 条或 500 条,处理完之后释放掉,再取下一批。用 AR 的批处理不会一次性把所有数据加载到内存里。
我习惯于这样用:
foreach ($this->query->batch(500) as $orders) { foreach ($orders as $order) { // 写入单元格... } // 注意:每次取完 500 条后,这些模型会在下一次迭代前被垃圾回收机制释放 }把 batch 的每批条数控制在 500 左右,内存占用和数据库查询次数都比较均衡。如果你用 each 直接迭代,效果类似,但 each 是一次一条,写单元格的循环结构不如 batch 好控制。
还有一个被忽略的内存杀手:Excel 写入公式默认会自动重算。如果表里没有公式,可以在创建 Writer 之后关掉预计算,省下不少内存和时间:
$writer = new Xlsx($spreadsheet); $writer->setPreCalculateFormulas(false);数据量确实很大的情况下,还可以考虑分多个 sheet 分页导出,比如每 5 万行开一个新的工作表,避免单个 sheet 行数过多导致 Excel 打开缓慢。这个要看实际业务量级,不是必须的。
4. 未分离版:Excel 导入的全流程落地
4.1 上传表单与文件校验
导入功能的前端形式就是一个普通的表单,用户选择文件,点击提交,页面刷新显示结果。Yii 2 里用模型规则做文件校验最省事,我建了一个专门的导入表单模型:
class ProductImportForm extends Model { public $excelFile; public function rules() { return [ [ ['excelFile'], 'file', 'extensions' => ['xlsx'], 'maxSize' => 5 * 1024 * 1024, 'skipOnEmpty' => false, ], ]; } }控制器里接收文件的方式是用UploadedFile::getInstance($model, 'excelFile'),注意这里的第二个参数要和表单字段名对应。在 ActiveForm 里,文件输入框的 name 会自动生成ProductImportForm[excelFile]这种格式,所以getInstance能直接拿到上传的文件对象。
文件校验这里有个经验:表单规则里的extensions只检查文件名的后缀,不检查真实文件内容。也就是说有人把一个改名成.xlsx的文本文件传上来,规则也会通过。当然 PhpSpreadsheet 在读取时大概率会抛异常,所以最终防线是让库自己去解析,解析失败就报错;但为了更好的用户体验,可以先通过$file->size判断文件大小是否为 0,再用mime_content_type($file->tempName)做一次粗筛,最后才交给 PhpSpreadsheet。
4.2 读取 Excel 数据并逐行转换格式
拿到上传文件后,读取 xlsx 内容其实很简单。我建议在加载时直接开启"只读数据"模式,这样 PhpSpreadsheet 不会去解析样式、图表等无用信息,内存占用会低很多:
$reader = new \PhpOffice\PhpSpreadsheet\Reader\Xlsx(); $reader->setReadDataOnly(true); $reader->setReadEmptyCells(false); $spreadsheet = $reader->load($file->tempName); $sheetData = $spreadsheet->getActiveSheet()->toArray();toArray()会把整个工作表转成二维数组,第一行是表头,从第二行开始才是业务数据。这个数组大小由 Excel 的实际使用范围决定,一般情况下够用。如果文件特别大,后面会提专门的分块读取过滤方案。
拿到二维数组后,先把表头行去掉,然后逐行处理。这里有个高频坑:Excel 里的"看起来是空行但其实不是空行"的情况。比如某一行只有边框样式没有内容,toArray()会给它返回一个全是null或空字符串的数组;如果开了setReadEmptyCells(true),那些本来没任何东西的单元格也会返回null。处理办法是跳过"所有字段都是空"的行:
$dataRows = array_slice($sheetData, 1); // 去掉表头 foreach ($dataRows as $row) { // 去掉首尾空白后判断是否整行为空 $filtered = array_map('trim', $row); if (empty(array_filter($filtered, fn($v) => $v !== ''))) { continue; } // 处理真正的业务数据 }接下来是字段级的格式转换。特别是日期列,Excel 里存的是日期序列号,比如 2024-05-01 在 xlsx 内部可能存成一串数字 45413 之类的。PhpSpreadsheet 提供了一个专门的转换工具类:
if (is_numeric($value) && $value > 0) { $date = \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($value); $dateString = $date->format('Y-m-d H:i:s'); }如果 Excel 单元格本身是文本格式,日期读出来就是字符串,这两种情况都要能处理。我在导入逻辑里通常会写一个normalizeDateTime($value)函数,先判断是数字还是字符串,分别走转换分支。
4.3 事务写入、行级校验与错误反馈
导入数据入库最怕的是半路出错,前面写了一批,后面报错,数据对不上。我的做法是先用事务把整个导入过程包起来,任何一行数据有问题就整体回滚。
事务怎么用:
$transaction = Yii::$app->db->beginTransaction(); try { foreach ($dataRows as $row) { // 业务校验,失败则抛异常 $product = new Product(); $product->name = $row[1]; $product->price = (float)$row[2]; $product->stock = (int)$row[3]; if (!$product->save()) { throw new \Exception('第 ' . ($index + 1) . ' 行保存失败:' . implode(',', $product->getFirstErrors())); } } $transaction->commit(); Yii::$app->session->setFlash('success', '导入成功,共导入 ' . $count . ' 条记录'); } catch (\Exception $e) { $transaction->rollBack(); Yii::$app->session->setFlash('error', $e->getMessage()); }直接抛异常回滚的好处是保证数据一致性,但用户体验比较粗暴:第 100 行出错了,前面 99 行也一起回滚,运营得把错改完重新传。更高级一点的做法是把校验和入库分成两个阶段:第一遍先做全量校验,收集所有错误行号,一次性返回给用户;全部通过之后再开事务写入。这样运营不用反复传文件。代价是代码量会多不少,但如果导入功能是高频使用的,非常值得做。
另外一个细节是批量插入和逐行 save 的取舍。如果一次导入几千行,逐行save()会创建几千个模型实例,速度慢不说,Yii 的验证开销也不小。可以把每条数据先转成数组,用Yii::$app->db->createCommand()->batchInsert(...)分批批量插入。当然这就要求入库前已经做好了完整的数据校验和字段映射。
5. 前后端分离版:下载与上传的交互改造
5.1 下载接口:后端返回二进制流,前端用 Blob 接收
前后端分离模式下,导出不再依赖浏览器同步提交,而是一个异步接口。后端代码其实和未分离版很像,只是不再从页面获取参数,而是从接口参数获取,响应部分直接把文件流下发。
先定义下载接口的动作。为了避免复杂的前端名称兼容问题,推荐后端用固定文件名,前端在下载时自己指定下载文件名:
public function actionApiExport() { $params = Yii::$app->request->get(); $query = Order::find()->select(['order_no', 'customer_name', 'amount', 'status', 'created_at']); // 根据 $params 加过滤条件,和未分离版相同 $spreadsheet = Yii::createObject([ 'class' => ExcelExportService::class, 'query' => $query, ])->buildOrderSheet(); $tmpPath = tempnam(sys_get_temp_dir(), 'export_') . '.xlsx'; $writer = new Xlsx($spreadsheet); $writer->save($tmpPath); return Yii::$app->response->sendFile($tmpPath, 'api_export.xlsx') ->deleteFileAfterSend(true); }前端用 fetch 请求这个接口时,注意别直接用 JSON 解析。正确的姿势是把响应转成 Blob:
fetch('/api/order/export?dateFrom=2024-01-01&dateTo=2024-06-30', { headers: { 'Authorization': 'Bearer ' + token, 'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', } }) .then(async res => { const contentType = res.headers.get('Content-Type') || ''; if (contentType.includes('application/json')) { const err = await res.json(); throw new Error(err.message || '导出失败'); } return res.blob(); }) .then(blob => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `订单导出_${new Date().getTime()}.xlsx`; document.body.appendChild(a); a.click(); URL.revokeObjectURL(url); a.remove(); }) .catch(err => { alert(err.message); });这里有个特别容易踩的坑:后端如果返回的是业务错误,比如"没有权限"或者"查询条件有误",通常响应格式是 JSON{code: 403, message: '...'},但是你的fetch里如果直接res.blob(),前端拿到的就是一个伪装成文本的 Blob,完全解析不出错误信息。所以一定要像上面那样,先判断Content-Type是不是 JSON,是 JSON 就调res.json()取错误信息,否则才当二进制流处理。
接口鉴权方面,Yii 2 的 controller 默认开启 CSRF 验证,但 API 接口通常不需要 CSRF,否则前端发 POST 请求会因为缺少 csrf token 报 400。解决方式是在 API 控制器里关闭 CSRF 验证,同时通过行为配置加上HttpBearerAuth或者自定义的 token 鉴权:
public function init() { parent::init(); $this->enableCsrfValidation = false; } public function behaviors() { return array_merge(parent::behaviors(), [ 'authenticator' => [ 'class' => HttpBearerAuth::class, ], ]); }5.2 上传接口:FormData 提交与后端接收
导入的分离版同样简单,前端把文件放进 FormData 提交:
const formData = new FormData(); formData.append('excelFile', fileInput.files[0]); fetch('/api/product/import', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, }, body: formData }) .then(res => res.json()) .then(data => { if (data.code === 0) { alert('导入成功,共导入 ' + data.count + ' 条记录'); } else { alert(data.message); } });后端接收时注意不要用getInstance($model, 'excelFile')那套表单模型方式,因为前端 FormData 里的字段名并没有套上模型前缀。直接用UploadedFile::getInstanceByName('excelFile')就能取到:
public function actionApiImport() { $file = UploadedFile::getInstanceByName('excelFile'); if (!$file) { throw new BadRequestHttpException('未收到文件'); } // 基础校验 if ($file->extension !== 'xlsx' || $file->size === 0) { throw new BadRequestHttpException('仅支持 xlsx 文件'); } // 读取、校验、入库逻辑与未分离版完全一致 $importService = Yii::createObject(ExcelImportService::class); $result = $importService->importFromPath($file->tempName); // 返回 JSON Yii::$app->response->format = Response::FORMAT_JSON; return [ 'code' => 0, 'message' => '导入成功', 'count' => $result['count'], 'errors' => $result['errors'] ?? [], ]; }有了服务层,导入的核心逻辑在这里几乎不写第二遍,只是把错误提示从 flash 消息改成了 JSON 返回。这也是我前面强调服务层封装的意义所在。
5.3 两种模式的代码复用:控制器只做薄薄一层
把未分离版和分离版放在一起对比,你会发现服务层代码完全不用改,区别全在控制器层。未分离版的导出控制器从 request 拿参数,然后直接用 Yii 的 Response 发送文件;分离版同样是拿参数、发文件,只是多了鉴权行为和关闭 CSRF。导入也是同样的模式,未分离版返回 flash,分离版返回 JSON。
如果一开始就把逻辑堆在控制器里,现在你就要维护两套几乎相同的 Excel 处理代码,后续任何 Excel 行为的调整都得改两个地方。而服务层方案下,Excel 相关的所有规则、格式转换、校验逻辑都集中在一个类里,改一处两边生效。这种"薄控制器 + 胖服务"的组织方式,我认为是 Yii 项目里接 Excel 功能最健康的结构。
6. 常见问题与排查技巧实录
6.1 下载的 Excel 打开提示"文件格式或扩展名无效"
这是我遇到最多的一个问题,尤其是在从手写header()输出改成sendFile之前。原因是 XLSX 本质是一个 ZIP 压缩包,如果文件流里混进了任何其他字符,Excel 在解压时就识别不出来。常见的污染源有几个:控制器里或框架里意外输出的 BOM 头、调试时var_dump留下的空格、PHP 开启display_errors后抛出的警告文本。
排查思路很简单:下载下来的文件用编辑器打开看看,如果开头是PK那就是正常的 xlsx 文件流,如果开头多了一串奇怪字符,就说明输出被污染了。处理方式就是不要自己管理输出,统一交给Yii::$app->response->sendFile(),并且确保执行到这个动作之前没有任何echo或print。
6.2 中文文件名下载乱码
文件名乱码的根本原因是Content-Disposition头里对中文的处理方式。老标准用filename="中文.xlsx",很多浏览器不认;新标准引入了filename*=UTF-8''的 RFC 5987 格式。Yii 的sendFile默认会处理这件事,现代主流浏览器问题不大。但如果你的项目要兼容某些老的内核浏览器,或者前端拿到的下载文件名不对,最省事的办法是我前面说的:后端文件名固定用 ASCII,显示给用户的中文文件名完全由前端的a.download属性决定。未分离版受制于浏览器行为,就只能依赖 Yii 的默认处理,实测下来 Edge、Chrome、Firefox 都正常。
6.3 日期列读出来是一串 40000 多的数字
这是 Excel 和 PHP 之间的经典认知差。Excel 内部的时间是从 1900 年 1 月 1 日开始算的序列号,所以你在 PhpSpreadsheet 里直接拿到的单元格值很可能不是日期字符串,而是一个浮点数。解决办法前面已经写过,用Date::excelToDateTimeObject()转成 DateTime 对象再格式化。还有一个容易被忽视的情况:如果 Excel 单元格本身没设置日期格式,只是你"觉得"它是日期,PhpSpreadsheet 读出来的可能就是原始字符串或者文本,所以格式化前先判断类型是数字还是字符串。
6.4 读取超大 Excel 时内存溢出
Excel 文件在磁盘上不大,但加载到 PHP 内存后会膨胀得很厉害,尤其当里面有一堆样式、图片、图表的时候。常用的三板斧:
第一,读取时开启只读数据模式,setReadDataOnly(true),跳过所有样式解析。第二,setReadEmptyCells(false),避免生成大量无意义的空行数据。第三,如果文件行数实在太多,可以用 PhpSpreadsheet 提供的setReadChunkSize()配合自定义ReadFilter分块读取。具体做法是定义一个过滤器类,控制只读取指定行范围,循环多次加载同一个文件的不同块,处理完一个块就释放一次。这个方案代码量略大,但效果立竿见影。
6.5 导入时遇到数字列变成科学计数法
业务中的编号、身份证号、银行卡号这类数据很容易触发 Excel 的自动科学计数法。导出时用setCellValueExplicit(..., DataType::TYPE_STRING)加文本类型可以避免;导入时如果源头单元格已经是科学计数法,PhpSpreadsheet 读出来的可能已经是浮点数了,这种时候只能让用户重新提供正确格式的文件,或者在导入模板里提前对这类列设置"文本"格式。
6.6 导入模板最好给运营提供"格式护栏"
这个其实不算 bug,算产品建议。如果导入表格可以自己随便填,运营填错日期格式、填错数字格式的概率非常高,后端写得再好也扛不住人肉混乱。我的做法是额外提供一个带格式的模板文件:日期列直接设置成日期格式,数字列设置成文本格式,表头加背景色,业务必填列用数据有效性下拉框。运营照着模板填,错误率会明显下降。这个模板每次更新时,用同一个服务层生成一份空表,比手动去 Excel 里维护模板要可靠得多。
最后的经验与建议
这个需求做完,我最大的感触是:Excel 导入导出不是"调用一个大库就完事"的功能。真正的成本在格式边界、数据转换、输出输出方式、错误处理这些琐碎但关键的细节上。PhpSpreadsheet 确实好用,但如果组织结构一开始就是扁平的,后面做分离版会非常被动。
我现在的固定套路是:任何项目接入 Excel 功能,第一件事先建一个 Excel 服务层,定义好行数组这个中间格式,然后才是写控制器。导出入口也好,导入入口也好,都只是服务层的薄薄包装。另外一个最值得推荐的改进,是把给运营用的导入模板做成带格式、带校验规则的版本,从源头减少脏数据。Excel 这个格式本身并不难,难的是让它在真实业务里稳稳当当地跑起来。