uniapp小程序遷移到TS
我一直在做的小程序就是 山科小站 也已經做了兩年了,目前是用uniapp構建的,在這期間也重構好幾次了,這次在鵝廠實習感覺受益良多,這又得來一次很大的重構,雖然小程序功能都是比較簡單的功能,但是這好不容易實習學到的東西得學以致用,那就繼續在小程序上動手吧哈哈。這次實習收獲最大倒不是怎么遷移到TS,而是一些組件設計的概念以及目錄結構設計上的東西,不過這都是在之后重寫組件的時候要做的東西了。回到正題,小程序是用uniapp寫的,畢竟還是比較熟悉Vue語法的,這次遷移首先是要將小程序從HBuilderX遷移到cli版本,雖然用HBuilderX確實是有一定的優點,但是拓展性比較差,這些東西還是得自己折騰折騰,遷移到cli版本完成后,接下來就是要慢慢從js過渡到ts了,雖然是Vue2對ts支持相對比較差,但是至少對於抽離出來的邏輯是可以寫成ts的,可以在編譯期就避免很多錯誤,另外自己使用cli創建可以搞一些其他功能,畢竟只要不操作DOM的話一般還是在用常用的js方法,例如可以嘗試接入Jest單元測試等。
遷移到cli版本
首先要遷移到cli版本,雖然 官網 上說明了如何新建一個cli版本的uniapp,但是其中還是有很多坑的。
首先在安裝依賴的時候npm和yarn是沒有問題的,但是用pnpm安裝依賴的會出現無法編譯的情況,多般測試也沒有結果,像是內部有一個異常,然后被uniapp編寫的webpack插件給捕捉了,並且沒有向外拋出異常信息,這就很難受,本來一直是用pnpm去管理包,現在只能是使用yarn去管理整個項目了,另外我想使用軟連接mklink -J做一個中心包存儲也失敗了,插件生成的dist文件夾的位置很奇怪,導致打包的時候尋找文件夾路徑失敗,也最終導致編譯失敗,所以想用uniapp的cli的話,還是只能按部就班地來,不能搞些騷操作。
首先安裝全局安裝vue-cli:
$ npm install -g @vue/cli
創建項目project:
$ npm install -g @vue/cli
之后就要選擇版本了,要選擇TypeScript的默認模板,這樣就不需要自己去配置例如tsconfig.json這種的了。在之后就需要將之前的代碼移動到新的目錄的src目錄下,當然諸如.editorconfig這些配置文件還是要遷移出來放置在根目錄下的,如果沒有配置一些插件例如sass的話,現在小程序可能能夠運行了,如果還安裝了其他插件,那就特別是要注意依賴問題,因為uniapp寫的這些插件有的是挺老的依賴,所以需要安裝老版本插件去兼容。
安裝插件
上邊說到了直接yarn install -D xxx可能會出現問題,比如我就遇到了sass和webpack版本不兼容問題,另外eslint和prettier這些規范代碼的插件也是需要安裝的,另外還有eslint的ts 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.ts和mixins.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");
}
// or
type DateParams =
| []
| [string]
| [number, number?, number?, number?, number?, number?, number?]
| [Date];
const safeDate = <T extends DateParams>(...args: T): Date => {
const copyParams = args.slice(0);
if (typeof copyParams[0] === "string") copyParams[0] = copyParams[0].replace(/-/g, "/");
return new Date(...(args as ConstructorParameters<typeof Date>));
};
在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.js與tsconfig.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-import和babel-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文件的組件引入問題,然后啟動微信開發者工具,然后發現組件成功加載了,但是邏輯與樣式全部丟失了,在我奇怪的時候我去查看了組件的編譯情況,發現了組件根本沒有編譯成功,js與css都編譯失敗了,這就尷尬了,實際上在編譯過程中uniapp的插件並沒有拋出任何異常,相關的情況都被他內部吃掉了,然后我依舊是想通過編寫webpack插件的形式去解決這個問題,嘗試在compiler、compilation鈎子中處理都沒有解決這個問題,之后在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_LIBRARIES的source處理是寫死的,真的是要了親命,所以要想處理好這個問題,必須提前處理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: [],
},
};
*/
近來事情不多所以重寫了之前提到的loader,如果使用按需加載的方式上邊都可以忽略,只需要安裝好依賴並且在vue.config.js中配置好就可以了,詳細配置可以查看https://github.com/SHST-SDUST/SHST-PLUS/blob/master/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",
},
},
],
},
// ..
},
};
近日又研究了一下相關的代碼以及uniapp框架babel的處理方案,實現了按需引用babel-plugin的解決方案,與webpack-loader解決方案二選一,需要配置babel.config.js,詳細配置可以查看https://github.com/SHST-SDUST/SHST-PLUS/blob/master/babel.config.js。
$ yarn add -D uniapp-import-loader
// ...
process.UNI_LIBRARIES = ["shst-campus"];
plugins.push([
require("uniapp-import-loader/dist/babel-plugin-dynamic-import"),
{
libraryName: "shst-campus",
libraryPath: "lib",
},
// import { CCard } from "shst-campus";
// => import CCard from "shst-campus/lib/c-card/c-card";
]);
// ...
構建新目錄並發布
最后就是准備采用的方案了,這個方案就是純粹和@dcloudio/uni-ui的使用方案是相同的了,因為既然uniapp是寫死的,那么我們就適應一下這個方式吧,就不再去做loader或者plugin去特殊處理插件了,將其作為一個規范就好,踩坑踩太多了,頂不住了,實際上我覺得使用loader去解決這個問題也還可以,但是畢竟實際上改動太大並且需要通用地適配,還是采用一個相對通用的方式吧,直接看他的npm包可以看到其組件的結構為/lib/component/component,那我們就可以寫個腳本去處理並且可以構建完成后自動發布。現在就是在dist/package下生成了index.js作為引入的main,還有index.d.ts作為聲明文件,還有README.md、package.json、.npmrc文件,以及符合上述目錄結構的組件,主要都是一些文件操作,以及在package.json寫好構建和發布的命令。可以對比https://npm.runkit.com/shst-campus和https://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://ts.xcatliu.com/basics/declaration-files.html
https://jkchao.github.io/typescript-book-chinese/typings/migrating.html
