node實現文件屬性批量修改(文件名)


前言

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

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

簡單的說下實現的效果

通常我們在藍湖上下載的切圖是和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 希望你能有所收獲 😁


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM