搞定 XLSX 預(yù)覽?別瞎找了,這幾個(gè)庫(kù)(尤其最后一個(gè))真香!

  • Hey, 我是 沉浸式趣談
  • 本文首發(fā)于【沉浸式趣談】,我的個(gè)人博客 https://yaolifeng.com 也同步更新。
  • 轉(zhuǎn)載請(qǐng)?jiān)谖恼麻_頭注明出處和版權(quán)信息。
  • 如果本文對(duì)您有所幫助,請(qǐng) 點(diǎn)贊評(píng)論、轉(zhuǎn)發(fā),支持一下,謝謝!
  • 該平臺(tái)創(chuàng)作會(huì)佛系一點(diǎn),更多文章在我的個(gè)人博客上更新,歡迎訪問我的個(gè)人博客。

做前端的經(jīng)常碰到這種需求:用戶嘩啦一下傳個(gè) Excel 上來,你得在網(wǎng)頁(yè)上給它弄個(gè)像模像樣的預(yù)覽?有時(shí)候還要編輯,還挺折騰人的。

我踩了不少坑,也試了市面上挺多庫(kù),今天就聊聊幾個(gè)比較主流的選擇,特別是最后那個(gè),我個(gè)人是強(qiáng)推!

在線預(yù)覽 Demo

Stackblitz 在線預(yù)覽

第一個(gè)選手:老牌勁旅 xlsx

提起處理 Excel,xlsx 這庫(kù)估計(jì)是繞不過去的。GitHub 上 35k 的 star,簡(jiǎn)直是元老級(jí)別的存在了。

安裝?老規(guī)矩:

npm install xlsx

用起來嘛,也挺直接??炊未a感受下:

<template>
    <input type="file" @change="readExcel" />
</template>

<script setup>
import { ref } from 'vue';
import * as XLSX from 'xlsx';

// 讀取Excel文件
const readExcel = event => {
    const file = event.target.files[0];
    const reader = new FileReader();
    reader.onload = e => {
        const data = new Uint8Array(e.target.result);
        const workbook = XLSX.read(data, { type: 'array' });

        // 獲取第一個(gè)工作表
        const firstSheet = workbook.Sheets[workbook.SheetNames[0]];

        // 轉(zhuǎn)換為JSON
        const jsonData = XLSX.utils.sheet_to_json(firstSheet);
        console.log('喏,JSON 數(shù)據(jù)到手:', jsonData);
    };
    reader.readAsArrayBuffer(file);
};
</script>

[圖片上傳失敗...(image-2acbbf-1745399986818)]

上面就是讀個(gè)文件,拿到第一個(gè) sheet 轉(zhuǎn)成 JSON。很簡(jiǎn)單粗暴,對(duì)吧?

搞個(gè)帶文件選擇器的預(yù)覽 Demo 也不復(fù)雜:

<template>
    <div>
        <input type="file" accept=".xlsx,.xls" @change="handleFile" />

        <div v-if="data.length > 0" style="overflow-x: auto; margin-top: 20px">
            <table border="1" cellPadding="5" style="border-collapse: collapse">
                <thead>
                    <tr>
                        <th v-for="(column, index) in columns" :key="index">
                            {{ column.title }}
                        </th>
                    </tr>
                </thead>
                <tbody>
                    <tr v-for="(row, rowIndex) in data" :key="rowIndex">
                        <td v-for="(column, colIndex) in columns" :key="colIndex">
                            {{ row[column.title] }}
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>
    </div>
</template>

<script setup>
import { ref } from 'vue';
import * as XLSX from 'xlsx';

const data = ref([]);
const columns = ref([]);

const handleFile = (e: Event) => {
  const file = (e.target as HTMLInputElement).files?.[0];
  if (!file) return;

  const reader = new FileReader();
  reader.onload = (event) => {
    try {
      // 修改變量名避免與外部響應(yīng)式變量沖突
      const fileData = new Uint8Array(event.target?.result as ArrayBuffer);
      const workbook = XLSX.read(fileData, { type: 'array' });
      const worksheet = workbook.Sheets[workbook.SheetNames[0]];

      // 使用 header: 1 來獲取原始數(shù)組格式
      const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });

      if (jsonData.length > 0) {
        // 第一行作為列標(biāo)題
        columns.value = jsonData[0] as string[];
        // 其余行作為數(shù)據(jù)
        data.value = jsonData.slice(1);
        console.log('數(shù)據(jù)已加載:', { 列數(shù): columns.value.length, 行數(shù): data.value.length });
      }
    } catch (error) {
      console.error('Excel解析失敗:', error);
      alert('文件解析失敗,請(qǐng)檢查文件格式');
    }
  };

  reader.readAsArrayBuffer(file);
};
</script>

