I've been working on the mini-program Shanke Station for two years, and it's been built using uniapp
. During this period, I've refactored it several times. During my internship at a certain company, I gained a lot, but now I need to do a major refactoring again. Although the functionalities of the mini-program are relatively simple, I need to apply what I've learned during the internship. So, let's continue working on the mini-program. The most significant gain from the internship was not how to migrate to TS
, but rather the concepts of component design and directory structure design. However, these will be things to address when rewriting the components later on.
Back to the main topic, the mini-program is written using uniapp
, and since I'm quite familiar with Vue
syntax, the first step in the migration is to move the mini-program from HBuilderX
to the cli
version. Although HBuilderX
does have certain advantages, its extensibility is relatively poor. I need to tinker with it myself, so after completing the migration to the cli
version, the next step is to gradually transition from js
to ts
. Although Vue2
has relatively poor support for ts
, at least the logic that's been abstracted can be written in ts
, thus avoiding many errors during the compilation. Additionally, creating one's own functionality using cli
may be possible, provided that there's no manipulation of the DOM
. Generally, common js
methods are still used. For example, trying to integrate Jest
unit testing, among other capabilities.
The first step is to migrate to the cli
version. Although the official website explains how to create a uniapp
cli
version, there are still many pitfalls.
Initially, there's no issue with installing dependencies using npm
and yarn
, but using pnpm
may lead to situations where compilation fails. After various tests, no results were found, almost as if there's an internal exception, which was caught by the webpack
plugin written by uniapp
, and no exception information was thrown outward. It's quite frustrating. I've been using pnpm
to manage packages all along, but now I have to use yarn
to manage the entire project. Additionally, my attempt to use a symbolic link, mklink -J
, to create a central package storage failed. The dist
folder generated by the plugin is located in a very strange place, causing the build process to fail as it couldn't find the folder path, ultimately leading to a compilation failure. So, if you want to use uniapp
's cli
, you can only follow the standard procedures and not engage in fancy operations.
First, install vue-cli
globally:
$ npm install -g @vue/cli
Create the project project
:
$ npm install -g @vue/cli
Next, choose the version and select the default template for TypeScript
, so that you don't need to configure things like tsconfig.json
. Afterwards, the existing code needs to be moved to the src
directory of the new project. Of course, configuration files such as .editorconfig
still need to be moved out and placed in the root directory. If some plugins, such as sass
, are not configured, the mini-program may be able to run now. If you've installed other plugins, pay close attention to the dependency issues, because some of these plugins written in uniapp
may have quite outdated dependencies, requiring installation of older version plugins for compatibility.
As mentioned earlier, directly yarn install -D xxx
may lead to problems. For example, I encountered issues with sass
and webpack
version incompatibility. Additionally, code standardization plugins such as eslint
and prettier
need to be installed, along with eslint
's ts parser
and plugins. All of this has already been configured and runs smoothly in VS Code
. I've also configured lint-staged
and more. Here's the information in the package.json
file. With this file available, you can directly start a normal, compilable uniapp-typescript
template. If you need other plugins, you'll need to try them yourself.
{
"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"
]
}
}
// 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) {
// Construct with no parameters
return new Date();
} else if (p1 instanceof Date || (typeof p1 === "number" && p2 === void 0)) {
// The first parameter is a `Date` or `Number` and there is no second parameter
return new Date(p1);
} else if (typeof p1 === "number" && typeof p2 === "number") {
// Both the first and second parameters are `Number`
return new Date(p1, p2, p3, p4, p5, p6, p7);
} else if (typeof p1 === "string") {
// The first parameter is a `String`
return new Date(p1.replace(/-/g, "/"));
}
throw new Error("No suitable parameters");
}
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>));
};
It's quite tricky to write TypeScript in Vue files. In fact, there are two main ways. One is using Vue.extend
, and the other is using decorators. The primary reference I used is this. Personally, I lean towards the decorator approach. However, when using decorators to write components in WeChat mini-programs, a prop
type mismatch warning often occurs. It doesn't affect usage. Regardless of the approach, there are still fragmentation issues. This can be considered a design flaw in Vue 2, especially since TypeScript wasn't very popular at that time.
Decorator | Purpose | Description |
---|---|---|
Component |
Declare class components | Must be added to all components |
Prop |
Declare props |
Corresponds to the props attribute in regular component declarations |
Watch |
Declare watchers | Corresponds to the watch attribute in regular component declarations |
Mixins |
Mixin inheritance | Corresponds to the mixins attribute in regular component declarations |
Emit |
Child component to parent component value transfer | Equivalent to regular this.$emit() |
Inject |
Receive values passed by ancestor components | Corresponds to the inject attribute in regular component declarations |
Provide |
An ancestor component injects a dependency for all its descendants | Corresponds to the provide attribute in regular component declarations |
<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>
<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";
// All `Vue` instance attributes can be written in `Component`, for example, `filters`
@Component({
components: {
HelloWorld
}
})
export default class App extends Vue {}
</script>
```vue
<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>
<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>
<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>
<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>
<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>
<!-- children.vue -->
<template>
<button @click="$emit("submit", "1")">Submit</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">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";
}
}
</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>
<!-- 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>
// 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;
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { State, Getter, Action } from "vuex-class";
import { UserInfo } from "./store/modules/user";
@Component({
name: 'App',
components: {
CCard,
}
})
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>
Writing and publishing a component to NPM in uniapp
is quite tricky. I'd like to extract some functionalities into a standalone NPM package for multi-project usage, but there are numerous hurdles to overcome. Here, I mainly record the pitfalls encountered, which can be quite frustrating. Since it is mainly used on the mini-program side, it's different from the web
side. It needs to be compiled into files that the mini-program can recognize. However, dcloud
currently does not provide this capability, so it can only write the most original vue
components. Also, since uniapp
performs many plugin parsing behaviors, some things are even directly fixed in the code and cannot be changed externally. In addition, some error locations do not throw exceptions but swallow them directly, resulting in the output file being empty without any console prompts. In short, there were quite a few pitfalls to overcome. Here, there are three ways to complete the NPM component publishing. I use https://github.com/WindrunnerMax/Campus
as an example.
First is the simplest way, similar to https://github.com/WindrunnerMax/Campus/tree/master/src/components
. All the components are completed in the components
directory. We can directly create a package.json
file here, and then publish the resource files here. In this way, it's very simple. When using it, just reference it directly. Also, you can set an alias to reference it, I tried it in VSCode
, there will be code prompts when pressing @
, so adding an @
as an alias may be helpful.
$ yarn add shst-campus-components
Configure vue.config.js
and 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/*"
]
},
// ...
}
Using the component library, please refer to https://github.com/WindrunnerMax/Campus
.
// ...
import CCard from "@campus/c-card/c-card.vue";
// ...
The second method is quite tricky, and to be honest, I've given up on this idea now. However, I'll still document the process because after all, I've managed to achieve a functional implementation after toiling for a whole day. But it's not very versatile, mainly because the regular expression matching in the loader
doesn't cover all scenarios. So, ultimately, I didn't opt for this method. What initially seemed like a straightforward issue ended up requiring the creation of a loader
, which was quite a headache. Initially, I aimed to achieve a import format similar to import { CCard } from "shst-campus"
. It looks familiar, and the idea was to mimic the import method of antd
or similarly element-ui
. So, in reality, I did delve into their import methods, and in the end, I created a Babel
plugin. Through this plugin, the imports are compiled into other import statements. By default, the example I mentioned earlier would look something like import CCard from "shst-campus/lib/c-card"
. Of course, this can be configured using babel-plugin-import
and babel-plugin-component
to achieve a kind of demand-driven loading. First, I tried babel-plugin-import
and configured the related paths.
// 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,
};
The idea was idealistic, but when I attempted to compile, I found that this configuration had no effect. Although I was puzzled, I considered that this was originally a built-in plugin for uniapp
, so the configuration might have been overridden or disregarded. Hence, I tried using 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,
};
This time, it actually had an effect and succeeded in achieving demand-driven loading. Excitedly, I proceeded with the compilation which was successful. However, upon opening the WeChat developer tool, I encountered an error. It turned out that there was an error in the json
file, as the imported component could not be found. In the json
file, the imported file was simply placed as is, i.e., shst-campus/index
, which obviously isn't a component. Most likely, the issue arose because the timing of the plugin I was using didn't align with the original plugin. The uniapp
plugin was already completed in the pre-analysis phase, which was quite awkward. I thought of solving this json
issue by writing a webpack
plugin.
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",
}),
// ...
],
// ...
},
};
*/
Through this plugin, I did manage to solve the problem of importing components from the json
file successfully. I then started the WeChat Developer Tool and found that the component loaded successfully, but all the logic and styles were lost. It was strange. I checked the compilation of the component and found that the component was not compiled successfully at all. Both the js
and css
failed to compile. This was embarrassing. In fact, during the compilation process, the uniapp
plugin did not throw any exceptions. It internally handled all the related issues without any indication. I still wanted to solve this problem by writing a webpack
plugin. I tried handling it in the compiler
and compilation
hooks, but it didn't solve the problem. Later, while printing the NormalModuleFactory
hook, I found that the source
had been correctly specified as the desired path through the processing of babel-plugin-component
. However, there were still problems during uniapp
compilation. Then I started thinking about how early uniapp
actually handles these things. I also tried the JavascriptParser
hook, but it didn't handle it successfully. In fact, there is a plugin @dcloudio/webpack-uni-mp-loader/lib/babel/util.js
which handles this matter. There were more pitfalls here.
Then I went back to the babel-plugin-import
, as this is a handling plugin carried in the dependencies of uniapp
. So theoretically, it is used in there. I noticed that there is a statement to handle @dcloudio/uni-ui
in the babel.config.js
.
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}`;
},
},
]);
});
So, I thought of writing something similar. The specific process is just a description. First, I had written a similar declaration before, but it did not take effect. I tried adding my components to process.UNI_LIBRARIES
and found that it actually worked. This surprised me. I thought that there must be some processing in process.UNI_LIBRARIES
, so I modified it slightly. After handling in process.UNI_LIBRARIES
, the babel-plugin-import
plugin also handled it. Then I started the compilation and found that the same problem still existed. The files could not be compiled successfully, the content was empty, and all the error information was hidden, without any error message coming out. This was really frustrating. It also affected the reference to the @dcloudio/uni-ui
component. I found this by casually referencing a component, and found that the component here also became empty and could not be resolved successfully. In the json
file, the declaration of my file in src/components
was declared as being in the lib
directory, and then I saw that there is a babel
plugin that references @dcloudio/webpack-uni-mp-loader/lib/babel/util.js
, and the processing of process.UNI_LIBRARIES
in there was hardcoded. This really made it a nightmare. Therefore, to solve this problem, I must handle the reference declaration of the vue
file in advance, because it is not a problem to directly declare the reference under src/components
. If I want to handle this problem before uniapp
processes it, then I can only write a loader
to handle it. I implemented a regex to match the import
statement and then parsed the import
statement to process the complete path. Considering the complexity of references, I still considered using a relatively common parsing library to implement the parsing of the import
statement rather than completing this task only through the matching of regular expressions. Then I used parse-imports
to complete this loader
.
// vue.config.js
const path = require("path");
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;
}
);
};
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: [],
},
};
Lately, I haven't had much to do, so I rewrote the loader
mentioned earlier. If you use on-demand loading, you can ignore the above. Just install the dependencies and configure them in vue.config.js
. For detailed configuration, you can check https://github.com/SHST-SDUST/SHST-PLUS/blob/master/vue.config.js
.
$ yarn add -D uniapp-import-loader
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",
},
},
],
},
// ..
},
};
Recently, I've been studying the related code and the babel
processing solution in the uniapp
framework, and I've implemented a solution for on-demand importing using babel-plugin
. You can choose between this solution and the webpack-loader
solution. To implement this, you need to configure the babel.config.js
. You can find detailed configuration at 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";
]);
// ...
Finally, it's time to prepare for the chosen solution. This solution is essentially the same as the usage method for @dcloudio/uni-ui
, because since uniapp
is hardcoded, we should adapt to this approach. We won't be doing any special handling of plugins using loaders or plugins. We'll just consider it as a standard. I've encountered too many obstacles, and I can't cope with them anymore. I actually think that using a loader to solve this problem is also acceptable, but in reality, the changes required are too extensive and need to be adapted universally. It's better to use a relatively universal approach. We can write a script to handle the structure of its components, which can be automatically built and published after completion. Now, I've generated an index.js
in dist/package
as the import main
, as well as an index.d.ts
as the declaration file, a README.md
, package.json
, .npmrc
files, and the components conforming to the above directory structure. These are mainly file operations and writing the build and publish commands in package.json
. You can compare the file differences between https://npm.runkit.com/shst-campus
and https://github.com/WindrunnerMax/Campus
, or just run npm run build:package
at https://github.com/WindrunnerMax/Campus
to see the npm
package to be published in dist/package
.
// 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 });
// Print the output of background executable program
workerProcess.stdout.on("data", data => {
process.stdout.write(data);
});
// Print the error output of background executable program
workerProcess.stderr.on("data", data => {
process.stdout.write(data);
});
// Output after exit
// 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);
I originally thought this approach would be enough, but then ran into a major issue. The problem this time is that when using the on-demand import method, for example, import { CCard } from "shst-campus";
, if the pages written in the local src
directory use the decorator syntax, the components in the node_modules
cannot be compiled properly. Whether the components in node_modules
are in TS
or regular vue
format, the same problem occurs. This issue was discussed in detail in the blog post above; it's a major pitfall. The output of the compilation lacks CSS and JavaScript files, and only contains a Component({})
. However, if the Vue.extend
syntax is used, the components in node_modules
can be compiled properly. Of course, locally written components in src
without using TS
will not have any issues. There are now three possible solutions, but the ultimate solution would be to write a webpack loader
. I have implemented this in the blog, but decided not to use it due to its lack of versatility. If things get really tough, I will refine the loader
. As for why a loader
was written instead of just using a plugin
, that can also be found in the blog. What a pitfall!
- Components in
src
are written using the decorator syntax, and components are imported using the actual path, as inimport CCard from "shst-campus/lib/c-card/c-card.vue";
. - Components in
src
are written using theVue.extend
syntax, and can be imported on demand, as inimport { CCard } from "shst-campus";
. - Components in
src
can be written in either of these ways, and then configure theeasycom
provided byuniapp
to use the components directly without declaring them.
If configuring on-demand import of components, as in import { CCard } from "shst-campus";
, modification of the babel.config.js
file is required.
// 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,
]);
});
// ...
If using the easycom
import method, then configuration of pages.json
is necessary.
// pages.json
{
"easycom": {
"autoscan": true,
"custom": {
"^c-(.*)": "shst-campus/lib/c-$1/c-$1.vue"
}
},
// ...
}
This is the ultimate solution. Eventually, I took some time to use the parse-imports
library to create a new loader
. Compatibility should be good. Besides, this library is quite tricky. It's a module
without being packaged as commonjs
, which means that as a loader
, I had to bundle all the dependencies into one js
file, which is quite frustrating. I'm planning to use this approach to solve the issues with uniapp
components, and also to test the library's compatibility. If you want to use the on-demand loading method, you can ignore the above. Just install the dependencies and configure them in the 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);
});
};
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