node實現檔案屬性批量修改(檔名)

磨蹭先生發表於2020-07-05

前言

書接上回,我們實現了批量修改檔案的時間,但是卻沒有實現檔名稱的批量修改,是因為我也說過,沒有介面的話直接在命令列實現顯得有點繁瑣,所以我們就通過介面+介面的方式來實現我們這個小需求吧。所以,閒話不多說啦,開始寫我們的程式碼啦~~

本次教程過於囉嗦,所以這裡先放上預覽地址供大家預覽——點我預覽,也可到文末直接下載程式碼先自行體驗。。。

簡單的說下實現的效果

通常我們在藍湖上下載的切圖是和UI小姐姐定義的圖層名相關的,一般下載下來之後我們就需要修改名稱,但是一個個修改又顯得十分傻逼 ?,所以我們就自己寫一下程式碼自己修改,具體效果如圖:

產品效果

看到這裡,是不是也想躍躍欲試啦,所以,我們就開始寫我們的程式碼吧

簡單的搭建一下

  • 新建一個 batch-modify-filenames 目錄

  • 初始化一個node專案工程

    npm init -y
    
  • 安裝依賴,這裡依賴比較多,所以下面我會講一下他們大概是幹嘛的

    npm i archiver glob koa koa-body koa-router koa-static uuid -S
    npm i nodemon -D
    
    • koa Nodejs的Web框架
    • koa-body 解析 post 請求,支援檔案上傳
    • koa-router 處理路由(介面)相關
    • koa-static 處理靜態檔案
    • glob 批量處理檔案
    • uuid 生成不重複的檔名
    • nodemon 監聽檔案變化,自動重啟專案
    • archiver 壓縮成 zip 檔案

    ps:nodemon 是用於我們除錯的,所以他是開發依賴,所以我們需要-D。其他的都是主要依賴,所以-S

  • 配置一下我們的啟動命令

    {
      ...
      "scripts": {
          "dev": "nodemon app.js"
      },
      ...
    }
    

Koa 是什麼

既然用到了Koa,那麼我們就瞭解一下他是什麼?

Koa 是由 Express 原班人馬打造的,致力於成為一個更小、更富有表現力、更健壯的 Web 框架,採用了 asyncawait 的方式執行非同步操作。 Koa 並沒有捆綁任何中介軟體, 而是提供了一套優雅的方法,幫助您快速而愉快地編寫服務端應用程式。也正是因為沒有捆綁任何中介軟體,Koa 保持著一個很小的體積。

通俗點來講,就是常說的後端框架,處理我們前端傳送過去的請求。

上下文(Context)

Koa Contextnoderequestresponse 物件封裝到單個物件中,為編寫 Web 應用程式和 API 提供了許多有用的方法。 這些操作在 HTTP 伺服器開發中頻繁使用,它們被新增到此級別而不是更高階別的框架,這將強制中介軟體重新實現此通用功能。

Context這裡我們主要用到了staterequestresponse這幾個常用的物件,這裡我大概講講他們的作用。

  • state 推薦的名稱空間,用於通過中介軟體傳遞資訊和你的前端檢視。
  • req Node 的 Request 物件.
  • request Koa 的 Request 物件.
  • res Node 的 Response 物件.
  • response Koa 的 Response 物件.

ctx.req 和 ctx.request 的區別

通常剛學Koa的時候,估計有不少人弄混這兩個的區別,這裡就說說他們兩有什麼區別吧。

最主要的區別是,ctx.requestcontext 經過封裝的請求物件,ctx.reqcontext 提供的 node.js 原生 HTTP 請求物件,同理 ctx.responsecontext 經過封裝的響應物件,ctx.rescontext 提供的 node.js 原生 HTTP 響應物件。

所以,通常我們是通過ctx.request獲取請求引數,通過ctx.response設定返回值,不要弄混了哦 (⊙o⊙)

ctx.body 和 ctx.request.body 傻傻分不清

以為通常get請求我們可以直接通過ctx.query(ctx.request.query的別名)就可以獲得提交過來的資料,post請求的話這是通過body來獲取,所以通常我們會通過猜想,以為ctx.body也是ctx.request.body的別名,其實- -這個是不對的。因為我們不僅要接受資料,最重要還要響應資料給前端,所以ctx.bodyctx.response.body的別名。而ctx.request.body為了區分,是沒有設定別名的,即只能通過ctx.request.body獲取post提交過來的資料。

總結:ctx.bodyctx.response.body的別名,而ctx.request.bodypost提交過來的資料

Koa 中介軟體

Koa 的最大特色,也是最重要的一個設計,就是中介軟體(middleware)Koa 應用程式是一個包含一組中介軟體函式的物件,它是按照類似堆疊的方式組織和執行的。Koa 中使用 app.use()用來載入中介軟體,基本上 Koa 所有的功能都是通過中介軟體實現的。每個中介軟體預設接受兩個引數,第一個引數是 Context 物件,第二個引數是 next 函式。只要呼叫 next 函式,就可以把執行權轉交給下一個中介軟體。

下面兩張圖很清晰的表明了一個請求是如何經過中介軟體最後生成響應的,這種模式中開發和使用中介軟體都是非常方便的:

洋蔥模型1

洋蔥模型2

再來看下 Koa 的洋蔥模型例項程式碼:

const Koa = require("koa");
const app = new Koa();
app.use(async (ctx, next) => {
  console.log(1);
  await next();
  console.log(6);
});
app.use(async (ctx, next) => {
  console.log(2);
  await next();
  console.log(5);
});
app.use(async (ctx, next) => {
  console.log(3);
  await next();
  console.log(4);
});
app.listen(8000);

怎麼樣,是不是有一點點感覺了。當程式執行到 await next()的時候就會暫停當前程式,進入下一個中介軟體,處理完之後才會回過頭來繼續處理。

理解完這些後就可以開始寫我們的程式碼啦!!! (lll ¬ ω ¬),好像寫了好多和這次教程主題沒關的東西,見怪莫怪啦

簡單的搭建前端專案

既然說到了寫介面,這裡我們技術棧就採用vue吧,然後UI庫的話,大家都用慣了ElementUI,我想大家都特別熟悉了,所以我們這裡就採用Ant Design Vue吧,也方便大家對Antd熟悉一下,也沒什麼壞處

所以,我們就簡單的建立一下我們的專案,在我們batch-modify-filenames資料夾下執行vue create batch-front-end,如圖所示:

簡單的編寫介面

基本上都是無腦下一步,只不過是ant-design-vue用了less,我們為了符合它的寫法,我們配置上也採用less。當然,採用sass也是可以的,沒什麼強制要求。

建立完專案後就是安裝依賴了,因為其實我們用到的元件不多,所以這裡我們使用按需載入,即需要安裝babel-plugin-import,這裡babel-plugin-import也是開發依賴,生產環境是不需要的,所以安裝的時候需要-D

這裡我們用到了一個常用的工具庫(類庫)—— lodash,我們不一定用到他所有的方法,所以我們也需要安裝個babel外掛進行按需載入,即babel-plugin-transform-imports,同樣也是-D

最後,既然是與後端做互動,我們肯定需要用到一個http庫啦,既然官方推薦我們用axios,所以這裡我們也要把axios裝上,不過axios不是vue的外掛,所以不能直接用use方法。所以,這裡我為了方便,也把vue-axios裝上了。在之後,因為我有不想把最終的zip檔案留在伺服器上,畢竟會佔用空間,所以我以流(Stream)的方式返回給前端,讓前端自己下載,那麼這裡我就採用一個成熟第三方庫實現,也就是file-saver,所以最終我們的依賴項就是:

npm install ant-design-vue lodash axios vue-axios file-saver -S
npm install babel-plugin-import babel-plugin-transform-imports -D

配置babel.config.js:

module.exports = {
  presets: ["@vue/cli-plugin-babel/preset"],
  plugins: [
    [
      "import",
      { libraryName: "ant-design-vue", libraryDirectory: "es", style: true },
    ], // `style: true` 會載入 less 檔案,
    [
      "transform-imports",
      {
        lodash: {
          transform: "lodash/${member}",
          preventFullImport: true,
        },
      },
    ],
  ],
};

因為我們這裡改成了style: true,按需引入的時候大概會報下面的這個錯誤:

按需載入報錯

解決方案這裡也說的很清楚了,在https://github.com/ant-design/ant-motion/issues/44這個連結,也有說明Inline JavaScript is not enabled. Is it set in your options?,告訴我們less沒開啟JavaScript功能,我們需要修改下 lless-loader的配置即可

因為vue-cli4webpack不像vue-cli2.x,他對外遮蔽了webpack的細節,如果像修改必須建立vue.config.js來修改配置,所以我們建立一個vue.config.js檔案,書寫下面配置:

module.exports = {
  css: {
    loaderOptions: {
      less: {
        javascriptEnabled: true,
      },
    },
  },
};

關掉服務,在重新跑一下npm run serve,看是不是沒有報錯了?這樣子我就可以書寫我們的程式碼了。

編寫佈局

批處理介面

介面大概長這樣子,我想大家寫介面應該比我厲害多了,都是直接套用Antd的元件,所以這裡我主要分析我們怎麼拆分這個頁面的元件比較好,怎麼定義我們的資料比較好~~

從這個圖我們可以看出新檔案列表是基於原檔案列表+各種設定得出來的,所以新檔案列表我們就可以採用計算屬性(computed)來實現啦,那麼接下來就是拆分我們頁面的時候啦。。。

ps:這裡我不會詳細講怎麼寫介面,只會把我覺得對開發有用的講出來,不然文章就太多冗長了。雖然現在也十分冗長了(;´д `)ゞ

拆分頁面

其實從頁面的分割線我們大概就可以看出,他是分成 3 個大的子元件了,還有檔案列表可以單獨劃分為孫子元件,所以基本上如圖所示:

拆分頁面

程式碼結構如圖:

|-- batch-front-end
    ├─.browserslistrc
    ├─.eslintrc.js
    ├─.gitignore
    ├─babel.config.js
    ├─package-lock.json
    ├─package.json
    ├─README.md
    ├─vue.config.js
    ├─src
    |  ├─App.vue
    |  ├─main.js
    |  ├─utils
    |  |   ├─helpers.js
    |  |   ├─index.js
    |  |   └regexp.js
    |  ├─components
    |  |     ├─ModifyFilename2.vue
    |  |     ├─ModifyFilename
    |  |     |       ├─FileList.vue
    |  |     |       ├─FileListItem.vue
    |  |     |       ├─FileOutput.vue
    |  |     |       ├─FileSetting.vue
    |  |     |       └index.vue
    |  ├─assets
    |  |   └logo.png
    ├─public
    |   ├─favicon.ico
    |   └index.html

看到這裡,基本知道我是怎麼拆分的吧?沒錯,一共用了四個元件分別是FileSetting(檔名設定)FileOutput(輸出設定)FileList(輸出結果)FileListItem(列表元件)這麼四大塊

當然知道怎麼拆分了還遠遠不夠的,雖然現在我們只有 4 個元件,所以寫起來問題不是那麼的大,但是呢。。。寫起頁面來其實也是比較麻煩的,一般正常的寫法是:

<template>
  <div class="content">
    <div>
      <divider orientation="left">檔名設定</divider>
      <FileSetting :fileSettings="fileSettings" :diyForm="diyForm" />
      <divider orientation="left">輸出設定</divider>
      <FileOutput :ext="ext" :enable="enable" />
      <divider orientation="left">輸出結果</divider>
      <FileList :oldFiles="oldFiles" :newFiles="newFiles" />
    </div>
  </div>
</template>

<script>
import { Divider } from "ant-design-vue";
import FileList from "./FileList";
import FileOutput from "./FileOutput";
import FileSetting from "./FileSetting";
export default {
  name: "ModifyFilename",
  components: {
    Divider,
    FileList,
    FileOutput,
    FileSetting,
  },
  computed: {
    // 新檔案列表
    newFiles() {
      return this.oldFiles;
    },
  },
  data() {
    return {
      // 存放這檔名設定的資料
      fileSettings: {},
      // 存放自定義序號陣列
      diyForm: {},
      // 啟用輸出設定
      enable: false,
      // 輸出設定字尾名
      ext: [],
      // 原檔案列表
      oldFiles: [],
    };
  },
};
</script>

<style lang="less" scoped>
.content {
  width: 1366px;
  box-sizing: border-box;
  padding: 0 15px;
  margin: 0 auto;
  overflow-x: hidden;
}
</style>

思考一下

但是有沒有發現,我們寫了三個divider元件,要繫結的資料也是相當之多,雖然我都整合在fileSettings了。如果我們要單獨拿出來的話,豈不是要累死個人?所以我們思考一下,怎麼可以更加方便的書寫我們的這個頁面。所以我引申出了下面三個問題:

  1. 有沒有辦法可以用一個元件來標識我們匯入的另外三個子元件呢?

  2. 有沒有辦法一次性繫結我們要的資料,而不是一個個的繫結呢?

  3. 一次性繫結之後,元件間怎麼通訊呢(因為這裡涵蓋了子孫元件)?

針對於這三個問題,我分別使用了動態元件v-bindprovide實現的,接下來我們就講講怎麼實現它,先上程式碼:

<template>
  <div class="content">
    <div v-for="item in components" :key="item.name">
      <divider orientation="left">{{ item.label }}</divider>
      <component
        :is="item.name"
        v-bind="{ ...getProps(item.props) }"
        @update="
          (key, val) => {
            update(item.props, key, val);
          }
        "
      />
    </div>
  </div>
</template>

<script>
import getNewFileList from "@/utils/";
import { Divider } from "ant-design-vue";
import FileList from "./FileList";
import FileOutput from "./FileOutput";
import FileSetting from "./FileSetting";
export default {
  name: "ModifyFilename",
  components: {
    Divider,
    FileList,
    FileOutput,
    FileSetting,
  },
  // 傳遞給深層級子元件
  provide() {
    return {
      parent: this,
    };
  },
  data() {
    return {
      components: [
        {
          label: "檔名設定",
          name: "FileSetting",
          props: "fileSettingsProps",
        },
        {
          label: "輸出設定",
          name: "FileOutput",
          props: "fileOutputProps",
        },
        {
          label: "輸出結果",
          name: "FileList",
          props: "fileListProps",
        },
      ],
      fileSettingsProps: {
        fileSettings: {
          filename: {
            value: "",
            span: 6,
            type: "file",
            placeholder: "請輸入新的檔名",
          },
          serialNum: {
            value: "",
            span: 6,
            type: "sort-descending",
            placeholder: "起始序號(預設支援純數字或純字母)",
          },
          increment: {
            value: 1,
            span: 2,
            placeholder: "增量",
            isNum: true,
          },
          preReplaceWord: {
            value: "",
            span: 3,
            type: "file",
            placeholder: "替換前的字元",
          },
          replaceWord: {
            value: "",
            span: 3,
            type: "file",
            placeholder: "替換後的字元",
          },
        },
        diyForm: {
          diySerial: "",
          separator: "",
          diyEnable: false,
        },
      },
      fileOutputProps: {
        enable: false,
        ext: ["", ""],
      },
      oldFiles: [],
    };
  },
  computed: {
    newFiles() {
      const { fileSettings, diyForm } = this.fileSettingsProps;
      const { ext, enable } = this.fileOutputProps;
      const { diySerial, separator, diyEnable } = diyForm;
      return getNewFileList(
        this.oldFiles,
        fileSettings,
        ext,
        enable,
        this.getRange(diySerial, separator, diyEnable)
      );
    },
  },
  watch: {
    "fileSettingsProps.diyForm.diySerial"(val) {
      if (!val) {
        this.fileSettingsProps.diyForm.diyEnable = !1;
      }
    },
  },
  methods: {
    getRange(diySerial, separator, enable) {
      if (!enable) return null;
      !separator ? (separator = ",") : null;
      return diySerial.split(separator);
    },
    getProps(key) {
      if (key === "fileListProps") {
        return {
          oldFiles: this.oldFiles,
          newFiles: this.newFiles,
        };
      }
      return this[key] || {};
    },
    update(props, key, val) {
      if (props === "fileListProps") {
        return (this[key] = val);
      }
      this[props][key] = val;
    },
  },
};
</script>
<style lang="less" scoped>
.content {
  width: 1366px;
  box-sizing: border-box;
  padding: 0 15px;
  margin: 0 auto;
  overflow-x: hidden;
}
</style>

程式碼裡,我通過componentis來標識我們匯入的元件,這樣就解決了我們的第一個問題。第二個問題,從文件可知,v-bind是可以繫結多個屬性值,所以我們直接通過v-bind就可以實現了。

但是,解決第二個問題後,就引發了第三個問題,因為通常我們可以通過.sync修飾符來進行props的雙向繫結,但是文件有說,在解析一個複雜表示式的時是無法正常工作的,所以我們無法通過this.$emit('update:props',newVal)更新我們的值。

所以這裡我自定義了一個update方法,通過props的方式傳遞給子元件,通過子元件觸發父元件的方法實現狀態的更新。當然,也通過provide把自身傳遞下去共子元件使用,這裡提供FileListItem(列表元件)的程式碼供大家參考:

<template>
  <a-list bordered :dataSource="fileList" :pagination="pagination" ref="list">
    <div slot="header" class="list-header">
      <strong>
        {{ filename }}
      </strong>
      <a-button type="danger" size="small" @click="clearFiles">
        清空
      </a-button>
    </div>
    <a-list-item slot="renderItem" slot-scope="item, index">
      <a-list-item-meta>
        <a-tooltip slot="title" :overlayStyle="{ maxWidth: '500px' }">
          <template slot="title">
            {{ item.name }}
          </template>
          {{ item.name }}
        </a-tooltip>
      </a-list-item-meta>
      <a-button
        ghost
        type="danger"
        size="small"
        @click="
          () => {
            delCurrent(index);
          }
        "
      >
        刪除
      </a-button>
    </a-list-item>
  </a-list>
</template>

<script>
import { List, Button, Tooltip } from "ant-design-vue";
const { Item } = List;
export default {
  name: "FileListItem",
  props: {
    fileList: {
      type: Array,
      required: true,
    },
    filename: {
      type: String,
      required: true,
    },
    pagination: {
      type: Object,
      default: () => ({
        pageSize: 10,
        showQuickJumper: true,
        hideOnSinglePage: true,
      }),
    },
  },
  inject: ["parent"],
  components: {
    "a-list": List,
    "a-list-item": Item,
    "a-list-item-meta": Item.Meta,
    "a-button": Button,
    "a-tooltip": Tooltip,
  },
  methods: {
    delCurrent(current) {
      this.parent.oldFiles.splice(current, 1);
    },
    clearFiles() {
      this.parent.update("fileListProps", "oldFiles", []);
    },
    drop(e) {
      e.preventDefault();
      this.parent.update("fileListProps", "oldFiles", [
        ...this.parent.oldFiles,
        ...e.dataTransfer.files,
      ]);
    },
  },
  mounted() {
    let $el = this.$refs.list.$el;
    this.$el = $el;
    if ($el) {
      $el.ondragenter = $el.ondragover = $el.ondragleave = () => false;
      $el.addEventListener("drop", this.drop, false);
    }
  },
  destroyed() {
    this.$el && this.$el.removeEventListener("drop", this.drop, false);
  },
};
</script>

<style lang="less" scoped>
.list-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
</style>

這裡我們看到,可以直接呼叫父元件身上的方法來進行資料的更新(當然這裡也可以用splice更新資料),這樣也就解決了我們之前的三個問題啦。

reactreact可是很常用擴充套件運算子傳屬性的哦,雖然是jsx都可以 ? 但是我們通過v-bind也可以繫結複雜屬性,知識點哦(●'◡'●)

Antd 的坑

自定義序號

因為我自定義序號採用的事彈窗,又因為我們採用的是按需載入,在ant-design-vue@1.6.2版本中會報Failed to resolve directive: ant-portal無法解析指令的錯誤,所以我們需要在main.js中全域性註冊他,然後因為我們請求可能會用message,所以我順便也把message放到vue的原型鏈上了,即:

import Vue from "vue";
import App from "./App.vue";
import { Message, Modal } from "ant-design-vue";
import axios from "axios";
import VueAxios from "vue-axios";
Vue.config.productionTip = false;
Vue.use(VueAxios, axios);

// Failed to resolve directive: ant-portal
// https://github.com/vueComponent/ant-design-vue/issues/2261
Vue.use(Modal);
Vue.prototype.$message = Message;
new Vue({
  render: (h) => h(App),
}).$mount("#app");

這樣子就可以愉快的使用我們的Modal了,然後到了元件選型上了,一開始我選的元件時Form元件,但是寫著寫著發現我們有個自定義序號是否啟用自定義相關聯,而且Form元件如果時必選的話,只能通過v-decorator指令的rules實現繫結資料和必選,不能通過v-model進行資料的雙向繫結(不能偷懶)。

因為我們的ant-design-vue版本已經是1.5.0+,而FormModel元件也支援支援v-model檢驗,那麼就更符合我們的需求啦,所以我這裡改了下我的程式碼,使用FormModel元件實現我們的需求了:

<template>
  <a-modal
    title="自定義序號"
    :visible="serialNumVisible"
    @cancel="serialNumVisible = !1"
    @ok="handleDiySerialNum"
  >
    <a-form-model
      ref="diyForm"
      :model="diyForm"
      :rules="rules"
      labelAlign="left"
      :label-col="{ span: 6 }"
      :wrapper-col="{ span: 18 }"
    >
      <a-form-model-item label="自定義序號" prop="diySerial">
        <a-input
          v-model="diyForm.diySerial"
          placeholder="請輸入自定義序號"
          aria-placeholder="請輸入自定義序號"
        />
      </a-form-model-item>
      <a-form-model-item label="自定義分隔符" prop="separator">
        <a-input
          v-model="diyForm.separator"
          placeholder="請輸入自定義序號分隔符(預設,)"
          aria-placeholder="請輸入自定義序號分隔符"
        />
      </a-form-model-item>
      <a-form-model-item label="是否啟用自定義">
        <a-switch v-model="diyForm.diyEnable" :disabled="disabled" />
      </a-form-model-item>
    </a-form-model>
  </a-modal>
</template>

ps:果然,懶人還是推動技術進步的最主要的動力啊 ?

post 請求下載檔案

因為我之前說過,我們後端不想保留返回的zip檔案,所以我們是以流(Stream)傳遞個前端的,那我們怎麼實現在個功能呢?

其實,還是挺簡單的。主要是後端設定兩個請求頭,分別是Content-TypeContent-Disposition,一個告訴瀏覽器是什麼型別,一個是告訴要以附件的形式下載,並指明預設檔名。

Content-Type我想大家都很常見了把,而且也不用我們處理了,所以這裡我們講講再怎麼處理Content-Disposition,即獲取預設的檔名,如圖所示:

Content-Disposition響應頭

從圖可以看出,響應頭資訊為content-disposition: attachment; filename="files.zip"。看到這個字串,我們第一眼可以能機會想到通過split方法分割=然後下標取1就可以獲取檔名了。但是發現了嗎?我們獲取的檔名是"files.zip",與我們想要的結果不同,雖然我們可以通過切割來實現獲取到files.zip,但是假設有一天伺服器返回的不帶"就不通用了。

那怎麼辦呢?沒錯啦,就是通過正則並搭配字串的replace方法來獲取啦~~當然,正則不是本篇的重點,所以就不講正則怎麼寫了,接下來書寫我們的方法:

// 獲取content-disposition響應頭的預設檔名
const getFileName = (str) => str.replace(/^.*filename="?([^"]+)"?.*$/, "$1");
const str = `content-disposition: attachment; filename=files.zip`;
const doubleStr = `content-disposition: attachment; filename="files.zip"`;

console.log(getFileName(str)); // files.zip
console.log(getFileName(doubleStr)); // files.zip

看輸出的是不是和預期的一樣?如果一樣,這裡就實現了我們的獲取使用者名稱的方法了,主要用到的就是正則replace的特殊變數名

ps:不知道repalce搞基(高階)用法的請看請點選我,這裡就不闡述啦

當然,寫到這裡其實如果是get請求,那麼瀏覽器會預設就下載檔案了,我們也不用獲取檔名。但是我們是post請求,所以我們需要處理這一些列的東西,並且期待responseTypeblob型別,所以我們就寫一下前端怎麼請求後端並下載檔案的。

既然要寫前端程式碼,那麼就要先和後端約定介面是什麼,這裡因為後端也是自己寫的,所以我們暫且把介面定義為http://localhost:3000/upload,又因為我們這裡vue的埠在8080,肯定會和我們的後端跨域,所以我們需要在vue.config.js,配置一下我們的代理,即:

module.exports = {
  // 靜態資源匯入的路徑
  publicPath: "./",
  // 輸出目錄,因為這裡我們Node設定本上級public為靜態服務
  // 實際設定成koa-static設定batch-front-end/dist為靜態目錄的話就不要修改了
  // 具體看自己變通
  outputDir: "../public",
  // 生產環境下不輸出.map檔案
  productionSourceMap: false,
  devServer: {
    // 自動開啟瀏覽器
    open: true,
    // 配置代理
    proxy: {
      "/upload": {
        target: "http://localhost:3000",
        // 傳送請求頭中host會設定成target
        changeOrigin: true,
        // 路徑重寫
        pathRewrite: {
          "^/upload": "/upload",
        },
      },
    },
  },
  css: {
    loaderOptions: {
      less: {
        javascriptEnabled: true,
      },
    },
  },
};

這樣子我們就可以跨域請求我們的介面啦,既然說到了下載檔案,我們就簡單看下file-saver是怎麼使用的,從官方例項來看:

import { saveAs } from "file-saver";
const blob = new Blob(["Hello, world!"], { type: "text/plain;charset=utf-8" });
saveAs(blob, "hello world.txt");

從示例來看,它可以直接儲存blob,並指定檔名為hello world.txt,知道這個之後我們就可以書寫我們的程式碼啦:

import { saveAs } from "file-saver";
this.axios({
  method: "post",
  url: "/upload",
  data,
  // 重要,告訴瀏覽器響應的型別為blob
  responseType: "blob",
})
  .then((res) => {
    const disposition = res.headers["content-disposition"];
    // 轉換為Blob物件
    let file = new Blob([res.data], {
      type: "application/zip",
    });
    // 下載檔案
    saveAs(file, getFileName(disposition));
    this.$message.success("修改成功");
  })
  .catch(() => {
    this.$message.error("發生錯誤");
  });

基本下,這樣就可以實現下載檔案啦,下面是FileSetting.vue原始碼,僅供參考:

<template>
  <a-row type="flex" :gutter="16">
    <a-col
      :key="key"
      :span="setting.span"
      v-for="(setting, key) in fileSettings"
    >
      <template v-if="setting.isNum">
        <a-input-number
          style="width:100%"
          :placeholder="setting.placeholder"
          :min="1"
          v-model="setting.value"
        />
      </template>
      <template v-else>
        <a-input
          :placeholder="setting.placeholder"
          v-model="setting.value"
          allowClear
        >
          <a-icon
            slot="prefix"
            :type="setting.type"
            style="color:rgba(0,0,0,.25)"
          />
        </a-input>
      </template>
    </a-col>
    <a-col>
      <a-button @click="serialNumVisible = !0">
        自定義序號
      </a-button>
    </a-col>
    <a-col>
      <a-button type="primary" @click="handleModify">
        確定修改
      </a-button>
    </a-col>

    <a-modal
      title="自定義序號"
      :visible="serialNumVisible"
      @cancel="serialNumVisible = !1"
      @ok="handleDiySerialNum"
    >
      <a-form-model
        ref="diyForm"
        :model="diyForm"
        :rules="rules"
        labelAlign="left"
        :label-col="{ span: 6 }"
        :wrapper-col="{ span: 18 }"
      >
        <a-form-model-item label="自定義序號" prop="diySerial">
          <a-input
            v-model="diyForm.diySerial"
            placeholder="請輸入自定義序號"
            aria-placeholder="請輸入自定義序號"
          />
        </a-form-model-item>
        <a-form-model-item label="自定義分隔符" prop="separator">
          <a-input
            v-model="diyForm.separator"
            placeholder="請輸入自定義序號分隔符(預設,)"
            aria-placeholder="請輸入自定義序號分隔符"
          />
        </a-form-model-item>
        <a-form-model-item label="是否啟用自定義">
          <a-switch v-model="diyForm.diyEnable" :disabled="disabled" />
        </a-form-model-item>
      </a-form-model>
    </a-modal>
  </a-row>
</template>

<script>
import {
  Row as ARow,
  Col as ACol,
  Icon as AIcon,
  Input as AInput,
  Switch as ASwitch,
  Button as AButton,
  InputNumber as AInputNumber,
  FormModel as AFormModel,
} from "ant-design-vue";
import { saveAs } from "file-saver";
// 是否符合預設序號規範
import { isDefaultSerialNum } from "@/utils/regexp";
const AFormModelItem = AFormModel.Item;

// 獲取content-disposition響應頭的預設檔名
const getFileName = (str) => str.replace(/^.*filename="?([^"]+)"?.*$/, "$1");

export default {
  name: "FileSetting",
  props: {
    fileSettings: {
      type: Object,
      required: true,
    },
    diyForm: {
      type: Object,
      required: true,
    },
  },
  components: {
    ARow,
    ACol,
    AIcon,
    AInput,
    ASwitch,
    AButton,
    AInputNumber,
    AFormModel,
    AFormModelItem,
  },
  inject: ["parent"],
  // 沒有自定義序號時不可操作
  computed: {
    disabled() {
      return !this.diyForm.diySerial;
    },
  },
  data() {
    return {
      serialNumVisible: !1,
      rules: {
        diySerial: [
          {
            required: true,
            message: "請輸入自定義序號",
            trigger: "blur",
          },
        ],
      },
    };
  },
  methods: {
    handleModify() {
      // 獲取填寫的序號
      const serialNum = this.fileSettings.serialNum.value;
      // 當沒有啟用自定義時,走預設規則
      if (isDefaultSerialNum(serialNum) && !this.diyForm.enable) {
        return this.$message.error("請輸入正確的序號,格式為純數字或純字母");
      }
      const { newFiles, oldFiles } = this.parent;
      const data = new FormData();
      for (let i = 0; i < oldFiles.length; i++) {
        const { name } = newFiles[i];
        data.append("files", oldFiles[i]);
        data.append("name", name);
      }
      this.axios({
        method: "post",
        url: "/upload",
        data,
        responseType: "blob",
      })
        .then((res) => {
          const disposition = res.headers["content-disposition"];
          // 轉換為Blob物件
          let file = new Blob([res.data], {
            type: "application/zip",
          });
          // 下載檔案
          saveAs(file, getFileName(disposition));
          this.$message.success("修改成功");
        })
        .catch(() => {
          this.$message.error("發生錯誤");
        });
    },
    handleDiySerialNum() {
      this.$refs.diyForm.validate((valid) => {
        if (!valid) {
          return false;
        }
        this.serialNumVisible = !1;
      });
    },
  },
};
</script>

至此,和後端互動的邏輯基本上已經寫完了,但是- -我們好像還沒有寫前端頁面的實際邏輯,那麼接下來就開始寫前端邏輯啦,可能比較囉嗦- -So Sorry?

ps:webpack 是開發解決跨域問題,線上該跨域還是要跨域,最好的方法是cors或者proxy,再不然就是放在node的靜態服務裡。

書寫前端邏輯

從之前的圖來看,很顯然我們的邏輯就是處理新檔案列表這個資料,而這個資料則是根據頁面其他元件的值來實現的,所以我們之前用了計算屬性來實現,但是我們邏輯卻還沒寫,所以接下來就是處理這個最重要的邏輯啦。

不過好像寫好了介面,卻還沒有說,我想實現什麼東西,所以簡單的說一下我們介面的互動,我們再開始寫邏輯吧。

前端互動

通過圖片,我們大概可以知道有這些操作:

  1. 我們可以通過輸入檔名,批量修改所有的檔名

  2. 通過序號,讓檔名後面新增字尾,預設支援純數字和純字母,即輸入test1+001則輸出test1001

  3. 通過增量,我們可以讓檔案的字尾加上增量的值,即加設增量為 2,這裡的下一個就是test1+00(1+2),為test1003

  4. 通過輸入需要替換的字元-test替換的字元-測試,把所有檔案的名稱修改替換為為測試1+001,即測試1001

  5. 通過輸入需要修改的字尾名-png替換的字元-txt並開啟修改開關,把所有符合png字尾名的都修改為txt,因為這裡只有一個,所以修改為測試1001.txt,即test1001.png->測試1001.png->測試1001.txt

而啟用自定義序號之後,還有後續操作,如圖所示:

啟用自定義序號

前端互動

  1. 輸入g,a,t,i,n,g這個自定義序號化時,我們的序號的值就應該為["g", "a", "t", "i", "n", "g"]中的一個

  2. 當我們序號為g且增量為2時,第一個檔案的字尾為g,第二個為t,以為g為列表的第一個,那麼他下一個的就為1+2,即列表的第三個,也就是t

  3. 純字母分小寫和大寫,所以這裡我們也需要處理一下

  4. 檔名+字尾名不能為.,因為在 pc 上是建立不了檔案的

知道這 9 點後,我們就可以開始寫我們的程式碼啦。其實主要分為幾大塊:

  • 檔名的處理

  • 字尾名的處理

  • 自定義序號的處理

思考一下

還記得我們之前的目錄結構嗎?裡面有一個utils(工具庫)的資料夾,我們就在這裡書寫我們的方法。

先思考一下,我們這些都是針對字串的,那麼什麼和字串最合適呢?肯定是正則啦。

首先當然是書寫我們常用的正則啦,主要有那麼幾個:

  • 獲取檔名和擴充名

  • 判斷是不是空字串,為空不處理

  • 檔名+字尾名不能是.

  • 在沒有自定義序號的情況下,是否符合純字母這種情況,主要用於區分純數字和純字母這兩種情況

  • 是否符合預設序號規範(純數字或者純字母)

utils/regexp.js中寫上:

// 匹配檔名和擴充名
export const extReg = /(.+)(\..+)$/;
// 是否為空字串
export const isEmpty = /^\s*$/;
/**
 * 整個檔名+字尾名不能是 .
 * @param { string }} str 檔名
 */
export const testDot = (str) => /^\s*\.+\s*$/.test(str);

/**
 * 序號是否為字母
 * @param { string }} str 序號
 */
export const testWord = (str) => /^[a-zA-Z]+$/.test(str);

/**
 * 是否符合預設序號規範
 * @param { string } str 序號
 * @return { object } 返回是否符合預設序號規範(純字母/純數字)
 */
export const isDefaultSerialNum = (str) =>
  !/(^\d+$)|(^[a-zA-Z]+$)/.test(str) && !isEmpty.test(str);

書寫完正則後,就到了我們的utis/helpers.js幫助函式了,幫助函式主要有三個,分別做了三件事:

  1. 判斷首字母是不是大寫,用於區分aA,因為aA序號輸出的內容完全不同

  2. 計算預設情況中字母序號和自定義序號的實際值

  3. 用於轉換預設情況中字母序號和自定義序號的值

針對第一點,其實大家應該都知道怎麼寫了把,也比較簡單,我們直接通過正則就好了:

/**
 * 判斷是不是大寫字母
 * @param { string } word => 字母
 * @return { boolean } 返回是否大寫字母
 */
export const isUpper = (word) => {
  return /^[A-Z]$/.test(word[0]);
};

2、3點的話,可能會覺得比較拗口,也比較難理解。不怕,我舉個例子你就理解了。

假設我們是預設是輸入的是純字母的情況,如果輸入a,那麼輸出是不是就是1,即是第一個字母;輸入z,則是26。又因為我們最後得到的字串,所以我們需要把26這個值轉換成z,其實就是反著來。

乍一看,是不是很像26進位制10進位制?對的,沒錯,其實就是26進位制轉換成10進位制。那麼我們怎麼轉換呢?

然後我們也說過,要把字母先轉成實際的值,在轉換成十進位制在進行上面的操作。

那麼怎麼計算十進位制的值呢?比如baa轉成十進位制是多少?它的運算規則是這樣的baa = 26**2*2 + 26**1*1 + 26**0*1,即1379,知道規則之後,我們就可以寫出以下的計算程式碼,

// 建立一個連續的整數陣列
import { range } from "lodash";
// 建立一個[0-25]的陣列,並轉換為[A-Z]陣列供預設字母序號使用
let convertArr = range(26).map((i) => String.fromCharCode(65 + i));
const serialNum = "baa",
  complement = serialNum.length;
/**
 * 計算第n位26進位制數的十進位制值
 * @param {*} range => 26進位制陣列
 * @param {*} val => 當前值
 * @param {*} idx => 當前的位置
 * @returns { number } 第n位26進位制數的十進位制值
 */
const calculate = (range, val, idx) => {
  let word = range.indexOf(val.toLocaleUpperCase());
  return word === -1 ? 0 : (word + 1) * 26 ** idx;
};

const sum = [...serialNum].reduce(
  (res, val, idx) => res + calculate(convertArr, val, complement - 1 - idx),
  0
);
console.log(sum); // 1379

這樣子就計算好了我們的值,接下來就是對這個值進行轉換為字母了。因為我們不是從 0 開始,而是從 1 開始,所以每一位的時候我們只需要前的位進行減 1 操作即可。

ps: 不知道**冪運算子的,建議看看 es7,比如26 ** 3,它相當於 Math.pow(26,3),即26 * 26 * 26

// 建立一個連續的整數陣列
import { range } from "lodash";
// 建立一個[0-25]的陣列,並轉換為[A-Z]陣列供預設字母序號使用
let convertArr = range(26).map((i) => String.fromCharCode(65 + i));

/**
 * 26進位制轉換
 * @param { number } num => 轉換的值
 * @param { array } range => 轉換的編碼
 * @return { string } 返回轉換後的字串
 */
const convert = (num, range) => {
  let word = "",
    len = range.length;
  while (num > 0) {
    num--;
    word = range[num % len] + word;
    // ~~位運算取整
    num = ~~(num / len);
  }

  return word;
};

console.log(convert(1379, convertArr)); // BAA

所以最終的utils/helpers.js檔案程式碼如下:

/**
 * 判斷是不是大寫字母
 * @param { string } word => 字母
 * @return { boolean } 返回是否大寫字母
 */
export const isUpper = (word) => {
  return /^[A-Z]$/.test(word[0]);
};

/**
 * 進位制轉換
 * @param { number } num => 轉換的值
 * @param { array } range => 轉換的編碼
 * @return { string } 返回轉換後的字串
 */
export const convert = (num, range) => {
  // 沒有range的時候即為數字,數字我們不需要處理
  if (!range) return num;
  let word = "",
    len = range.length;
  while (num > 0) {
    num--;
    word = range[num % len] + word;
    num = ~~(num / len);
  }

  return word;
};

/**
 * 計算第n位進位制數的十進位制值
 * @param {*} range => 進位制陣列
 * @param {*} val => 當前值
 * @param {*} idx => 當前的位置
 * @returns { number } 第n位進位制數的十進位制值
 */
export const calculate = (range, val, idx) => {
  let word = range.indexOf(val);
  const len = range.length;
  return word === -1 ? 0 : (word + 1) * len ** idx;
};

這裡我把range作為引數傳過來我想大家應該能理解吧?因為其實自定義序號預設的字母序號的處理是一樣,所以這裡我們直接傳入range就可以處理自定義純字母這種情況了。

無非就是n進位制十進位制的操作,計算規則也同理。。。

不過寫到這裡,可能要罵我了- -這不就是把baa轉為1379,然後再把1379轉回BAA,壓根就沒有做什麼操作啊?━━( ̄ー ̄*|||━━

小傻瓜,其實不是的,這裡我們只是用一個baa作為演示,假設我們有多個檔案,不就是需要它實際的值+增量來計算了嗎,大概就是:

// 虛擬碼...
function getNewFileList(fileList, serialNum, increment, range) {
  // 起始序號
  let start = [...serialNum].reduce(
    (res, val, idx) => res + calculate(convertArr, val, complement - 1 - idx),
    0
  );
  return fileList.map((file) => {
    // 得出字尾
    const suffer = convert(start, range);
    // 根據increment增量自增
    start += increment;
    return {
      ...file,
      name: file.name + suffer,
    };
  });
}

到這裡,我們基本上對序號處理已經完成了,剩下來就是比較簡單的了,也就是對檔名字尾名進行處理。還記得我們之前定義的正則,接下來我們就是使用它的時候了。

處理檔名和字尾名

先書寫我們覺得比較容易處理的方法,比如獲取檔案和檔案字尾名根據指定字元替換檔名,在utils/index.js檔案書寫如下程式碼:

import { extReg } from "./regexp";
/**
 * 獲取檔案和檔案字尾名
 * @param { string } filename 原始檔名
 * @return { array } 返回的檔案和檔案字尾名
 */
const splitFilename = (filename) =>
  filename.replace(extReg, "$1,$2").split(",");

/**
 * 替換檔名
 * @param { string } filename 檔名
 * @param { string } preReplaceWord 需要替換的字元
 * @param { string } replaceWord 替換的字元
 * @return { string } 返回替換後的檔名
 */
const replaceFilename = (filename, preReplaceWord, replaceWord) =>
  filename.replace(preReplaceWord, replaceWord);

還記得我們之前我們的fileSettings這個配置嗎?他有很多一個物件,而我們只需要獲取到這物件下的value值,但是一個個解構賦值比較麻煩,所以我們也可以寫一個方法在獲取到它的value值在結構賦值,即:

/**
 * 獲取檔名設定
 * @param { Object } fileSettings 檔名設定
 */
const getFileSetting = (fileSettings) =>
  Object.values(fileSettings).map((setting) => setting.value);
const fileSettings = {
  filename: {
    value: "test",
  },
  serialNum: {
    value: "aaa",
  },
};
const [filename, serialNum] = getFileSetting(fileSettings);
console.log(filename, serialNum); // test aaa

這樣子就能很方便的解構賦值了,再然後就是根據輸入的字尾名獲得新的字尾名,這裡有個有個暴力的配置,所以單獨拿出來講講。

因為我們需要對*這個字串進行全域性的替換,同時也需要對字尾名,比如png;或者點+字尾名,比如.png。這兩種情況處理。所以程式碼是:

import { startsWith } from "lodash";
/**
 * 根據輸入的字尾名,獲取修改檔名的的字尾名
 * @param { array } fileExt => 檔案字尾名陣列
 * @return { array } [oldExt, newExt] => 返回檔案的字尾名
 */
const getFileExt = (fileExt) =>
  // 如果 i 不存在,返回""
  // 如果 i 是以 "." 開頭的返回i,即'.png'返回'.png'
  // 如果 i === "*" 的返回i,即'*'返回'*'
  // 如果是 i 是 'png',則返回 '.png'
  fileExt.map((i) =>
    i ? (startsWith(i, ".") || i === "*" ? i : "." + i) : ""
  );

ps:提一點,如果不知道startsWith這個方法的,建議閱讀字串的方法的總結和使用,當然我這裡用的是lodashstartsWith,但實際上一樣的

這裡程式碼翻譯成中文就是:

  1. 如果字尾名不存在,返回""(空字串)

  2. 如果字尾名是以.開頭的返回字尾名,即.png返回.png

  3. 如果字尾名* 的返回*,即*返回*

  4. 如果是字尾名不是以.開頭的返回png,則返回.png

寫完獲取字尾名之後就是修改啦,這裡單獨拿出來談主要也是因為有個小坑,因為有些檔案比較奇葩,他是.+名字,比如我們常常見到的.gitignore檔案

所以我們需要針對這種.+名字這型別的檔案進行一個區分,即沒有字尾名的檔案的處理:

/**
 * 獲取修改後的字尾名
 * @param { string } fileExt => 匹配的檔案字尾名
 * @param { string } oldExt => 所有檔案字尾名
 * @param { string } newExt => 修改後檔案字尾名
 * @param { boolean } enable => 是否啟用修改字尾名
 * @return { string } 返回修改後的字尾名
 */
const getNewFileExt = (fileExt, oldExt, newExt, enable) => {
  if ((oldExt === "*" || fileExt === oldExt) && enable) {
    return newExt;
  } else {
    // 避免沒有字尾名的bug,比如 .gitignore
    return fileExt || "";
  }
};

寫到這裡,基本上邏輯要寫完了,但是還有一個最最最小的問題,就是他可能會輸入001,而我們之前的程式碼會把001轉為數字,即會直接轉為1。這不是我們想要的,那麼我們怎麼讓他還是字串形式,但是還是按照數字計算呢?

ps:因為轉換值需要+增量,不可能用字串相加的,所以必須轉成數字

所以這裡就要需要用到es6padStart方法啦,通過他來進行序號的補位,然後把之前的方法整理下,定義為getOptions函式,獲取通用的配置:

import { range, padStart } from "lodash";
import { testWord } from "./regexp";
import { calculate, isUpper } from "./helpers";
let convertArr = range(26).map((i) => String.fromCharCode(65 + i));
/**
 *  獲取起始位置、補位字元和自定義陣列
 * @param { string } serialNum 檔案序號
 * @param { number } complement 需要補的位數
 * @param { array } range 自定義序號陣列
 * @return { object } 返回起始位置、補位字元和自定義陣列
 */
const getOptions = (serialNum, complement, range) => {
  // 起始序號的值,補位序號
  let start, padNum;
  // 字母和自定義序號的情況
  if (testWord(serialNum) || range) {
    if (!range) {
      // 轉換大小寫
      if (!isUpper(serialNum[0])) {
        convertArr = convertArr.map((str) => str.toLocaleLowerCase());
      }
      range = convertArr;
    }
    // 補位字元
    padNum = range[0];
    start = [...serialNum].reduce(
      (res, val, idx) => res + calculate(range, val, complement - 1 - idx),
      0
    );
  } else {
    // 純數字的情況
    start = serialNum ? ~~serialNum : NaN;
    // 補位字元
    padNum = "0";
  }
  return {
    start,
    padNum,
    convertArr: range,
  };
};
let { start, padNum } = getOptions("001", 3);
console.log(padStart(convert(start) + "", 3, padNum)); // 001

如果不知道padStart這個方法的,建議閱讀字串的方法的總結和使用,當然我這裡用的是lodashpadStart,但實際上一樣的

寫好這一對方法之後,我們就可以實現剛剛那個虛擬碼了,而我們最終vue裡面也就需要這一個方法,所以直接匯出就行了。

utils/index.js最終程式碼如下:

import {
  extReg,
  testWord,
  isDefaultSerialNum,
  isEmpty,
  testDot,
} from "./regexp";
import { calculate, isUpper, convert } from "./helpers";
import { range, padStart, startsWith } from "lodash";
// 建立一個[0-25]的陣列,並轉換為[A-Z]陣列供預設字母序號使用
let convertArr = range(26).map((i) => String.fromCharCode(65 + i));
/**
 * 獲取修改後的字尾名
 * @param { string } fileExt => 匹配的檔案字尾名
 * @param { string } oldExt => 所有檔案字尾名
 * @param { string } newExt => 修改後檔案字尾名
 * @param { boolean } enable => 是否啟用修改字尾名
 * @return { string } 返回修改後的字尾名
 */
const getNewFileExt = (fileExt, oldExt, newExt, enable) => {
  if ((oldExt === "*" || fileExt === oldExt) && enable) {
    return newExt;
  } else {
    // 避免沒有字尾名的bug
    return fileExt || "";
  }
};

/**
 * 根據輸入的字尾名,獲取修改檔名的的字尾名
 * @param { array } fileExt => 檔案字尾名陣列
 * @return { array } [oldExt, newExt] => 返回檔案的字尾名
 */
const getFileExt = (fileExt) =>
  // 如果 i 不存在,返回""
  // 如果 i 是以 "." 開頭的返回i,即'.png'返回'.png'
  // 如果 i === "*" 的返回i,即'*'返回'*'
  // 如果是 i 是 'png',則返回 '.png'
  fileExt.map((i) =>
    i ? (startsWith(i, ".") || i === "*" ? i : "." + i) : ""
  );

/**
 * 獲取檔案和檔案字尾名
 * @param { string } filename 原始檔名
 * @return { array } 返回的檔案和檔案字尾名
 */
const splitFilename = (filename) =>
  filename.replace(extReg, "$1,$2").split(",");

/**
 * 替換檔名
 * @param { string } filename 檔名
 * @param { string } preReplaceWord 需要替換的字元
 * @param { string } replaceWord 替換的字元
 * @return { string } 返回替換後的檔名
 */
const replaceFilename = (filename, preReplaceWord, replaceWord) =>
  filename.replace(preReplaceWord, replaceWord);

/**
 * 獲取檔名設定
 * @param { Object } fileSettings 檔名設定
 */
const getFileSetting = (fileSettings) =>
  Object.values(fileSettings).map((setting) => setting.value);

/**
 *  獲取起始位置、補位字元和自定義陣列
 * @param { string } serialNum 檔案序號
 * @param { number } complement 需要補的位數
 * @param { array } range 自定義序號陣列
 * @return { object } 返回起始位置、補位字元和自定義陣列
 */
const getOptions = (serialNum, complement, range) => {
  // 起始序號的值,補位序號
  let start, padNum;
  // 字母和自定義序號的情況
  if (testWord(serialNum) || range) {
    if (!range) {
      // 轉換大小寫
      if (!isUpper(serialNum[0])) {
        convertArr = convertArr.map((str) => str.toLocaleLowerCase());
      }
      range = convertArr;
    }
    // 補位字元
    padNum = range[0];
    start = [...serialNum].reduce(
      (res, val, idx) => res + calculate(range, val, complement - 1 - idx),
      0
    );
  } else {
    // 純數字的情況
    start = serialNum ? ~~serialNum : NaN;
    // 補位字元
    padNum = "0";
  }
  return {
    start,
    padNum,
    convertArr: range,
  };
};
/**
 * 獲取檔名
 * @param { string } filename 舊檔名
 * @param { string } newFilename 新檔名
 * @return { string } 返回最終的檔名
 */
const getFileName = (filename, newFilename) =>
  isEmpty.test(newFilename) ? filename : newFilename;

/**
 * 根據配置,獲取修改後的檔名
 * @param { array } fileList 原檔案
 * @param { object } fileSettings 檔名設定
 * @param { array } extArr 修改的字尾名
 * @param { boolean } enable 是否啟用修改字尾名
 * @return { array } 修改後的檔名
 */
export default function getNewFileList(
  fileList,
  fileSettings,
  extArr,
  enable,
  range
) {
  const [
    newFilename,
    serialNum,
    increment,
    preReplaceWord,
    replaceWord,
  ] = getFileSetting(fileSettings);

  // 如果不符合預設序號規則,則不改名
  if (isDefaultSerialNum(serialNum) && !range) {
    return fileList;
  }
  // 獲取檔案修改的字尾名
  const [oldExt, newExt] = getFileExt(extArr);

  // 補位,比如輸入的是001 補位就是00
  const padLen = serialNum.length;
  // 獲取開始
  let { start, padNum, convertArr } = getOptions(serialNum, padLen, range);

  return fileList.map((item) => {
    // 獲取檔名和字尾名
    let [oldFileName, fileExt] = splitFilename(item.name);
    // 獲取修改後的檔名
    let filename = replaceFilename(
      getFileName(oldFileName, newFilename),
      preReplaceWord,
      replaceWord
    );
    // 獲取修改後的字尾名
    fileExt = getNewFileExt(fileExt, oldExt, newExt, enable);
    const suffix =
      (padLen && padStart(convert(start, convertArr) + "", padLen, padNum)) ||
      "";
    filename += suffix;
    start += increment;
    // 檔名+字尾名不能是.
    let name = testDot(filename + fileExt) ? item.name : filename + fileExt;
    return {
      ...item,
      basename: filename,
      name,
      ext: fileExt,
    };
  });
}

看到這裡,你會發現,我多數方法只做一件事,通常也建議只做一件事(單一職責原則),這樣有利於降低程式碼複雜度和降低維護成本。希望大家也能養成這樣的好習慣哦~~ ?

ps:函式應該做一件事,做好這件事,只做這一件事。 —程式碼整潔之道

優化相關

預載入(preload)和預處理(prefetch)

preloadprefetch不同的地方就是它專注於當前的頁面,並以高優先順序載入資源,prefetch專注於下一個頁面將要載入的資源並以低優先順序載入。同時也要注意preload並不會阻塞windowonload事件。

preload載入資源一般是當前頁面需要的, prefetch一般是其它頁面有可能用到的資源。

明白這點後,就是在vue.config.js寫我們的配置了:

module.exports = {
  // ...一堆之前配置
  chainWebpack(config) {
    // 建議開啟預載入,它可以提高第一屏的速度
    config.plugin("preload").tap(() => [
      {
        rel: "preload",
        // to ignore runtime.js
        // https://github.com/vuejs/vue-cli/blob/dev/packages/@vue/cli-service/lib/config/app.js#L171
        fileBlacklist: [/\.map$/, /hot-update\.js$/, /runtime\..*\.js$/],
        include: "initial",
      },
    ]);
    // 去除預讀取,因為如果頁面過多,會造成無意義的請求
    config.plugins.delete("prefetch");
  },
};

ps:優化網站效能的 pre 家族還有dns-prefetchprerenderpreconnect,有興趣的可以進一步瞭解

提取 runtime.js

因為打包生成的runtime.js非常的小,但這個檔案又經常會改變,它的http耗時遠大於它的執行時間了,所以建議不要將它單獨拆包,而是將它內聯到我們的index.html之中,那麼則需要使用到script-ext-html-webpack-plugin這個外掛,我們安裝一下,同樣是開發依賴-D

npm i script-ext-html-webpack-plugin -D

接下來就是在vue.config.js寫我們的配置了:

const isProduction = process.env.NODE_ENV !== "development";
module.exports = {
  // ...一堆之前配置
  chainWebpack(config) {
     // 只在生成環境使用
     config.when(isProduction, (config) => {
      // html-webpack-plugin的增強功能
      // 打包生成的 runtime.js非常的小,但這個檔案又經常會改變,它的 http 耗時遠大於它的執行時間了,所以建議不要將它單獨拆包,而是將它內聯到我們的 index.html 之中
      // inline 的name 和你 runtimeChunk 的 name保持一致
      config
        .plugin("ScriptExtHtmlWebpackPlugin")
        .after("html")
        .use("script-ext-html-webpack-plugin", [
          {
            inline: /runtime\..*\.js$/,
          },
        ])
        .end();
      // 單獨打包runtime
      config.optimization.runtimeChunk("single");
  },
};

對第三方庫進行拆包

實際上預設我們會講所有的第三方包打包在一個檔案上,這樣的方式可行嗎?實際上肯定是有問題的,因為將第三方庫一塊打包,只要有一個庫我們升級或者引入一個新庫,這個檔案就會變動,那麼這個檔案的變動性會很高,並不適合長期快取,還有一點,我們要提高首頁載入速度,第一要務是減少首頁載入依賴的程式碼量,所以我們需要第三方庫進行拆包。

module.exports = {
  publicPath: "./",
  outputDir: "../public",
  productionSourceMap: false,
  devServer: {
    open: true,
    proxy: {
      "/upload": {
        target: "http://localhost:3000",
        changeOrigin: true,
        pathRewrite: {
          "^/upload": "/upload",
        },
      },
    },
  },
  css: {
    loaderOptions: {
      less: {
        javascriptEnabled: true,
      },
    },
  },
  chainWebpack(config) {
      // 拆分模組
      config.optimization.splitChunks({
        chunks: "all",
        cacheGroups: {
          libs: {
            name: "chunk-libs", // 輸出名字
            test: /[\\/]node_modules[\\/]/, // 匹配目錄
            priority: 10, // 優先順序
            chunks: "initial", // 從入口模組進行拆分
          },
          antDesign: {
            name: "chunk-antd", // 將antd拆分為單個包
            priority: 20, // 權重需要大於libs和app,否則將打包成libs或app
            test: /[\\/]node_modules[\\/]_?ant-design-vue(.*)/, // 為了適應cnpm
          },
          commons: {
            name: "chunk-commons",
            test: resolve("src/components"),
            minChunks: 3,
            priority: 5,
            reuseExistingChunk: true, // 複用其他chunk內已擁有的模組
          },
        },
      });
    });
  },
};

其他優化

當然其實還有很多的優化方式,我們這裡沒有提及,比如:

  1. (偽)服務端渲染,通過prerender-spa-plugin在本地模擬瀏覽器環境,預先執行我們的打包檔案,這樣通過解析就可以獲取首屏的 HTML,在正常環境中,我們就可以返回預先解析好的 HTML 了。

  2. FMP(首次有意義繪製),通過vue-skeleton-webpack-plugin製作一份Skeleton骨架屏

  3. 使用cdn

  4. 其它等等...

編寫後端

最後,到了編寫後端了,為了符合MVC的開發模式,這裡我們建立了controllers資料夾處理我們的業務邏輯,具體目錄結構如下:

|-- batch-modify-filenames
    ├─batch-front-end     # 前端頁面
    ├─utils               # 工具庫
    |   └index.js
    ├─uploads             # 存放使用者上傳的檔案
    ├─routes              # 後端路由(介面)
    |   ├─index.js        # 路由入口檔案
    |   └upload.js        # 上傳介面路由
    ├─controllers         # 介面控制器,處理據具體操作
    |      └upload.js     # 上傳介面控制器
    ├─package.json        # 依賴檔案
    ├─package-lock.json   # 依賴檔案版本鎖
    ├─app.js              # 啟動檔案

因為這次我們的後端只有一個介面,而koa-router的使用也十分簡單,所以我只會講我覺得相對有用的東西 (;´д `)ゞ(因為再講下去,篇幅就太長了)