[圖片上傳失敗...(image-d0a2b7-1745399986818)]

xlsx 這家伙吧,優(yōu)點(diǎn)很明顯: 輕、快!核心庫(kù)體積不大,解析速度嗖嗖的,兼容性也不錯(cuò),老格式新格式基本都能吃。社區(qū)也活躍,遇到問題谷歌一下大多有解。

但缺點(diǎn)也得說說: 它的 API 設(shè)計(jì)感覺有點(diǎn)…嗯…老派?或者說比較底層,不太直觀。想拿到的數(shù)據(jù)結(jié)構(gòu),經(jīng)常得自己再加工一道(就像上面 Demo 里那樣)。而且,如果你想連著樣式一起搞,比如單元格顏色、字體啥的,那 xlsx 就有點(diǎn)力不從心了,樣式處理能力基本等于沒有。

我個(gè)人覺得,如果你的需求就是簡(jiǎn)單讀寫數(shù)據(jù),不關(guān)心樣式,那 xlsx 絕對(duì)夠用,效率杠杠的。但凡需求復(fù)雜一點(diǎn),比如要高度還原 Excel 樣式,或者處理復(fù)雜公式,那用它就有點(diǎn)“小馬拉大車”的感覺了。

第二個(gè)選手:重量級(jí)嘉賓 Handsontable

聊完基礎(chǔ)款,我們來看個(gè)重量級(jí)的:Handsontable。這家伙最大的賣點(diǎn),就是直接給你一個(gè)長(zhǎng)得、用起來都跟 Excel 賊像的在線表格!

安裝要多裝個(gè) Vue 的適配包:

npm install handsontable
npm install @handsontable/vue3  # Vue3 專用包

別忘了還有 CSS:

import 'handsontable/dist/handsontable.full.css';

基礎(chǔ)用法,它是在一個(gè) DOM 容器里初始化:

<template>
    <div id="excel-preview"></div>
</template>

<script setup>
import { onMounted } from 'vue';
import Handsontable from 'handsontable';
import 'handsontable/dist/handsontable.full.css';

onMounted(() => {
    // 初始化表格
    const container = document.getElementById('excel-preview');
    const hot = new Handsontable(container, {
        data: [
            ['姓名', '年齡', '城市'],
            ['張三', 28, '北京'],
            ['李四', 32, '上海'],
            ['王五', 25, '廣州'],
        ],
        rowHeaders: true,
        colHeaders: true,
        contextMenu: true,
        licenseKey: 'non-commercial-and-evaluation', // 注意:商用要錢!這很關(guān)鍵!
    });
});
</script>

[圖片上傳失敗...(image-39b0d1-1745399986818)]

搞個(gè)可編輯的 Demo 看看?這才是它的強(qiáng)項(xiàng):

<template>
    <div class="handsontable-container">
        <h2>Handsontable 數(shù)據(jù)分析工具</h2>

        <div class="toolbar">
            <div class="filter-section">
                <label>部門過濾:</label>
                <select v-model="selectedDepartment" @change="applyFilters">
                    <option value="all">所有部門</option>
                    <option value="銷售">銷售</option>
                    <option value="市場(chǎng)">市場(chǎng)</option>
                    <option value="技術(shù)">技術(shù)</option>
                </select>
            </div>

            <div class="toolbar-actions">
                <button @click="addNewRow">添加員工</button>
                <button @click="saveData">保存數(shù)據(jù)</button>
                <button @click="exportToExcel">導(dǎo)出Excel</button>
            </div>
        </div>

        <hot-table
            ref="hotTableRef"
            :data="filteredData"
            :colHeaders="headers"
            :rowHeaders="true"
            :width="'100%'"
            :height="500"
            :contextMenu="contextMenuOptions"
            :columns="columnDefinitions"
            :nestedHeaders="nestedHeaders"
            :manualColumnResize="true"
            :manualRowResize="true"
            :colWidths="colWidths"
            :beforeChange="beforeChangeHandler"
            :afterChange="afterChangeHandler"
            :cells="cellsRenderer"
            licenseKey="non-commercial-and-evaluation"
        ></hot-table>

        <div class="summary-section">
            <h3>數(shù)據(jù)統(tǒng)計(jì)</h3>
            <div class="summary-items">
                <div class="summary-item"> <strong>員工總數(shù):</strong> {{ totalEmployees }} </div>
                <div class="summary-item"> <strong>平均績(jī)效分:</strong> {{ averagePerformance }} </div>
                <div class="summary-item"> <strong>總薪資支出:</strong> {{ totalSalary }} </div>
            </div>
        </div>
    </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { HotTable } from '@handsontable/vue3';
