uniapp小程式遷移到TS

WindrunnerMax發表於2021-10-12

uniapp小程式遷移到TS

我一直在做的小程式就是 山科小站 也已經做了兩年了,目前是用uniapp構建的,在這期間也重構好幾次了,這次在鵝廠實習感覺受益良多,這又得來一次很大的重構,雖然小程式功能都是比較簡單的功能,但是這好不容易實習學到的東西得學以致用,那就繼續在小程式上動手吧哈哈。這次實習收穫最大倒不是怎麼遷移到TS,而是一些元件設計的概念以及目錄結構設計上的東西,不過這都是在之後重寫元件的時候要做的東西了。回到正題,小程式是用uniapp寫的,畢竟還是比較熟悉Vue語法的,這次遷移首先是要將小程式從HBuilderX遷移到cli版本,雖然用HBuilderX確實是有一定的優點,但是擴充性比較差,這些東西還是得自己折騰折騰,遷移到cli版本完成後,接下來就是要慢慢從js過渡到ts了,雖然是Vue2ts支援相對比較差,但是至少對於抽離出來的邏輯是可以寫成ts的,可以在編譯期就避免很多錯誤,另外自己使用cli建立可以搞一些其他功能,畢竟只要不操作DOM的話一般還是在用常用的js方法,例如可以嘗試接入Jest單元測試等。

遷移到cli版本

首先要遷移到cli版本,雖然 官網 上說明了如何新建一個cli版本的uniapp,但是其中還是有很多坑的。
首先在安裝依賴的時候npmyarn是沒有問題的,但是用pnpm安裝依賴的會出現無法編譯的情況,多般測試也沒有結果,像是內部有一個異常,然後被uniapp編寫的webpack外掛給捕捉了,並且沒有向外丟擲異常資訊,這就很難受,本來一直是用pnpm去管理包,現在只能是使用yarn去管理整個專案了,另外我想使用軟連線mklink -J做一箇中心包儲存也失敗了,外掛生成的dist資料夾的位置很奇怪,導致打包的時候尋找資料夾路徑失敗,也最終導致編譯失敗,所以想用uniappcli的話,還是隻能按部就班地來,不能搞些騷操作。
首先安裝全域性安裝vue-cli:

$ npm install -g @vue/cli

建立專案project:

$ npm install -g @vue/cli

之後就要選擇版本了,要選擇TypeScript的預設模板,這樣就不需要自己去配置例如tsconfig.json這種的了。在之後就需要將之前的程式碼移動到新的目錄的src目錄下,當然諸如.editorconfig這些配置檔案還是要遷移出來放置在根目錄下的,如果沒有配置一些外掛例如sass的話,現在小程式可能能夠執行了,如果還安裝了其他外掛,那就特別是要注意依賴問題,因為uniapp寫的這些外掛有的是挺老的依賴,所以需要安裝老版本外掛去相容。

安裝外掛

上邊說到了直接yarn install -D xxx可能會出現問題,比如我就遇到了sasswebpack版本不相容問題,另外eslintprettier這些規範程式碼的外掛也是需要安裝的,另外還有eslintts parser和外掛等等,在這裡我已經直接配好了,在VS Code中能夠正常執行起來,另外還配置了lint-staged等,這裡直接給予package.json的資訊,有這個檔案當然就能夠直接啟動一個正常的能夠編譯的uniapp-typescript模板了,如果還需要其他外掛的話就需要自己嘗試了。