路由的使用

koa-router使用非常簡單,我們在routes/upload.js書寫如下程式碼:

// 匯入控制器
const { upload } = require("../controllers/upload");
// 匯入路由
const Router = require("koa-router");
// 設定路由字首為 upload
const router = new Router({
  prefix: "/upload",
});
// post請求,請求地址為 ip + 字首 + '/',即'/upload/'
router.post("/", upload);
// 匯出路由
module.exports = router;

這樣子就是寫了一個介面了,你可以先upload理解為一個空方法,什麼都不知,只返回請求成功,即ctx.body="請求成功"

上文中介軟體那裡有說,upload的第一個引數為上下文,不理解的翻閱前面內容。

為了方便以後我們匯入介面,而不需要每個route都呼叫一次app.use(route.routes()).use(route.allowedMethods()),我在routes/index.js(即入口檔案),書寫了一個方法,讓他可以自動引入除index.js的其他檔案,之後我們只需要新建介面檔案就可以而不需要我們手動匯入了,程式碼如下:

const { resolve } = require("path");
// 用於獲取檔案
const glob = require("glob");
module.exports = (app) => {
  // 獲取當前資料夾下的所有檔案,除了自己
  glob.sync(resolve(__dirname, "!(index).js")).forEach((item) => {
    // 新增路由
    const route = require(item);
    app.use(route.routes()).use(route.allowedMethods());
  });
};

檔案上傳

為啥使用 koa-body 而不是 koa-bodyparser?

因為koa-bodyparser不支援檔案上傳,想要檔案上傳還必須安裝koa-multer,所以我們這裡直接使用koa-body一勞永逸。

檔案上傳優化

很顯然,我們上傳的檔案都在uploads目錄下,如果日積月累,這個目錄檔案會越來越多。但同一目錄下檔案數量過多的時候,就會影響檔案讀寫效能,這樣子是我們最不想看到的了。那麼有沒有什麼方法可以優化這個問題呢?當然是有的,我們可以檔案上傳前的進行一些操作,比如根據日期建立資料夾,然後把檔案儲存在當前日期的資料夾下。

這樣既可以保證效能,又不會導致資料夾的層次過深。而koa-body剛到又有提供onFileBegin這個函式來實現我們的需求,閒話不多說了,開始寫程式碼吧

ps:不建議層次太深,如果層次過深也會影響效能的- -

為了更好的實現我們的需求,我們需要封裝了兩個基本的工具方法。

  1. 根據日期,生成資料夾名稱

  2. 檢查資料夾路徑是否存在,如果不存在則建立資料夾

utils/index.js程式碼如下:

const fs = require("fs");
const path = require("path");
/**
 * 生成資料夾名稱
 */