import { registerAllModules } from 'handsontable/registry';
import 'handsontable/dist/handsontable.full.css';
import * as XLSX from 'xlsx';
import Handsontable from 'handsontable';

// 注冊(cè)所有模塊
registerAllModules();

// 表頭定義
const headers = ['ID', '姓名', '部門', '職位', '入職日期', '薪資', '績(jī)效評(píng)分', '狀態(tài)'];

// 嵌套表頭
const nestedHeaders = [['員工基本信息', '', '', '', '員工績(jī)效數(shù)據(jù)', '', '', ''], headers];

// 列寬設(shè)置
const colWidths = [60, 100, 100, 120, 120, 100, 100, 120];

// 列定義
const columnDefinitions = [
    { data: 'id', type: 'numeric', readOnly: true },
    { data: 'name', type: 'text' },
    {
        data: 'department',
        type: 'dropdown',
        source: ['銷售', '市場(chǎng)', '技術(shù)', '人事', '財(cái)務(wù)'],
    },
    { data: 'position', type: 'text' },
    {
        data: 'joinDate',
        type: 'date',
        dateFormat: 'YYYY-MM-DD',
        correctFormat: true,
    },
    {
        data: 'salary',
        type: 'numeric',
        numericFormat: {
            pattern: '¥ 0,0.00',
            culture: 'zh-CN',
        },
    },
    {
        data: 'performance',
        type: 'numeric',
        numericFormat: {
            pattern: '0.0',
        },
    },
    {
        data: 'status',
        type: 'dropdown',
        source: ['在職', '離職', '休假'],
    },
];

// 右鍵菜單選項(xiàng)
const contextMenuOptions = {
    items: {
        row_above: { name: '上方插入行' },
        row_below: { name: '下方插入行' },
        remove_row: { name: '刪除行' },
        separator1: Handsontable.plugins.ContextMenu.SEPARATOR,
        copy: { name: '復(fù)制' },
        cut: { name: '剪切' },
        separator2: Handsontable.plugins.ContextMenu.SEPARATOR,
        columns_resize: { name: '調(diào)整列寬' },
        alignment: { name: '對(duì)齊' },
    },
};

// 初始數(shù)據(jù)
const initialData = [
    {
        id: 1,
        name: '張三',
        department: '銷售',
        position: '銷售經(jīng)理',
        joinDate: '2022-01-15',
        salary: 15000,
        performance: 4.5,
        status: '在職',
    },
    {
        id: 2,
        name: '李四',
        department: '技術(shù)',
        position: '高級(jí)開發(fā)',
        joinDate: '2021-05-20',
        salary: 18000,
        performance: 4.7,
        status: '在職',
    },
    {
        id: 3,
        name: '王五',
        department: '市場(chǎng)',
        position: '市場(chǎng)專員',
        joinDate: '2022-03-10',
        salary: 12000,
        performance: 3.8,
        status: '在職',
    },
    {
        id: 4,
        name: '趙六',
        department: '技術(shù)',
        position: '開發(fā)工程師',
        joinDate: '2020-11-05',
        salary: 16500,
        performance: 4.2,
        status: '在職',
    },
    {
        id: 5,
        name: '錢七',
        department: '銷售',
        position: '銷售代表',
        joinDate: '2022-07-18',
        salary: 10000,
        performance: 3.5,
        status: '休假',
    },
    {
        id: 6,
        name: '孫八',
        department: '市場(chǎng)',
        position: '市場(chǎng)總監(jiān)',
        joinDate: '2019-02-28',
        salary: 25000,
        performance: 4.8,
        status: '在職',
    },
    {
        id: 7,
        name: '周九',
        department: '技術(shù)',
        position: '測(cè)試工程師',
        joinDate: '2021-09-15',
        salary: 14000,
        performance: 4.0,
        status: '在職',
    },
    {
        id: 8,
        name: '吳十',
        department: '銷售',
        position: '銷售代表',
        joinDate: '2022-04-01',
        salary: 11000,
        performance: 3.6,
        status: '離職',
    },
];

// 表格引用
const hotTableRef = ref(null);
const data = ref([...initialData]);
const selectedDepartment = ref('all');

// 過濾后的數(shù)據(jù)
const filteredData = computed(() => {
    if (selectedDepartment.value === 'all') {
        return data.value;
    }
    return data.value.filter(item => item.department === selectedDepartment.value);
});

// 數(shù)據(jù)統(tǒng)計(jì)
const totalEmployees = computed(() => data.value.filter(emp => emp.status === '在職' || emp.status === '休假').length);