{
  "name": "shst",
  "version": "3.6.0",
  "private": true,
  "scripts": {
    "serve": "npm run dev:h5",
    "build": "npm run build:h5",
    "build:app-plus": "cross-env NODE_ENV=production UNI_PLATFORM=app-plus vue-cli-service uni-build",
    "build:custom": "cross-env NODE_ENV=production uniapp-cli custom",
    "build:h5": "cross-env NODE_ENV=production UNI_PLATFORM=h5 vue-cli-service uni-build",
    "build:mp-360": "cross-env NODE_ENV=production UNI_PLATFORM=mp-360 vue-cli-service uni-build",
    "build:mp-alipay": "cross-env NODE_ENV=production UNI_PLATFORM=mp-alipay vue-cli-service uni-build",
    "build:mp-baidu": "cross-env NODE_ENV=production UNI_PLATFORM=mp-baidu vue-cli-service uni-build",
    "build:mp-kuaishou": "cross-env NODE_ENV=production UNI_PLATFORM=mp-kuaishou vue-cli-service uni-build",
    "build:mp-qq": "cross-env NODE_ENV=production UNI_PLATFORM=mp-qq vue-cli-service uni-build",
    "build:mp-toutiao": "cross-env NODE_ENV=production UNI_PLATFORM=mp-toutiao vue-cli-service uni-build",
    "build:mp-weixin": "cross-env NODE_ENV=production UNI_PLATFORM=mp-weixin vue-cli-service uni-build",
    "build:quickapp-native": "cross-env NODE_ENV=production UNI_PLATFORM=quickapp-native vue-cli-service uni-build",
    "build:quickapp-webview": "cross-env NODE_ENV=production UNI_PLATFORM=quickapp-webview vue-cli-service uni-build",
    "build:quickapp-webview-huawei": "cross-env NODE_ENV=production UNI_PLATFORM=quickapp-webview-huawei vue-cli-service uni-build",
    "build:quickapp-webview-union": "cross-env NODE_ENV=production UNI_PLATFORM=quickapp-webview-union vue-cli-service uni-build",
    "dev:app-plus": "cross-env NODE_ENV=development UNI_PLATFORM=app-plus vue-cli-service uni-build --watch",
    "dev:custom": "cross-env NODE_ENV=development uniapp-cli custom",
    "dev:h5": "cross-env NODE_ENV=development UNI_PLATFORM=h5 vue-cli-service uni-serve",
    "dev:mp-360": "cross-env NODE_ENV=development UNI_PLATFORM=mp-360 vue-cli-service uni-build --watch",
    "dev:mp-alipay": "cross-env NODE_ENV=development UNI_PLATFORM=mp-alipay vue-cli-service uni-build --watch",
    "dev:mp-baidu": "cross-env NODE_ENV=development UNI_PLATFORM=mp-baidu vue-cli-service uni-build --watch",
    "dev:mp-kuaishou": "cross-env NODE_ENV=development UNI_PLATFORM=mp-kuaishou vue-cli-service uni-build --watch",
    "dev:mp-qq": "cross-env NODE_ENV=development UNI_PLATFORM=mp-qq vue-cli-service uni-build --watch",
    "dev:mp-toutiao": "cross-env NODE_ENV=development UNI_PLATFORM=mp-toutiao vue-cli-service uni-build --watch",
    "dev:mp-weixin": "cross-env NODE_ENV=development UNI_PLATFORM=mp-weixin vue-cli-service uni-build --watch",
    "dev:quickapp-native": "cross-env NODE_ENV=development UNI_PLATFORM=quickapp-native vue-cli-service uni-build --watch",
    "dev:quickapp-webview": "cross-env NODE_ENV=development UNI_PLATFORM=quickapp-webview vue-cli-service uni-build --watch",
    "dev:quickapp-webview-huawei": "cross-env NODE_ENV=development UNI_PLATFORM=quickapp-webview-huawei vue-cli-service uni-build --watch",
    "dev:quickapp-webview-union": "cross-env NODE_ENV=development UNI_PLATFORM=quickapp-webview-union vue-cli-service uni-build --watch",
    "info": "node node_modules/@dcloudio/vue-cli-plugin-uni/commands/info.js",
    "serve:quickapp-native": "node node_modules/@dcloudio/uni-quickapp-native/bin/serve.js",
    "test:android": "cross-env UNI_PLATFORM=app-plus UNI_OS_NAME=android jest -i",
    "test:h5": "cross-env UNI_PLATFORM=h5 jest -i",
    "test:ios": "cross-env UNI_PLATFORM=app-plus UNI_OS_NAME=ios jest -i",
    "test:mp-baidu": "cross-env UNI_PLATFORM=mp-baidu jest -i",
    "test:mp-weixin": "cross-env UNI_PLATFORM=mp-weixin jest -i"
  },
  "dependencies": {
    "@dcloudio/uni-app-plus": "^2.0.0-32220210818002",
    "@dcloudio/uni-h5": "^2.0.0-32220210818002",
    "@dcloudio/uni-helper-json": "*",
    "@dcloudio/uni-i18n": "^2.0.0-32220210818002",
    "@dcloudio/uni-mp-360": "^2.0.0-32220210818002",
    "@dcloudio/uni-mp-alipay": "^2.0.0-32220210818002",
    "@dcloudio/uni-mp-baidu": "^2.0.0-32220210818002",
    "@dcloudio/uni-mp-kuaishou": "^2.0.0-32220210818002",
    "@dcloudio/uni-mp-qq": "^2.0.0-32220210818002",
    "@dcloudio/uni-mp-toutiao": "^2.0.0-32220210818002",
    "@dcloudio/uni-mp-vue": "^2.0.0-32220210818002",
    "@dcloudio/uni-mp-weixin": "^2.0.0-32220210818002",
    "@dcloudio/uni-quickapp-native": "^2.0.0-32220210818002",
    "@dcloudio/uni-quickapp-webview": "^2.0.0-32220210818002",
    "@dcloudio/uni-stat": "^2.0.0-32220210818002",
    "@vue/shared": "^3.0.0",
    "core-js": "^3.6.5",
    "flyio": "^0.6.2",
    "regenerator-runtime": "^0.12.1",
    "vue": "^2.6.11",
    "vue-class-component": "^6.3.2",
    "vue-property-decorator": "^8.0.0",
    "vuex": "^3.2.0"
  },
  "devDependencies": {
    "@babel/plugin-syntax-typescript": "^7.2.0",
    "@babel/runtime": "~7.12.0",
    "@dcloudio/types": "*",
    "@dcloudio/uni-automator": "^2.0.0-32220210818002",
    "@dcloudio/uni-cli-shared": "^2.0.0-32220210818002",
    "@dcloudio/uni-migration": "^2.0.0-32220210818002",
    "@dcloudio/uni-template-compiler": "^2.0.0-32220210818002",
    "@dcloudio/vue-cli-plugin-hbuilderx": "^2.0.0-32220210818002",
    "@dcloudio/vue-cli-plugin-uni": "^2.0.0-32220210818002",
    "@dcloudio/vue-cli-plugin-uni-optimize": "^2.0.0-32220210818002",
    "@dcloudio/webpack-uni-mp-loader": "^2.0.0-32220210818002",
    "@dcloudio/webpack-uni-pages-loader": "^2.0.0-32220210818002",
    "@typescript-eslint/eslint-plugin": "^4.30.0",
    "@typescript-eslint/parser": "^4.30.0",
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-typescript": "*",
    "@vue/cli-service": "~4.5.0",
    "babel-plugin-import": "^1.11.0",
    "cross-env": "^7.0.2",
    "eslint": "^7.32.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-prettier": "^4.0.0",
    "eslint-plugin-vue": "^7.17.0",
    "jest": "^25.4.0",
    "lint-staged": "^11.1.2",
    "mini-types": "*",
    "miniprogram-api-typings": "*",
    "postcss-comment": "^2.0.0",
    "prettier": "^2.3.2",
    "sass": "^1.38.2",
    "sass-loader": "10",
    "typescript": "^4.4.2",
    "vue-eslint-parser": "^7.10.0",
    "vue-template-compiler": "^2.6.11"
  },
  "browserslist": [
    "Android >= 4",
    "ios >= 8"
  ],
  "uni-app": {
    "scripts": {}
  },
  "gitHooks": {
    "pre-commit": "lint-staged"
  },
  "lint-staged": {
    "*.{js,vue,ts}": [
      "eslint --fix",
      "git add"
    ]
  }
}

