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");
}
在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: [],
},
};
*/
構建新目錄併發布
最後就是準備採用的方案了,這個方案就是純粹和@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://jkchao.github.io/typescript-book-chinese/typings/migrating.html