const averagePerformance = computed(() => {
    const activeEmployees = data.value.filter(emp => emp.status === '在職');
    if (activeEmployees.length === 0) return 0;

    const sum = activeEmployees.reduce((acc, emp) => acc + emp.performance, 0);
    return (sum / activeEmployees.length).toFixed(1);
});

const totalSalary = computed(() => {
    const activeEmployees = data.value.filter(emp => emp.status === '在職' || emp.status === '休假');
    const sum = activeEmployees.reduce((acc, emp) => acc + emp.salary, 0);
    return `¥ ${sum.toLocaleString('zh-CN')}`;
});

// 單元格渲染器 - 條件格式
const cellsRenderer = (row, col, prop) => {
    const cellProperties = {};

    // 績(jī)效評(píng)分條件格式
    if (prop === 'performance') {
        const value = filteredData.value[row]?.performance;

        if (value >= 4.5) {
            cellProperties.className = 'bg-green';
        } else if (value >= 4.0) {
            cellProperties.className = 'bg-light-green';
        } else if (value < 3.5) {
            cellProperties.className = 'bg-red';
        }
    }

    // 狀態(tài)條件格式
    if (prop === 'status') {
        const status = filteredData.value[row]?.status;

        if (status === '在職') {
            cellProperties.className = 'status-active';
        } else if (status === '離職') {
            cellProperties.className = 'status-inactive';
        } else if (status === '休假') {
            cellProperties.className = 'status-vacation';
        }
    }

    return cellProperties;
};

// 數(shù)據(jù)驗(yàn)證
const beforeChangeHandler = (changes, source) => {
    if (source === 'edit') {
        for (let i = 0; i < changes.length; i++) {
            const [row, prop, oldValue, newValue] = changes[i];

            // 薪資驗(yàn)證:不能小于0
            if (prop === 'salary' && newValue < 0) {
                changes[i][3] = oldValue;
            }

            // 績(jī)效驗(yàn)證:范圍1-5
            if (prop === 'performance') {
                if (newValue < 1) changes[i][3] = 1;
                if (newValue > 5) changes[i][3] = 5;
            }
        }
    }
    return true;
};

// 在數(shù)據(jù)更改后的處理
const afterChangeHandler = (changes, source) => {
    if (!changes) return;

    setTimeout(() => {
        if (hotTableRef.value?.hotInstance) {
            hotTableRef.value.hotInstance.render();
        }
    }, 0);
};

// 應(yīng)用過濾器
const applyFilters = () => {
    if (hotTableRef.value?.hotInstance) {
        hotTableRef.value.hotInstance.render();
    }
};

// 添加新行
const addNewRow = () => {
    const newId = Math.max(...data.value.map(item => item.id), 0) + 1;
    data.value.push({
        id: newId,
        name: '',
        department: '',
        position: '',
        joinDate: new Date().toISOString().split('T')[0],
        salary: 0,
        performance: 3.0,
        status: '在職',
    });

    if (hotTableRef.value?.hotInstance) {
        setTimeout(() => {
            hotTableRef.value.hotInstance.render();
        }, 0);
    }
};

// 保存數(shù)據(jù)
const saveData = () => {
    // 這里可以添加API保存邏輯
    alert('數(shù)據(jù)已保存');
};

// 導(dǎo)出為Excel
const exportToExcel = () => {
    const currentData = data.value;
    const ws = XLSX.utils.json_to_sheet(currentData);
    const wb = XLSX.utils.book_new();
    XLSX.utils.book_append_sheet(wb, ws, '員工數(shù)據(jù)');
    XLSX.writeFile(wb, '員工數(shù)據(jù)報(bào)表.xlsx');
};

// 確保組件掛載后正確渲染
onMounted(() => {
    setTimeout(() => {
        if (hotTableRef.value?.hotInstance) {
            hotTableRef.value.hotInstance.render();
        }
    }, 100);
});
</script>

<style>
.handsontable-container {
    padding: 20px;
    font-family: Arial, sans-serif;
}

.toolbar {
    display: flex;
    justify-content: space-between;
    margin-bottom: 20px;
    align-items: center;
}

.filter-section {
    display: flex;
    align-items: center;
    gap: 10px;
}

.toolbar-actions {
    display: flex;
    gap: 10px;
}

button {
    padding: 8px 16px;
    background-color: #4285f4;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 14px;
    transition: background-color 0.3s;
}

button:hover {
    background-color: #3367d6;
}

select {
    padding: 6px;
    border-radius: 4px;
    border: 1px solid #ccc;
}