const getUploadDirName = () => {
  const date = new Date();
  let month = date.getMonth() + 1;
  return `${date.getFullYear()}${month}${date.getDate()}`;
};
/**
 * 確定目錄是否存在, 如果不存在則建立目錄
 * @param {String} pathStr => 資料夾路徑
 */
const confirmPath = (dirname) => {
  if (!fs.existsSync(dirname)) {
    if (confirmPath(path.dirname(dirname))) {
      fs.mkdirSync(dirname);
      return true;
    }
  }
  return true;
};

module.exports = {
  getUploadDirName,
  confirmPath,
};

寫完工具函式後,我們就可以處理我們的koa-body這個中介軟體啦,具體程式碼如下:

const Koa = require("koa");
// uuid,生成不重複的檔名
const { v4: uuid } = require("uuid");
// 工具函式
const { getUploadDirName, confirmPath } = require("./utils/");
const app = new Koa();
// 解析post請求,
const koaBody = require("koa-body");
// 處理post請求的中介軟體
app.use(
  koaBody({
    multipart: true, // 支援檔案上傳
    formidable: {
      maxFieldsSize: 10 * 1024 * 1024, // 設定上傳檔案大小最大限制,預設2M
      keepExtensions: true, // 保持擴充名
      uploadDir: resolve(__dirname, `uploads`),
      // 檔案上傳前的一些設定操作
      onFileBegin(name, file) {
        // 生成資料夾
        // 最終要儲存到的資料夾目錄
        const dirName = getUploadDirName();
        // 生成檔名
        const fileName = uuid();
        const dir = resolve(__dirname, `uploads/${dirName}`);
        // 檢查資料夾是否存在如果不存在則新建資料夾
        confirmPath(dir);
        // 重新覆蓋 file.path 屬性
        file.path = join(dir, fileName);
        // 便於後續中介軟體使用
        // app.context.uploadPath = `${dirName}/${fileName}`;
      },
    },
  })
);