遷移到TS

其實本來是想寫一些遇到的坑,然後發現之前遷移的過程中沒跟著寫這個文章,導致都忘了,現在光記著這是個比較枯燥的體力活。
對於js檔案,遷移還是相對比較簡單的,主要是把型別搞清楚,對於api呼叫,引數的型別uniapp都已經給搞好了,可以看看@dcloudio/types下定義的型別,型別搞不好的可以考慮Parameters<T>以及as,這個可以簡單看看src/modules/toast.ts,如果引數數量不定,可以嘗試一下泛型,對於這個可以簡單看看src/modules/datetime.ts。遷移的過程中還是要首先關注最底層的js檔案,例如A.js引用了B.js,那麼肯定是要先更改B.js,然後再去處理A.js,要注意的是現在的tsconfig.json配置是嚴格模式,所以也會要求引入的檔案為帶型別宣告的或者本身就是ts的,當然在d.ts中宣告一下declare module A.js也不是不行。遷移的話首先可以將字尾直接改成.ts,然後用eslint的自動修正功能,先修正一個地方是一個地方,然後自己去修改型別,儘量別寫any吧,雖然TypeScript又稱AnyScript,但是還是儘量搞清楚型別,尤其是抽出model層後,帶欄位提示去寫程式碼還是挺爽的,另外有一些關於型別的擴充以及全域性Mixin等可以參考sfc.d.tsmixins.ts

// src/modules/toast.ts
export const toast = (msg: string, time = 2000, icon = "none", mask = true): Promise<void> => {
    uni.showToast({
        title: msg,
        icon: icon as Parameters<typeof uni.showToast>[0]["icon"],
        mask: mask,
        duration: time,
    });
    return new Promise(resolve => setTimeout(() => resolve(), time));
};
// src/modules/datetime.ts
export function safeDate(): Date;
export function safeDate(date: Date): Date;
export function safeDate(timestamp: number): Date;
export function safeDate(dateTimeStr: string): Date;
export function safeDate(
    year: number,
    month: number,
    date?: number,
    hours?: number,
    minutes?: number,
    seconds?: number,
    ms?: number
): Date;
export function safeDate(
    p1?: Date | number | string,
    p2?: number,
    p3?: number,
    p4?: number,
    p5?: number,
    p6?: number,
    p7?: number
): Date | never {
    if (p1 === void 0) {
        // 無參構建
        return new Date();
    } else if (p1 instanceof Date || (typeof p1 === "number" && p2 === void 0)) {
        // 第一個引數為`Date`或者`Number`且無第二個引數
        return new Date(p1);
    } else if (typeof p1 === "number" && typeof p2 === "number") {
        // 第一和第二個引數都為`Number`
        return new Date(p1, p2, p3, p4, p5, p6, p7);
    } else if (typeof p1 === "string") {
        // 第一個引數為`String`
        return new Date(p1.replace(/-/g, "/"));
    }
    throw new Error("No suitable parameters");
}

Vue檔案中編寫TS就比較要命了,實際上有兩種編寫方式,一種是Vue.extend的方式,另一種就是裝飾器的方式,這裡就是主要參考的https://www.jianshu.com/p/39261c02c6db,我個人還是比較傾向於裝飾器的方式的,但是在小程式寫元件時使用裝飾器經常會出現一個prop型別不匹配的warning,不影響使用,另外無論是哪種方式都還是會有斷層的問題,這個算是Vue2當時的設計缺陷,畢竟那時候TS並不怎麼流行。

裝飾器

裝飾器 用途 描述
Component 宣告class元件 只要是個元件都必須加該裝飾器
Prop 宣告props 對應普通元件宣告中的props屬性
Watch 宣告監聽器 對應普通元件宣告中的watch屬性
Mixins 混入繼承 對應普通元件宣告中的mixins屬性
Emit 子元件向父元件值傳遞 對應普通this.$emit()
Inject 接收祖先元件傳遞的值 對應普通元件宣告中的inject屬性
Provide 祖先元件向其所有子孫後代注入一個依賴 對應普通元件宣告中的provide屬性

Vue生命週期

<script>
export default {
  beforeCreate() {},
  created() {},
  beforeMount() {},
  mounted() {},
  beforeUpdate() {},
  updated() {},
  activated() {},
  deactivated() {},
  beforeDestroy() {},
  destroyed() {},
  errorCaptured() {}
}
</script>

<!-- -------------------------------------------------- -->

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";

@Component
export default class App extends Vue {
  beforeCreate() {}
  created() {}
  beforeMount() {}
  mounted() {}
  beforeUpdate() {}
  updated() {}
  activated() {}
  deactivated() {}
  beforeDestroy() {}
  destroyed() {}
  errorCaptured() {}
}
</script>

Component

<script>
import HelloWorld from "./hello-world.vue";
export default {
  components: {
    HelloWorld
  }
}
</script>

<!-- -------------------------------------------------- -->

<script lang="ts">
import HelloWorld from "./hello-world.vue";
import { Component, Vue } from "vue-property-decorator";

// `Vue`例項的所有屬性都可以在`Component`編寫 例如`filters`
@Component({
  components: {
    HelloWorld
  }
})
export default class App extends Vue {}
</script>

Prop

<script>
export default {
  props: {
    msg: {
      type: String,
      default: "Hello world",
      required: true,
      validator: (val) => (val.length > 2)
    }
  }
}
</script>

<!-- -------------------------------------------------- -->

<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";

@Component
export default class HelloWorld extends Vue {
  @Prop({
    type: String,
    default: "Hello world",
    required: true,
    validator: (val) => (val.length > 2)
  }) msg!: string
}
</script>