.summary-section {
    margin-top: 20px;
    padding: 15px;
    background-color: #f9f9f9;
    border-radius: 6px;
}

.summary-items {
    display: flex;
    gap: 30px;
    margin-top: 10px;
}

.summary-item {
    padding: 10px;
    background-color: white;
    border-radius: 4px;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

/* 條件格式樣式 */
.bg-green {
    background-color: rgba(76, 175, 80, 0.3) !important;
}

.bg-light-green {
    background-color: rgba(139, 195, 74, 0.2) !important;
}

.bg-red {
    background-color: rgba(244, 67, 54, 0.2) !important;
}

.status-active {
    font-weight: bold;
    color: #2e7d32;
}

.status-inactive {
    font-weight: bold;
    color: #d32f2f;
}

.status-vacation {
    font-weight: bold;
    color: #f57c00;
}
</style>

[圖片上傳失敗...(image-4b3e75-1745399986818)]

Handsontable 的牛逼之處: 界面無(wú)敵!

用戶體驗(yàn)幾乎無(wú)縫對(duì)接 Excel,什么排序、篩選、合并單元格、公式計(jì)算、右鍵菜單、拖拽調(diào)整行列,花里胡哨的功能一大堆。

定制性也強(qiáng),事件鉤子多得很。官方還貼心地提供了 Vue、React 這些框架的集成包。

但(總有個(gè)但是,對(duì)吧?):

貴! 商用許可不便宜,對(duì)不少項(xiàng)目來說是個(gè)門檻。雖然有非商用許可,但你懂的。

重! 功能全的代價(jià)就是體積大,加載可能慢一丟丟,尤其對(duì)性能敏感的頁(yè)面。

大數(shù)據(jù)量有壓力: 行列一多,性能可能會(huì)有點(diǎn)吃緊。

學(xué)習(xí)曲線: 配置項(xiàng)多如牛毛,想玩溜需要花點(diǎn)時(shí)間看文檔。

我個(gè)人感覺,Handsontable 就像是你去了一家裝修豪華、菜品精致的高檔餐廳,體驗(yàn)一級(jí)棒,但結(jié)賬時(shí)錢包會(huì)疼。

如果項(xiàng)目預(yù)算充足,而且用戶強(qiáng)烈要求“就要 Excel 那樣的體驗(yàn)”,那它確實(shí)是王炸。

壓軸出場(chǎng):我的心頭好 ExcelJS

前面說了兩個(gè),一個(gè)輕快但簡(jiǎn)陋,一個(gè)豪華但貴重。

那有沒有折中點(diǎn)的,功能強(qiáng)又免費(fèi)的?

ExcelJS 登場(chǎng)!這家伙給我的感覺就是:現(xiàn)代化、全能型選手,而且 API 設(shè)計(jì)得相當(dāng)舒服。

老規(guī)矩,安裝

npm install exceljs

基本用法,注意它用了 async/await,很現(xiàn)代:

<template>
    <input type="file" @change="readExcel" />
</template>

<script setup>
import { ref } from 'vue';
import ExcelJS from 'exceljs';

const readExcel = async event => {
    const file = event.target.files[0];
    if (!file) return;

    // 最好加個(gè) try...catch
    try {
        const workbook = new ExcelJS.Workbook();
        const arrayBuffer = await file.arrayBuffer(); // 直接讀 ArrayBuffer,省事兒
        await workbook.xlsx.load(arrayBuffer);

        const worksheet = workbook.getWorksheet(1); // 獲取第一個(gè) worksheet
        const data = [];

        worksheet.eachRow((row, rowNumber) => {
            const rowData = [];
            row.eachCell((cell, colNumber) => {
                rowData.push(cell.value);
            });
            // 它的 API 遍歷起來就挺順手
            data.push(rowData);
        });

        console.log(data);
        return data; // 返回解析好的數(shù)據(jù)
    } catch (error) {
        console.error('用 ExcelJS 解析失敗了,檢查下文件?', error);
        alert('文件好像有點(diǎn)問題,解析不了哦');
    }
};
</script>

[圖片上傳失敗...(image-95c6f5-1745399986818)]

來個(gè)帶勁的 Demo:把 Excel 樣式也給你扒下來!

<template>
    <div>
        <button @click="exportAdvancedExcel">導(dǎo)出進(jìn)階Excel</button>
    </div>
</template>

<script setup lang="ts">
import ExcelJS from 'exceljs';

// 高級(jí)數(shù)據(jù)類型
interface AdvancedData {
    id: number;
    name: string;
    department: string;
    salary: number;
    joinDate: Date;
    performance: number;
}

// 生成示例數(shù)據(jù)
const generateData = () => {
    const data: AdvancedData[] = [];
    for (let i = 1; i <= 5; i++) {
        data.push({
            id: i,
            name: `員工${i}`,
            department: ['技術(shù)部', '市場(chǎng)部', '財(cái)務(wù)部'][i % 3],
            salary: 10000 + i * 1000,
            joinDate: new Date(2020 + i, i % 12, i),
            performance: Math.random() * 100,
        });
    }
    return data;
};

const exportAdvancedExcel = async () => {
    const workbook = new ExcelJS.Workbook();
    const worksheet = workbook.addWorksheet('員工報(bào)表');

    // 設(shè)置文檔屬性
    workbook.creator = '企業(yè)管理系統(tǒng)';
    workbook.lastModifiedBy = '管理員';
    workbook.created = new Date();

    // 設(shè)置頁(yè)面布局
    worksheet.pageSetup = {
        orientation: 'landscape',
        margins: { left: 0.7, right: 0.7, top: 0.75, bottom: 0.75 },
    };

    // 創(chuàng)建自定義樣式
    const headerStyle = {
        font: { bold: true, color: { argb: 'FFFFFFFF' } },
        fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4F81BD' } },
        border: {
            top: { style: 'thin' },
            left: { style: 'thin' },
            bottom: { style: 'thin' },
            right: { style: 'thin' },
        },
        alignment: { vertical: 'middle', horizontal: 'center' },
    };

    const moneyFormat = '"¥"#,##0.00';
    const dateFormat = 'yyyy-mm-dd';
    const percentFormat = '0.00%';

    // 合并標(biāo)題行
    worksheet.mergeCells('A1:F1');
    const titleCell = worksheet.getCell('A1');
    titleCell.value = '2023年度員工數(shù)據(jù)報(bào)表';
    titleCell.style = {
        font: { size: 18, bold: true, color: { argb: 'FF2E75B5' } },
        alignment: { vertical: 'middle', horizontal: 'center' },
    };

    // 設(shè)置列定義
    worksheet.columns = [
        { header: '工號(hào)', key: 'id', width: 10 },
        { header: '姓名', key: 'name', width: 15 },
        { header: '部門', key: 'department', width: 15 },
        {
            header: '薪資',
            key: 'salary',
            width: 15,
            style: { numFmt: moneyFormat },
        },
        {
            header: '入職日期',
            key: 'joinDate',
            width: 15,
            style: { numFmt: dateFormat },
        },
        {
            header: '績(jī)效',
            key: 'performance',
            width: 15,
            style: { numFmt: percentFormat },
        },
    ];

    // 應(yīng)用表頭樣式
    worksheet.getRow(2).eachCell(cell => {
        cell.style = headerStyle;
    });

    // 添加數(shù)據(jù)
    const data = generateData();
    worksheet.addRows(data);

    // 添加公式行
    const totalRow = worksheet.addRow({
        id: '總計(jì)',
        salary: { formula: 'SUM(D3:D7)' },
        performance: { formula: 'AVERAGE(F3:F7)' },
    });

    // 設(shè)置總計(jì)行樣式
    totalRow.eachCell(cell => {
        cell.style = {
            font: { bold: true },
            fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFCE4D6' } },
        };
    });

    // 添加條件格式
    worksheet.addConditionalFormatting({
        ref: 'F3:F7',
        rules: [
            {
                type: 'cellIs',
                operator: 'greaterThan',
                formulae: [0.8],
                style: { fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFC6EFCE' } } },
            },
        ],
    });

    // 生成Blob并下載
    const buffer = await workbook.xlsx.writeBuffer();
    const blob = new Blob([buffer], {
        type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    });

    // 使用原生API下載
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = '員工報(bào)表.xlsx';
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    URL.revokeObjectURL(url);
};
</script>

[圖片上傳失敗...(image-d85eca-1745399986818)]

為啥我偏愛 ExcelJS?

API 友好: Promise 風(fēng)格,鏈?zhǔn)秸{(diào)用,寫起來舒服,代碼也更易讀。感覺就是為現(xiàn)代 JS 開發(fā)設(shè)計(jì)的。

功能全面: 不僅僅是讀寫數(shù)據(jù),樣式、公式、合并單元格、圖片、表單控件… 它支持的 Excel 特性相當(dāng)多。特別是讀取和修改樣式,這對(duì)于需要“還原”Excel 樣貌的場(chǎng)景太重要了!

免費(fèi)開源! 這點(diǎn)太香了,沒有商業(yè)使用的后顧之憂。

文檔清晰: 官方文檔寫得挺明白,示例也足。

當(dāng)然,沒啥是完美的:

體積比 xlsx 大點(diǎn): 但功能也強(qiáng)得多嘛,可以接受。

復(fù)雜公式支持可能有限: 極其復(fù)雜的嵌套公式或者宏,可能還是搞不定(不過大部分場(chǎng)景夠用了)。

超大文件性能: 幾十上百兆的 Excel,解析起來可能會(huì)慢,或者內(nèi)存占用高點(diǎn)(老實(shí)說,哪個(gè)庫(kù)處理這種文件不頭疼呢)。

我之前用 xlsx 時(shí),老是要自己寫一堆轉(zhuǎn)換邏輯,數(shù)據(jù)結(jié)構(gòu)處理起來煩得很。換了 ExcelJS 后,感覺世界清凈了不少。尤其是它能把單元格的背景色、字體、邊框這些信息都讀出來,這對(duì)做預(yù)覽太有用了!

實(shí)戰(zhàn)中怎么選?或者…全都要?

其實(shí)吧,這三個(gè)庫(kù)也不是非得“你死我活”。在真實(shí)項(xiàng)目中,完全可以根據(jù)情況搭配使用:

簡(jiǎn)單快速的導(dǎo)入導(dǎo)出: 用戶上傳個(gè)模板,或者導(dǎo)出一份簡(jiǎn)單數(shù)據(jù),用 xlsx 就行,輕快好省。

需要精確保留樣式或復(fù)雜解析: 用戶傳了個(gè)帶格式的報(bào)表,你想盡可能還原預(yù)覽,那 ExcelJS 就是主力。

需要在線編輯、強(qiáng)交互: 如果你做的不是預(yù)覽,而是個(gè)在線的類 Excel 編輯器,那砸錢上 Handsontable 可能是最接近目標(biāo)的(如果預(yù)算允許的話)。

我甚至見過有項(xiàng)目是這樣搞的:先用 xlsx 快速讀取基本數(shù)據(jù)和 Sheet 名稱做個(gè)“秒開”預(yù)覽,然后后臺(tái)或者異步再用 ExcelJS 做詳細(xì)的、帶樣式的解析。

這樣既快,又能保證最終效果。

下面這個(gè)(偽)代碼片段,大概是這個(gè)思路:

<template>
    <div class="excel-viewer">
        <div class="controls">
            <input type="file" @change="e => detailedParse(e.target.files[0])" accept=".xlsx,.xls" />
            <button @click="exportToExcel">導(dǎo)出Excel</button>
        </div>

        <div v-if="isLoading">加載中...</div>

        <template v-else>
            <div v-if="sheetNames.length > 0" class="sheet-tabs">
                <button
                    v-for="(name, index) in sheetNames"
                    :key="index"
                    :class="{ active: activeSheet === index }"
                    @click="handleSheetChange(index)"
                >
                    {{ name }}
                </button>
            </div>

            <hot-table
                v-if="data.length > 0"
                ref="hotTableRef"
                :data="data"
                :rowHeaders="true"
                :colHeaders="true"
                :width="'100%'"
                :height="400"
                licenseKey="non-commercial-and-evaluation"
            ></hot-table>
        </template>
    </div>
</template>

<script setup>
import { ref } from 'vue';
import * as XLSX from 'xlsx'; // 用于快速預(yù)覽 & 導(dǎo)出
import ExcelJS from 'exceljs'; // 用于詳細(xì)解析
import { HotTable } from '@handsontable/vue3'; // 用于展示 & 編輯
import 'handsontable/dist/handsontable.full.css';

const data = ref([]);
const isLoading = ref(false);
const sheetNames = ref([]);
const activeSheet = ref(0);
const hotTableRef = ref(null);

// 快速預(yù)覽(可選,或者直接用 detailedParse)
const quickPreview = file => {
    isLoading.value = true;
    const reader = new FileReader();
    reader.onload = e => {
        try {
            const data = new Uint8Array(e.target.result);
            const workbook = XLSX.read(data, { type: 'array' });
            sheetNames.value = workbook.SheetNames;

            const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
            const jsonData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 });
            data.value = jsonData;
            activeSheet.value = 0;
        } catch (error) {
            console.error('預(yù)覽失敗:', error);
            alert('文件預(yù)覽失敗');
        } finally {
            isLoading.value = false;
        }
    };
    reader.readAsArrayBuffer(file);
};