ps: 針對於uuid的版本問題,建議看:UUID 是如何保證唯一性的?,這裡我們使用的是v4版本,也是最常用的一個版本。。

檔案下載

和之前講的一樣,後端只需要設定Content-TypeContent-Disposition這兩個響應頭就可以實現下載了。但是archiver這個庫搭配Koa返回流給前端,確讓我措手不及。

我參考了官方Express 這個例子,但是發現在Koa身上不頂用,於是我就- -一直翻issue,發先很多人和我有同樣的問題,最後終於在stackoverflow找到了想要的答案。我們可以通過new Stream.PassThrough()建立一個雙向流,讓archiver通過pipe把資料流寫入到雙向流裡,再通過Koa返回給前端即可,具體實現如下(controllers/upload.js):

// 壓縮檔案
const archiver = require("archiver");
const Stream = require("stream");
// 判斷是否為陣列,如果不是,則轉為陣列
const isArray = (arr) => {
  if (!Array.isArray(arr)) {
    arr = [arr];
  }
  return arr;
};
// 上傳介面
exports.upload = async (ctx) => {
  // 獲取上傳的檔案
  let { files } = ctx.request.files;
  // 獲取上傳的檔名
  let filenames = isArray(ctx.request.body.name);
  // 將檔案轉為陣列
  files = isArray(files);
  // 設定響應頭,告訴瀏覽器我要下載的檔案叫做files.zip
  // attachment用於瀏覽器檔案下載
  ctx.attachment("files.zip");
  // 設定響應頭的型別
  ctx.set({ "Content-Type": "application/zip" });
  // 定義一個雙向流
  const stream = new Stream.PassThrough();
  // 把流返回給前端
  ctx.body = stream;
  // 壓縮成zip
  const archive = archiver("zip", {
    zlib: { level: 9 }, // Sets the compression level.
  });
  archive.pipe(stream);
  for (let i = 0; i < files.length; i++) {
    const path = files[i].path;
    archive.file(path, { name: filenames[i] });
  }
  archive.finalize();
};