Data

<script>
export default {
  data() {
    return {
      hobby: "1111111"
    };
  }
}
</script>

<!-- -------------------------------------------------- -->

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";

@Component
export default class HelloWorld extends Vue {
  hobby: string = "1111111"
}
</script>

Computed

<script>
export default {
  data() {
    return {
      hobby: "1111111"
    };
  },
  computed: {
    msg() {
      return this.hobby;
    }
  },
  mounted() {
    console.log(this.msg); // 1111111
  }
}
</script>

<!-- -------------------------------------------------- -->

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";

@Component
export default class HelloWorld extends Vue {
  hobby: string = "1111111"
  get msg() {
    return this.hobby;
  }
  mounted() {
    console.log(this.msg); // 1111111
  }
}
</script>

Watch

<script>
export default {
  data() {
    return {
      value: ""
    };
  },
  watch: {
    value: {
      handler() {
        console.log(this.value);
      },
      deep: true,
      immediate: true
    }
  }
}
</script>

<!-- -------------------------------------------------- -->

<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";

@Component
export default class App extends Vue {
  value: string = "
  @Watch("value", { deep: true, immediate: true })
  valueWatch() {
    console.log(this.value);
  }
}
</script>

Mixins

<script>
// info.js
export default {
  methods: {
    mixinsShow() {
      console.log("111");
    }
  }
}

// hello-world.vue
import mixinsInfo from "./info.js";
export default {
  mixins: [mixinsInfo],
  mounted() {
    this.mixinsShow(); // 111
  }
}
</script>

<!-- -------------------------------------------------- -->

<script lang="ts">
// info.ts
import { Component, Vue } from "vue-property-decorator";

@Component
export default class MixinsInfo extends Vue {
    mixinsShow() {
      console.log("111");
    }
}

// hello-world.vue
import { Component, Vue, Mixins } from "vue-property-decorator";
import mixinsInfo from "./info.ts";

@Component
export default class HelloWorld extends Mixins(mixinsInfo) {
  mounted() {
    this.mixinsShow(); // 111
  }
}
</script>

Emit

<!-- children.vue -->
<template>
  <button @click="$emit("submit", "1")">提交</button>
</template>

<!-- parent.vue  -->
<template>
  <children @submit="submitHandle"/>
</template>

<script lang="ts">
import children from "./children.vue";
export default {
  components: {
    children
  },
  methods: {
    submitHandle(msg) {
      console.log(msg); // 1
    }
  }
}
</script>

<!-- -------------------------------------------------- -->

<!-- children.vue -->
<template>
  <button @click="submit">提交</button>
</template>

<script lang="ts">
import { Component, Vue, Emit } from "vue-property-decorator";

@Component
export default class Children extends Vue {
  @Emit()
  submit() {
    return "1"; // 當然不使用裝飾器`@Emit`而使用`this.$emit`也是可以的
  }
}
</script>

<!-- parent.vue  -->
<template>
  <children @submit="submitHandle"/>
</template>

<script lang="ts">
import children from "./children.vue";
import { Component, Vue } from "vue-property-decorator";

@Component({
  components: {
    children
  }
})
export default class Parent extends Vue {
  submitHandle(msg: string) {
    console.log(msg); // 1
  }
}
</script>

Provide/Inject

<!-- children.vue -->
<script>
export default {
  inject: ["root"],
  mounted() {
    console.log(this.root.name); // aaa
  }
}
</script>

<!-- parent.vue  -->
<template>
  <children />
</template>
<script>
import children from "./children.vue";
export default {
  components: {
    children
  },
  data() {
    return {
      name: "aaa"
    };
  },
  provide() {
    return {
      root: this
    };
  }
}
</script>


<!-- -------------------------------------------------- -->

<!-- children.vue -->
<script lang="ts">
import { Component, Vue, Inject } from "vue-property-decorator";

@Component
export default class Children extends Vue {
  @Inject() root!: any
  mounted() {
    console.log(this.root.name); // aaa
  }
}
</script>

<!-- parent.vue  -->

<template>
  <children />
</template>

<script lang="ts">
import children from "./children.vue";
import { Component, Vue, Provide } from "vue-property-decorator";
@Component({
  components: {
    children
  }
})
export default class Parent extends Vue {
  name: string = "aaa"
  @Provide()
  root = this.getParent()
  getParent() {
    return this;
  }
}
</script>

Vuex

// store/store.ts
import Vue from "vue";
import Vuex, { StoreOptions } from "vuex";
import user from "./modules/user";

Vue.use(Vuex);
interface RootState {
  version: string;
}
const store: StoreOptions<RootState> = {
  strict: true,
  state: {
    version: "1.0.0"
  },
  modules: {
    user
  }
};

export default new Vuex.Store<RootState>(store);



// store/modules/user.ts
import { Module } from "vuex";
export interface UserInfo {
  uId: string;
  name: string;
  age: number;
}

interface UserState {
  userInfo: UserInfo;
}

const user: Module<UserState, any> = {
  namespaced: true,
  state: {
    userInfo: {
      uId: ",
      name: ",
      age: 0
    }
  },
  getters: {
    isLogin(state) {
      return !!state.userInfo.uId;
    }
  },
  mutations: {
    updateUserInfo(state, userInfo: UserInfo): void {
      Object.assign(state.userInfo, userInfo);
    }
  },
  actions: {
    async getUserInfo({ commit }): Promise<void> {
      let { userInfo } = await getUserInfo();
      commit("updateUserInfo", userInfo);
    }
  }
};

export default user;

Vuex-method

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { State, Getter, Action } from "vuex-class";
import { UserInfo } from "./store/modules/user";