// 使用ExcelJS詳細(xì)解析
const detailedParse = async file => {
    isLoading.value = true;
    try {
        const workbook = new ExcelJS.Workbook();
        const arrayBuffer = await file.arrayBuffer();
        await workbook.xlsx.load(arrayBuffer);

        // 也許這里還可以把 ExcelJS 解析到的樣式信息存起來,以后可能用得到
        // 比如,導(dǎo)出時(shí)嘗試用 ExcelJS 寫回樣式?那就更高級(jí)了
        const names = workbook.worksheets.map(sheet => sheet.name);
        sheetNames.value = names;

        // 解析第一個(gè) sheet
        parseWorksheet(workbook.worksheets[0]);
        activeSheet.value = 0;
    } catch (error) {
        console.error('解析失敗:', error);
        alert('文件解析失敗');
    } finally {
        isLoading.value = false;
    }
};

// 解析某個(gè) worksheet 并更新 Handsontable 數(shù)據(jù)
const parseWorksheet = worksheet => {
    const sheetData = [];
    worksheet.eachRow((row, rowNumber) => {
        const rowData = [];
        row.eachCell((cell, colNumber) => {
            let value = cell.value;
            // 處理日期等特殊類型
            if (value instanceof Date) {
                value = value.toLocaleDateString();
            }
            rowData.push(value);
        });
        sheetData.push(rowData);
    });
    // 這里的 data 結(jié)構(gòu)要適配 Handsontable,通常是二維數(shù)組
    data.value = sheetData;
};

// 切換 Sheet (需要重新調(diào)用 parseWorksheet)
const handleSheetChange = async index => {
    activeSheet.value = index;
    // 重新加載并解析對(duì)應(yīng) Sheet 的數(shù)據(jù)... 這需要保存 workbook 實(shí)例
    // 或者在 detailedParse 時(shí)就把所有 sheet 數(shù)據(jù)都解析緩存起來?看內(nèi)存消耗
};

// 導(dǎo)出 (簡(jiǎn)單起見,用 xlsx 快速導(dǎo)出當(dāng)前 Handsontable 的數(shù)據(jù))
const exportToExcel = () => {
    const ws = XLSX.utils.aoa_to_sheet(data.value);
    const wb = XLSX.utils.book_new();
    XLSX.utils.book_append_sheet(wb, ws, 'Sheet1');
    XLSX.writeFile(wb, '導(dǎo)出數(shù)據(jù).xlsx');
    // 如果想導(dǎo)出帶樣式的,那得用 ExcelJS 來寫,會(huì)復(fù)雜不少
};
</script>

<style scoped>
.excel-viewer {
    margin: 20px;
}
.controls {
    margin-bottom: 15px;
}
.sheet-tabs {
    display: flex;
    margin-bottom: 10px;
}
.sheet-tabs button {
    padding: 5px 10px;
    margin-right: 5px;
    border: 1px solid #ccc;
    background: #f5f5f5;
    cursor: pointer;
}
.sheet-tabs button.active {
    background: #e0e0e0;
    border-bottom: 2px solid #1890ff;
}
</style>

