Migrating Uniapp Mini Program to TypeScript

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.

Migrating to the cli Version

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.

Installing Plugins

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.

Decorators

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

Vue Lifecycle

<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";

// All `Vue` instance attributes can be written in `Component`, for example, `filters`
@Component({
  components: {
    HelloWorld
  }
})
export default class App extends Vue {}
</script>

Prop

```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>

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")">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>

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({
  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>

Publishing a NPM Package

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.

Only Publishing Components

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";
// ...

Writing Webpack Loader and Plugin

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/SHS‌T-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";
]);
// ...

Building a New Directory and Publishing

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 in import CCard from "shst-campus/lib/c-card/c-card.vue";.
  • Components in src are written using the Vue.extend syntax, and can be imported on demand, as in import { CCard } from "shst-campus";.
  • Components in src can be written in either of these ways, and then configure the easycom provided by uniapp 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);
    });
};

BLOG

https://github.com/WindrunnerMax/EveryDay

REFERENCES

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