@Component
export default class App extends Vue {
  @State("version") version!: string
  @State("userInfo", { namespace: "user" }) userInfo!: UserInfo
  @Getter("isLogin", { namespace: "user" }) isLogin!: boolean
  @Action("getUserInfo", { namespace: "user" }) getUserInfo!: Function
  mounted() {
    this.getUserInfo();
    console.log(this.version); // 1.0.0
  }
}

</script>

釋出NPM元件

uniapp中編寫釋出到NPM元件就比較要命了,我想將一些東西抽出來單獨作為NPM元件使用,這樣就可以多專案共用了,但是這裡邊坑是巨多,在這裡主要是記錄一下踩過的坑,真的是讓人頭禿。因為主要是在小程式端使用,跟web端不一樣,必須編譯成小程式能夠識別的檔案,但是dcloud目前並未提供這樣的能力,所以只能編寫最原始的vue元件。並且由於是uniapp做了很多外掛的解析行為,有些東西甚至是直接固定寫在程式碼裡的,無法從外部改動,還有些出現錯誤的地方並沒有將異常丟擲而是直接吃掉,導致最後編譯出來的檔案為空但是控制檯卻沒有什麼提示,反正是踩了不少坑,這裡主要是有三種方式去完成NPM元件釋出,在這裡全部是使用https://github.com/WindrunnerMax/Campus作為示例的。

僅釋出元件

首先是最簡單的方式,類似於https://github.com/WindrunnerMax/Campus/tree/master/src/components,元件全部都是在components目錄下完成的,那麼我們可以直接在此處建立一個package.json檔案,然後在此處將資原始檔釋出即可,這樣就很簡單了,在使用的時候直接引用即可,另外可以設定一下別名去引用,嘗試過在VSCode裡按@會有程式碼提示,所以可以加個@處理別名。

$ yarn add shst-campus-components

配置vue.config.jstsconfig.json