總結(jié)一下我的個(gè)人看法:

折騰下來,這幾個(gè)庫(kù)真是各有千秋:

xlsx (SheetJS): 老司機(jī),適合追求極致性能和體積的簡(jiǎn)單場(chǎng)景。代碼寫得少,跑得快,但不怎么講究“內(nèi)飾”(樣式)。

Handsontable: 豪華座駕,提供近乎完美的 Excel 編輯體驗(yàn)。功能強(qiáng)大沒得說,但得看你口袋里的銀子夠不夠。

ExcelJS: 可靠的全能伙伴。API 現(xiàn)代,功能均衡,對(duì)樣式支持好,關(guān)鍵還免費(fèi)!能幫你解決絕大多數(shù)問題。

說真的,沒有銀彈。選哪個(gè),最終還是看你的具體需求和項(xiàng)目限制。

但如果非要我推薦一個(gè),我絕對(duì)站 ExcelJS。

在功能、易用性和成本(免費(fèi)?。┲g,它平衡得太好了。

對(duì)于大部分需要精細(xì)處理 Excel 文件(尤其是帶樣式預(yù)覽)的場(chǎng)景,它就是那個(gè)最香的選擇!

好了,就叨叨這么多,希望能幫到你!趕緊去試試吧!

其他好文推薦

實(shí)戰(zhàn)分享】10大支付平臺(tái)全方面分析,獨(dú)立開發(fā)必備!

關(guān)于 MCP,這幾個(gè)網(wǎng)站你一定要知道!

做 Docx 預(yù)覽,一定要做這個(gè)神庫(kù)!!

【完整匯總】近 5 年 JavaScript 新特性完整總覽

關(guān)于 Node,一定要學(xué)這個(gè) 10+萬(wàn) Star 項(xiàng)目!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容