這個處理也特別簡單,就是根據前端傳過來的檔名,把檔案重新命名即可。最後我們整理一下,app.js的程式碼如下:

const { resolve, join } = require("path");
const Koa = require("koa");
// 解析post請求,
const koaBody = require("koa-body");
// 靜態伺服器
const serve = require("koa-static");
// uuid,生成不重複的檔名
const { v4: uuid } = require("uuid");
// 工具函式
const { getUploadDirName, confirmPath } = require("./utils/");
// 初始化路由
const initRoutes = require("./routes");
const app = new Koa();

// 處理post請求的中介軟體
app.use(
  koaBody({
    multipart: true, // 支援檔案上傳
    formidable: {
      maxFieldsSize: 10 * 1024 * 1024, // 設定上傳檔案大小最大限制,預設2M
      keepExtensions: true, // 保持擴充名
      uploadDir: resolve(__dirname, `uploads`),
      // 檔案上傳前的一些設定操作
      onFileBegin(name, file) {
        // 最終要儲存到的資料夾目錄
        const dirName = getUploadDirName();
        const fileName = uuid();
        const dir = resolve(__dirname, `uploads/${dirName}`);
        // 檢查資料夾是否存在如果不存在則新建資料夾
        confirmPath(dir);
        // 重新覆蓋 file.path 屬性
        file.path = join(dir, fileName);
        // 便於後續中介軟體使用
        // app.context.uploadPath = `${dirName}/${fileName}`;
      },
    },
  })
);
// 靜態伺服器
app.use(
  serve(resolve(__dirname, "public"), {
    maxage: 60 * 60 * 1000,
  })
);
// 初始化路由
initRoutes(app);
app.listen(3000, () => {
  console.log(`listen successd`);
  console.log(`伺服器執行於 http://localhost:${3000}`);
});

到這裡,基本上本次本章就結束了。當然,其實我們前端介面還可以做的更加可控一點的,比如我可以修改新檔案列表的某個檔案,使他可以單獨自定義而不根據我們的配置走,而根據使用者輸入的自定義名稱走。不過,這個就留給各位當作小作業啦~~~

順便提一嘴,因為我們是在瀏覽器上操作的,沒有操作檔案的許可權,所以寫起來會比較麻煩- -如果用Electron編寫的話,就方便多了。?

gitee 地址,github 地址

最後

感謝各位觀眾老爺的觀看 O(∩_∩)O 希望你能有所收穫 ?

相關文章