// vue.config.js
const path = require("path");
module.exports = {
  transpileDependencies: ["shst-campus-components"],
    configureWebpack: {
        resolve: {
            alias: {
                "@": path.join(__dirname, "./src"),
                "@campus": path.join(__dirname, "./node_modules/shst-campus-components"),
            },
        },
    },
};
// tsconfig.json
{
  "compilerOptions": {
    // ...
    "paths": {
      "@/*": [
        "./src/*"
      ],
      "@campus/*": [
        "./node_modules/shst-campus-components/*"
      ]
    },
    // ...
}

使用元件庫,具體請參考https://github.com/WindrunnerMax/Campus

// ...
import CCard from "@campus/c-card/c-card.vue";
// ...

編寫webpack的loader和plugin

第二個方式就比較難頂了,當然現在我也是放棄了這個想法,不過還是記錄一下,畢竟折騰了一天多實際上是做到了能夠實現一個正常使用的方式了,但並不是很通用,主要是寫的loader的正則匹配的場景覆蓋不全,所以最終還是沒有采用這個方式,本身一個並不麻煩的問題最後演變到了需要寫一個loader去解決,是真的要命。首先我是想實現一個類似於import { CCard } from "shst-campus"這種引用方式的,看起來很眼熟,其實就是想參照antd或者同樣也是element-ui的引入方式,所以實際上還是研究了一下他們的引入方式的,實際上是完成了babel外掛,然後通過這個外掛在引入的時候就編譯成其他引入的語句,實際上前邊舉的例子預設類似於import CCard from "shst-campus/lib/c-card",當然這個是可以配置的,使用babel-plugin-importbabel-plugin-component實現類似於按需載入的方式,首先我嘗試了babel-plugin-import並且配置了相關的路徑。

// babel.config.js
const plugins = [];
// ...
plugins.push([
    "import",
    {
        libraryName: "shst-campus",
        customName: name => {
            return `shst-campus/src/components/${name}/index`;
        },
    },
    "shst-campus-import",
]);
// ...
module.exports = {
    // ...
    plugins,
};

想法是很美好的,我嘗試進行編譯,發現這個配置沒有任何動靜,也就是沒有生效,我雖然很奇怪,但是想到這個是原本uniapp就自帶的外掛,所以可能配置會被吃掉或者被覆蓋掉,所以我嘗試了使用babel-plugin-component

// babel.config.js
const plugins = [];
// ...
plugins.push([
    "component",
    {
        libraryName: "shst-campus",
        libDir: "src/components",
        style: false,
    },
    "shst-campus-import",
]);
// ...
module.exports = {
    // ...
    plugins,
};

這次產生了實際效果,確實能做到按需引入了,我高興地進行編譯,編譯通過了,然後開啟微信開發者工具,發現報錯了,然後發現那邊json檔案出現了一個錯誤,引入的元件未找到,在json檔案裡將引入的檔案原封不動得放了進去,也就是shst-campus/index,這明顯不是個元件,而且實際上大概率是因為使用的外掛和原本的外掛解析時間沒有對上,uniapp的外掛在前解析已經完成了,所以就很尷尬,我想著通過編寫一個webpack外掛去解決這個json的問題。

export class UniappLoadDemandWebpackPlugin {
    constructor(options) {
        this.options = options || {};
    }
    apply(compiler) {
        compiler.hooks.emit.tapAsync("UniappLoadDemandWebpackPlugin", (compilation, done) => {
            Object.keys(compilation.assets).forEach(key => {
                if (/^\./.test(key)) return void 0;
                if (!/.*\.json$/.test(key)) return void 0;
                const root = "node-modules";
                const asset = compilation.assets[key];
                const target = JSON.parse(asset.source());
                if (!target.usingComponents) return void 0;
                Object.keys(target.usingComponents).forEach(componentsKey => {
                    const item = target.usingComponents[componentsKey];
                    if (item.indexOf("/" + root + "/" + this.options.libraryName) === 0) {
                        target.usingComponents[
                            componentsKey
                        ] = `/${root}/${this.options.libraryName}/${this.options.libDir}/${componentsKey}/index`;
                    }
                });
                compilation.assets[key] = {
                    source() {
                        return JSON.stringify(target);
                    },
                    size() {
                        return this.source().length;
                    },
                };
            });
            done();
        });
    }
}

/*
// vue.config.js
module.exports = {
    configureWebpack: {
        // ...
        plugins: [
            // ...
            new UniappLoadDemandWebpackPlugin({
                libraryName: "shst-campus",
                libDir: "src/components",
            }),
            // ...
        ],
        // ...
    },
};
*/

通過這個外掛,我確實成功解決了json檔案的元件引入問題,然後啟動微信開發者工具,然後發現元件成功載入了,但是邏輯與樣式全部丟失了,在我奇怪的時候我去檢視了元件的編譯情況,發現了元件根本沒有編譯成功,jscss都編譯失敗了,這就尷尬了,實際上在編譯過程中uniapp的外掛並沒有丟擲任何異常,相關的情況都被他內部吃掉了,然後我依舊是想通過編寫webpack外掛的形式去解決這個問題,嘗試在compilercompilation鉤子中處理都沒有解決這個問題,之後在NormalModuleFactory這個Hook中列印了一下發現,通過babel-plugin-component的處理,在這裡的source已經被指定為想要的路徑了,但是在uniapp編譯的時候還是有問題的,然後我就在想uniapp處理這個相關的東西到底是有多早,之後嘗試JavascriptParser鉤子也沒有成功處理,好傢伙估計是在babel解析的時候就已經完成了,實際上他確實也有一個外掛@dcloudio/webpack-uni-mp-loader/lib/babel/util.js,這裡邊後邊還有坑。之後我又回到了babel-plugin-import這個外掛,因為這個外掛是uniapp的依賴中攜帶的處理外掛,所以理論上在裡邊是用過這個外掛的,之後我注意到他在babel.config.js裡有一個處理@dcloudio/uni-ui的語句。

process.UNI_LIBRARIES = process.UNI_LIBRARIES || ["@dcloudio/uni-ui"];
process.UNI_LIBRARIES.forEach(libraryName => {
    plugins.push([
        "import",
        {
            libraryName: libraryName,
            customName: name => {
                return `${libraryName}/lib/${name}/${name}`;
            },
        },
    ]);
});

那麼我就想著我也寫一個類似的,具體過程只是描述一下,首先之前我也是寫了一個類似的宣告,但是並沒有生效,我嘗試把自己的元件寫到process.UNI_LIBRARIES然後發現竟然生效了,這讓我很吃驚,想了想肯定是在process.UNI_LIBRARIES做了一些處理,然後我把這個稍微修改了一下,也就是在process.UNI_LIBRARIES中處理了以後也有babel-plugin-import外掛處理,之後我啟動了編譯,發現依舊是那個問題,在那裡邊的檔案無法成功編譯,內容是空的,而且錯誤資訊都被吃掉了,沒有任何報錯出來,好傢伙要了命,而且他也影響到了@dcloudio/uni-ui的元件引用,這是我隨便引用了一個元件發現的,這裡邊的元件也會變成空的,無法成功解析,並且在json檔案中,對於我的檔案的宣告是src/components下的,他給我宣告成了lib下的檔案,然後我去看了看他的有一個babel的外掛,裡邊有引用@dcloudio/webpack-uni-mp-loader/lib/babel/util.js,這裡邊的process.UNI_LIBRARIESsource處理是寫死的,真的是要了親命,所以要想處理好這個問題,必須提前處理vue檔案的引用宣告,因為直接寫明src/components下的引用是沒有問題的,而想在uniapp之前處理好這個問題,那麼只能編寫一個loader去處理了,我自行實現了一個正則去匹配import語句然後將import解析出來去處理完整的path。之後考慮到引用的複雜性,還是考慮去引用一個相對比較通用的解析庫區實現import語句的解析而不只是通過正規表示式的匹配區完成這件事,然後使用parse-imports去完成這個loader

const transform = str => str.replace(/\B([A-Z])/g, "-$1").toLowerCase();
module.exports = function (source) {
    const name = this.query.name;
    if (!name) return source;
    const path = this.query.path || "lib";
    const main = this.query.main;
    return source.replace(
        // maybe use parse-imports to parse import statement
        new RegExp(
            `import[\\s]*?\\{[\\s]*?([\\s\\S]*?)[\\s]*?\\}[\\s]*?from[\\s]*?[""]${name}[""];?`,
            "g"
        ),
        function (_, $1) {
            let target = "";
            $1.split(",").forEach(item => {
                const transformedComponentName = transform(item.split("as")[0].trim());
                const single = `import { ${item} } from "${name}/${path}/${transformedComponentName}/${
                    main || transformedComponentName
                }";`;
                target = target + single;
            });
            return target;
        }
    );
};

/*
// vue.config.js
module.exports = {
    transpileDependencies: ["shst-campus"],
    configureWebpack: {
        resolve: {
            alias: {
                "@": path.join(__dirname, "./src"),
            },
        },
        module: {
            rules: [
                {
                    test: /\.vue$/,
                    loader: "shst-campus/build/components-loader",
                    options: {
                        name: "shst-campus",
                        path: "src/components",
                        main: "index",
                    },
                },
            ],
        },
        plugins: [],
    },
};
*/

構建新目錄併發布

最後就是準備採用的方案了,這個方案就是純粹和@dcloudio/uni-ui的使用方案是相同的了,因為既然uniapp是寫死的,那麼我們就適應一下這個方式吧,就不再去做loader或者plugin去特殊處理外掛了,將其作為一個規範就好,踩坑踩太多了,頂不住了,實際上我覺得使用loader去解決這個問題也還可以,但是畢竟實際上改動太大並且需要通用地適配,還是採用一個相對通用的方式吧,直接看他的npm包可以看到其元件的結構為/lib/component/component,那我們就可以寫個指令碼去處理並且可以構建完成後自動釋出。現在就是在dist/package下生成了index.js作為引入的main,還有index.d.ts作為宣告檔案,還有README.mdpackage.json.npmrc檔案,以及符合上述目錄結構的元件,主要都是一些檔案操作,以及在package.json寫好構建和釋出的命令。可以對比https://npm.runkit.com/shst-campushttps://github.com/WindrunnerMax/Campus的檔案差異,或者直接在https://github.com/WindrunnerMax/Campus執行一下npm run build:package即可在dist/package看到要釋出的npm包。

// utils.js
const { promisify } = require("util");
const fs = require("fs");
const path = require("path");
const exec = require("child_process").exec;

module.exports.copyFolder = async (from, to) => {
    if (fs.existsSync(from)) {
        if (!fs.existsSync(to)) fs.mkdirSync(to, { recursive: true });
        const files = fs.readdirSync(from, { withFileTypes: true });
        for (let i = 0; i < files.length; i++) {
            const item = files[i];
            const fromItem = path.join(from, item.name);
            const toItem = path.join(to, item.name);
            if (item.isFile()) {
                const readStream = fs.createReadStream(fromItem);
                const writeStream = fs.createWriteStream(toItem);
                readStream.pipe(writeStream);
            } else {
                fs.accessSync(path.join(toItem, ".."), fs.constants.W_OK);
                module.exports.copyFolder(fromItem, toItem);
            }
        }
    }
};

module.exports.execCMD = (cmdStr, cmdPath) => {
    const workerProcess = exec(cmdStr, { cwd: cmdPath });
    // 列印正常的後臺可執行程式輸出
    workerProcess.stdout.on("data", data => {
        process.stdout.write(data);
    });
    // 列印錯誤的後臺可執行程式輸出
    workerProcess.stderr.on("data", data => {
        process.stdout.write(data);
    });
    // 退出之後的輸出
    // workerProcess.on("close", code => {});
};

module.exports.fileExist = async location => {
    try {
        await promisify(fs.access)(location, fs.constants.F_OK);
        return true;
    } catch {
        return false;
    }
};

module.exports.writeFile = (location, content, flag = "w+") => {
    return promisify(fs.writeFile)(location, content, { flag });
};

module.exports.readDir = dir => {
    return promisify(fs.readdir)(dir);
};

module.exports.fsStat = fullPath => {
    return promisify(fs.stat)(fullPath);
};

module.exports.copyFile = (from, to) => {
    // const readStream = fs.createReadStream(from);
    // const writeStream = fs.createWriteStream(to);
    // readStream.pipe(writeStream);
    return promisify(fs.copyFile)(from, to);
};
// index.js
const path = require("path");
const { copyFolder, readDir, fsStat, writeFile, copyFile, fileExist } = require("./utils");

const root = process.cwd();
const source = root + "/src/components";
const target = root + "/dist/package";

const toClassName = str => {
    const tmpStr = str.replace(/-(\w)/g, (_, $1) => $1.toUpperCase()).slice(1);
    return str[0].toUpperCase() + tmpStr;
};

const start = async dir => {
    const components = [];
    console.log("building");

    console.log("copy components");
    const items = await readDir(dir);
    for (const item of items) {
        const fullPath = path.join(dir, item);
        const stats = await fsStat(fullPath);
        if (stats.isDirectory()) {
            if (/^c-/.test(item)) {
                components.push({ fileName: item, componentName: toClassName(item) });
            }
            copyFolder(fullPath, path.join(target, "/lib/", item));
        }
    }

    console.log("processing index.js");
    let indexContent = "";
    components.forEach(item => {
        indexContent += `import ${item.componentName} from "./lib/${item.fileName}/${item.fileName}.vue";\n`;
    });
    const exportItems = components.map(v => v.componentName).join(", ");
    indexContent += `export { ${exportItems} };\n`;
    indexContent += `export default { ${exportItems} };\n`;
    await writeFile(path.join(target, "/index.js"), indexContent);

    console.log("processing index.d.ts");
    let dtsContent = `import { Component } from "vue";\n\n`;
    components.forEach(item => {
        dtsContent += `declare const ${item.componentName}: Component;\n`;
    });
    await writeFile(path.join(target, "/index.d.ts"), dtsContent);

    console.log("processing .npmrc");
    const exist = await fileExist(path.join(target, "/.npmrc"));
    if (!exist) {
        const info = "registry=https://registry.npmjs.org/";
        await writeFile(path.join(target, "/.npmrc"), info);
    }

    console.log("processing README.md");
    await copyFile(path.join(root, "/README.md"), target + "/README.md");

    console.log("processing package.json");
    const originPackageJSON = require(path.join(root, "/package.json"));
    const targetJson = {
        ...originPackageJSON,
        repository: {
            type: "git",
            url: "https://github.com/WindrunnerMax/Campus",
        },
        scripts: {},
        author: "Czy",
        license: "MIT",
        dependencies: {
            "vue": "^2.6.11",
            "vue-class-component": "^6.3.2",
            "vue-property-decorator": "^8.0.0",
        },
        devDependencies: {},
    };
    await writeFile(path.join(target, "/package.json"), JSON.stringify(targetJson, null, "\t"));
};

start(source);

本來我想著用這種方案就可以了,之後又遇到了天坑環節,這次的坑是,使用按需引入的方式,即類似於import { CCard } from "shst-campus";這種形式,如果在本地src中寫頁面使用的是裝飾器的寫法的話,是不能正常編譯node_modules裡的元件的,無論node_modules裡的元件是TS還是普通vue元件都會出現這樣的情況,這個問題在上邊寫的部落格裡寫了這就是個大坑,即編譯出來的產物是沒有css檔案以及js檔案只有一個Component({}),如果使用的是Vue.extend的寫法的話,又是能夠正常編譯node_modules裡的元件,當然本地src編寫的元件如果沒有使用TS的話是沒有問題的,所以現在是有三種解決方案,其實終極大招是寫一個webpack loader,這個我在部落格中實現過,考慮到通用性才最終沒使用,要是實在頂不住了就完善一下直接上loader,至於為什麼要寫loader而不只是寫一個plugin也可以看看部落格,天坑。

  • src中元件使用裝飾器寫法,引入元件使用真實路徑,即類似於import CCard from "shst-campus/lib/c-card/c-card.vue";
  • src中元件使用Vue.extend寫法,可以使用按需引入,即類似於import { CCard } from "shst-campus";
  • src中元件使用這兩種寫法都可以,然後配置一下uniapp提供的easycom能力,之後可以直接使用元件不需要宣告。

如果需要配置元件的按需引入,即類似於import { CCard } from "shst-campus";這種形式,需要修改babel.config.js檔案。

// babel.config.js
// ...
process.UNI_LIBRARIES = process.UNI_LIBRARIES || ["@dcloudio/uni-ui"];
process.UNI_LIBRARIES.push("shst-campus");
process.UNI_LIBRARIES.forEach(libraryName => {
    plugins.push([
        "import",
        {
            libraryName: libraryName,
            customName: name => {
                return `${libraryName}/lib/${name}/${name}`;
            },
        },
        libraryName,
    ]);
});
// ...

如果需要使用easycom的引入形式,那麼需要配置pages.json

// pages.json
{
    "easycom": {
       "autoscan": true,
       "custom": {
            "^c-(.*)": "shst-campus/lib/c-$1/c-$1.vue"
       }
    },
    // ...
}

這是終極大招解決方案,在後來我抽時間使用parse-imports庫完成了一個新的loader,相容性應該還可以,另外這個庫也挺坑的,是個module而沒有打包成commonjs,這就導致最後我作為loader使用必須把所有的依賴都打到了一個js裡,挺要命的,我準備使用這種方式去解決uniapp元件的坑了,也驗證一下庫的相容性,如果使用按需載入的方式上邊都可以忽略,只需要安裝好依賴並且在vue.config.js中配置好就可以了。

$ yarn add -D uniapp-import-loader
// vue.config.js
const path = require("path");

module.exports = {
    configureWebpack: {
        // ...
        module: {
            rules: [
                {
                    test: /\.vue$/,
                    loader: "uniapp-import-loader", 
                    // import { CCard } from "shst-campus"; 
                    // => import CCard from "shst-campus/lib/c-card/c-card";
                    options: {
                        name: "shst-campus",
                        path: "lib",
                    },
                },
            ],
        },
        // ..
    },
};
import parseImports from "parse-imports";

const transformName = (str: string): string => str.replace(/\B([A-Z])/g, "-$1").toLowerCase();
const buildImportStatement = (itemModules: string, itemFrom: string): string =>
    `import ${itemModules} from "${itemFrom}";\n`;

export const transform = (
    source: string,
    options: { name: string; path: string; main?: string }
): Promise<string> => {
    const segmentStartResult = /<script[\s\S]*?>/.exec(source);
    const scriptEndResult = /<\/script>/.exec(source);
    if (!segmentStartResult || !scriptEndResult) return Promise.resolve(source);
    const startIndex = segmentStartResult.index + segmentStartResult[0].length;
    const endIndex = scriptEndResult.index;
    const preSegment = source.slice(0, startIndex);
    const middleSegment = source.slice(startIndex, endIndex);
    const endSegment = source.slice(endIndex, source.length);
    return parseImports(middleSegment)
        .then(allImports => {
            let segmentStart = 0;
            let segmentEnd = 0;
            const target: Array<string> = [];
            for (const item of allImports) {
                if (item.isDynamicImport) continue;
                if (!item.moduleSpecifier.value || item.moduleSpecifier.value !== options.name) {
                    continue;
                }
                segmentEnd = item.startIndex;
                target.push(middleSegment.slice(segmentStart, segmentEnd));
                if (item.importClause && item.moduleSpecifier.value) {
                    const parsedImports: Array<string> = [];
                    if (item.importClause.default) {
                        parsedImports.push(
                            buildImportStatement(
                                item.importClause.default,
                                item.moduleSpecifier.value
                            )
                        );
                    }
                    item.importClause.named.forEach(v => {
                        parsedImports.push(
                            buildImportStatement(
                                v.binding, // as 會被捨棄 `${v.specifier} as ${v.binding}`,
                                `${options.name}/${options.path}/${transformName(v.specifier)}/${
                                    options.main || transformName(v.specifier)
                                }`
                            )
                        );
                    });
                    target.push(parsedImports.join(""));
                }
                segmentStart = item.endIndex;
            }
            target.push(middleSegment.slice(segmentStart, middleSegment.length));
            return preSegment + target.join("") + endSegment;
        })
        .catch((err: Error) => {
            console.error("uniapp-import-loader parse error", err);
            return source;
        });
};
const { transform } = require("../dist/index");

// loader function
module.exports = function (source) {
    const name = this.query.name;
    if (!name) return source;
    const path = this.query.path || "lib";
    const main = this.query.main;
    const done = this.async();
    transform(source, { name, path, main }).then(res => {
        done(null, res);
    });
};

BLOG

https://github.com/WindrunnerMax/EveryDay

參考

https://tslang.baiqian.ltd/
https://cn.eslint.org/docs/rules/
https://www.jianshu.com/p/39261c02c6db
https://www.zhihu.com/question/310485097
https://juejin.cn/post/6844904144881319949
https://uniapp.dcloud.net.cn/quickstart-cli
https://webpack.docschina.org/api/parser/#import
https://v4.webpack.docschina.org/concepts/plugins/
https://cloud.tencent.com/developer/article/1839658
https://jkchao.github.io/typescript-book-chinese/typings/migrating.html

相關文章