基于Vue Cli4搭建Vue3 TSX移动端项目(四)

987 阅读12分钟

基于Vue Cli4搭建Vue3 TSX移动端项目(四)

至上一篇,基本上这个项目的框架的大致完成了。本篇不再针对框架层面进行改动,主要讲述基于Vue3 Components API 封装一些在移动端中我们经常会使用到的Hooks及Vue3中TSX和单Vue文件写法的一些对比。

封装常用Hooks

在我们公司的业务中,日常移动端开发必不可少的4个Hooks分别是:

  1. 接口请求
  2. 微信分享
  3. 文件上传
  4. 数据埋点上报

这里微信分享、文件上传、接口请求比较具有通用性,而用户操作记录的埋点上报太过业务,就不赘述了。

useRequest 接口请求

这里我借鉴了umiuseRequest,将其中一些在我这边没有应用场景的功能移除。这个Hooks 的主要实现如下

  1. 返回响应变量dataloading方便页面展示
  2. 返回run方法,可以让业务页面手动触发请求。同时run是一个Promise,当请求成功时会触发这个Promise,方便一些需要做链式请求的业务。
  3. 可配置自动/手动触发请求
  4. 请求防抖
  5. swr数据缓存
  6. 封装后端接口异常提示

这里我新建了src/hooks文件夹作为hooks的存放目录,新建useRequest.ts。具体代码如下,这里我做了详细的注释,代码逻辑也很简单。

import { ref } from '@vue/reactivity';
import { AxiosPromise } from 'axios';
import { Toast } from 'vant';
import { errorParse } from '@/utils/errorParse';
interface IRequestConfig {
  // 是否手动请求
  manual?:boolean;
  // 防抖毫秒数
  debounce?:number;
  // swr的缓存key
  cacheKey?:string;
  // 是否关闭Toast报错
  closeToast?:boolean;
}
// 缓存
const cache:Record<string, any> = {};
// 防抖
let timer:number | undefined;

/**
 * @description 执行防抖
 * @author Wynne
 * @date 2021-03-16
 * @param {IRequestConfig} [config]
 */
function runDebounce(config?:IRequestConfig) {
  return new Promise((resolve, reject) => {
    clearTimeout(timer);
    // 是否配置了防抖
    if (config?.debounce && config.debounce > 0) {
      timer = setTimeout(() => {
        resolve(true);
      }, config.debounce);
    } else {
      resolve(true);
    }
  });
}

/**
 * @description useRequest Hooks
 * @author Wynne
 * @date 2021-03-16
 * @param {() => AxiosPromise<T>} request
 * @param {IRequestConfig} [config]
 */
export function useRequest<T>(request:() => AxiosPromise<T>, config?:IRequestConfig) {
  // 是否加载中
  const loading = ref(false);
  // 返回数据
  const data = ref<T>();
  // 执行请求方法
  const run = ():AxiosPromise<T> => new Promise((resolve, reject) => {
    // 如果配置了缓存且命中时,先返回缓存再做请求
    if (config?.cacheKey && cache) {
      data.value = cache[config.cacheKey];
    }
    loading.value = true;
    runDebounce(config).then(() => {
    	/** 这里有点争议
       * request参数是具体的请求方法
       * 这里我想到的方法是可以采用两种形式:
       * 1. 将请求方法和参数分开传入
       * 2. 将请求方法再包一层,形成一个闭包,类似于: (param) => () => AxiosPromise<T>
       * 我采用了第二种形式,主要的考虑是请求方法中不止有传参,还会有一些headers之类的
       * 如果全部传进来会导致参数过长,我又不太喜欢超过3个参数的方法。
       * 至于是否还有更好的方法,目前我还没有想到,也望有大佬能够指点下
       */
      request().then((res) => {
        loading.value = false;
        if (config?.cacheKey) {
          cache[config?.cacheKey] = res.data;
        }
        if (/^2\d/.test(res.status.toString())) {
          data.value = res.data;
        } else if (!config?.closeToast) {
          // 如果非200开头,则展示通用errorCode提示
          Toast(errorParse((res.data as any).error_code));
        }
        resolve(res);
      }).catch((err) => {
        reject(err);
        loading.value = false;
       	// 如果不希望展示报错弹框,需要业务代码自己做处理时,可以关闭报错弹框
        if (!config?.closeToast) {
          // 这里errorParse(err.error_code)是根据后端返回错误码去换取文案,就不再展示详细代码了
          Toast(errorParse(err.error_code));
        }
      });
    }).catch((e) => console.log(e));
  });
  // 如果不需要立即执行
  if (!config?.manual) {
    run();
  }
  return { loading, data, run };
}

然后我这里大概的展示下如何使用这个Hooks

// src/apis/wechat.ts
import Http from 'axios';
const config = window.CUSTOMCONFIG; // 项目不同环境的配置文件
export const Api = new Http(config.api);
// 获取微信Token的API接口
export const fetchWechatToken = ():AxiosPromise<IWechatToken> => Api.get('/getWechatToken');

// src/views/Test.tsx
 const { data, loading, run } = useRequest(fetchAccountLogin({
   openId: 'xxxxxxxxxx',
 }),
 {
   manual: true,						// 开启手动触发
   debounce: 500,						// 开启500毫秒防抖
   cacheKey: 'WechatToken',	// 设置缓存key
   closeToast: false,				// 开启默认提示
 });
// 手动触发请求
run().then((res) => {
  console.log('请求成功', res);
}).catch((err) => {
  console.log('请求失败', err);
});

useWechatShare 微信分享

微信JSSDK的坑踩过太多回了,所以在封装这个Hooks的时候,我把一些正则参数校验也加入到了里面,防止后续使用的人不注意传了不规范的参数,导致设置失败。

首先我们在public/index.html中添加微信jssdk

<script src="//res.wx.qq.com/open/js/jweixin-1.6.0.js" async></script>

然后我将正则校验的规则统一放在了utils/regex.ts中,将微信JSSDK相关方法放在utils/wechat.ts中。这样能够统一后续项目中的正则,方便以后规则修改。

utils/regex.ts

// 手机号正则
export const mobileReg = /^1[3-9]\d{9}/;
// 整数正则
export const integerReg = /^\d{6}/;
// url正则
export const urlReg = /(https?):\/\/[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]/;
// 是否是微信浏览器
export const isWechat = !!navigator.userAgent.toLocaleLowerCase().match(/micromessenger/i);
// 是否是IOS
export const isIOS = !!navigator.userAgent.match(/ipad|iphone/i);

utils/wechat.ts

interface IWechatConfig {
  debug:boolean; // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
  appId:string; // 必填,公众号的唯一标识
  timestamp:number; // 必填,生成签名的时间戳
  nonceStr:string; // 必填,生成签名的随机串
  signature:string; // 必填,签名
  jsApiList:string[]; // 必填,需要使用的JS接口列表
}

export interface IWechatSharedConfig {
  title:string; // 分享标题
  desc:string; // 分享描述
  link:string; // 分享链接,该链接域名或路径必须与当前页面对应的公众号JS安全域名一致
  imgUrl:string; // 分享图标
}

/**
 * @description 微信分享给朋友配置
 * @author Wynne
 * @date 2021-03-16
 * @param {IWechatSharedConfig} [config]
 */
const wechatAppMessageShared = (config:IWechatSharedConfig) => new Promise((resolve, reject) => {
  window.wx.updateAppMessageShareData({
    title: config.title,
    desc: config.desc,
    link: config.link,
    imgUrl: config.imgUrl,
    success: function() {
      resolve(true);
    },
  });
});

/**
 * @description 微信分享朋友圈配置
 * @author Wynne
 * @date 2021-03-16
 * @param {IWechatSharedConfig} [config]
 */
const wechatTimelineShared = (config:IWechatSharedConfig) => new Promise((resolve, reject) => {
  window.wx.updateTimelineShareData({
    title: config.title,
    desc: config.desc,
    link: config.link,
    imgUrl: config.imgUrl,
    success: function() {
      resolve(true);
    },
  });
});

/**
 * @description 微信初始化
 * @author Wynne
 * @date 2021-03-16
 * @param {IWechatConfig} [config]
 */
export const wechatInit = (config:IWechatConfig) => new Promise((resolve, reject) => {
  window.wx.config(config);
  window.wx.ready(() => {
    resolve(true);
  });
  window.wx.error((res:any) => {
    reject(res);
  });
});

/**
 * @description 微信分享初始化
 * @author Wynne
 * @date 2021-03-16
 * @param {IWechatSharedConfig} [config]
 */
export const wechatSharedInit = async(config:IWechatSharedConfig) => Promise.all([wechatAppMessageShared(config), wechatTimelineShared(config)])

之后继续在hooks文件夹中添加useWechatShare.ts文件

import { fetchWechatJSSDK } from '@/apis/wechat'
import { isWechat, urlReg } from '@/utils/regex'
import { IWechatSharedConfig, wechatInit, wechatSharedInit } from '@/utils/wechat'
const config = window.CUSTOMCONFIG
/**
 * @description 微信分享配置Hooks
 * @author Wynne
 * @date 2021-03-16
 * @param {IWechatSharedConfig} [config]
 */
export const useWechatShare = (wechatSharedConfig:IWechatSharedConfig, isDebug = false) => {
  // 校验分享链接是否不符合url规则
  if (!urlReg.test(wechatSharedConfig.link)) {
    throw new Error('link 格式不正确!!')
  }
  // 校验分享图片是否不符合url规则
  if (!urlReg.test(wechatSharedConfig.imgUrl)) {
    throw new Error('imgUrl 格式不正确!!')
  }
  // 如果非微信浏览器则不再做后续操作
  if (!isWechat) return
  // 这里需要向后端获取签名,具体方法可以根据自己业务编写
  fetchWechatJSSDK({
    url: location.href.split('#')[0],
    js_api_list: ['updateAppMessageShareData', 'updateTimelineShareData'],
    appid: config.wechat.appId
  }).then((res) => {
    const { data } = res
    // 进行微信jssdk初始化
    wechatInit({
      debug: isDebug,
      appId: data.appId,
      nonceStr: data.nonceStr,
      signature: data.signature,
      timestamp: data.timestamp,
      jsApiList: data.jsApiList
    }).then((res) => {
      // 设置微信分享文案
      wechatSharedInit(wechatSharedConfig)
    }).catch((err) => {
      console.log(err)
    })
  }).catch((err) => console.log(err))
}

当我们需要做微信的分享样式设置时,只需要这样使用

useWechat({
  title: '开启时光机,惊喜好礼等你赢',
  desc: '参与活动可得100%抽奖机会,赢小米笔记本、ipad、扫地机器人…',
  link: location.origin,
  imgUrl: 'https://source-static.xxxxx.cn/wx_shared.png',
});

useUpload 文件上传

这里import { CdnUpload } from '@/utils/upload';是封装的阿里云OSS的上传SDK,这个Hooks主要是针对上传文件的数量、文件类型、文件大小之类等做了一些便于使用的功能业务。具体的阿里云OSS上传方法就不再赘述了,可以百度查一下或自己使用的云服务商文档看。

import { ref } from '@vue/reactivity';
import { Toast } from 'vant';
import { CdnUpload } from '@/utils/upload';

// 上传状态
export enum UploadStatusEnum {
  WAIT, // 未开始
  UPLOADING, // 上传中
  SUCCESS, // 成功
  FAIL, // 失败
}

// 上传配置
export interface IUploadConfig {
  // 允许上传的类型,例: image/*
  accept?:string;
  // 最大大小限制,单位K
  maxSize?:number;
  // 最大上传数量
  maxCount?:number;
  // 自定义文件名,如果不传会自动生成一个guid标识
  fileName?:string;
  // 上传路径,不传默认存放在根目录
  path?:string;
}

// 文件信息
export interface IFileInfo {
  // 文件原名
  fileOriginName:string;
  // 文件自定义后的名字
  fileName:string;
  // 文件大小,单位K
  fileSize:number;
  // 文件上传后的URL
  fileUrl?:string;
  // 文件上传进度
  progress:number;
  // bucket名,只针对海报上传
  bucket?:string;
}

/**
 * @description 生成GUID
 * @author Wynne
 * @date 2021-03-24
 * @return {*}
 */
function generateGUID() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
    const r = Math.random() * 16 | 0;
    const v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

// 上传input实例
let fileInputEle:HTMLInputElement | undefined;

/**
 * @description 生成GUID
 * @author Wynne
 * @date 2021-03-24
 * @param {function} fileUrl 成功回调
 * @param {IUploadConfig} config 上传配置
 */
export const useUpload = (config?:IUploadConfig) => {
  if (config?.path && !/[0-9|a-zA-Z|\-|_]*\/$/.test(config.path)) {
    throw new Error('path只支持中英文/数字/中划线/下划线');
  }
  // 上传进度,百分制
  const files = ref<IFileInfo[]>([]);
  // 上传状态
  const status = ref<UploadStatusEnum>(UploadStatusEnum.WAIT);

  // 开始上传
  const start = (uploadFiles?:File[]):Promise<IFileInfo[]> => new Promise((resolve, reject) => {
    // 进度条信息变更
    const handleProgressChange = (fileName:string, pg:any) => {
      const item = files.value.find((e) => e.fileName === fileName);
      if (item) {
        item.progress = pg.total.percent;
      }
    };

    // 验证是否上传完成
    const checkUploadComplete = () => {
      if (fileInputEle?.files && fileInputEle.files.length === files.value.filter((e) => e.fileUrl).length) {
        fileInputEle.value = '';
        status.value = UploadStatusEnum.SUCCESS;
        resolve(files.value);
      }
    };

    // 文件上传
    const fileUpload = (file:File) => {
      // 文件重命名
      const fileExt = file.name.split('.').pop();
      const fileName = `${(config && config.path) || ""}${generateGUID()}.${fileExt}`;
      const newFile = new File([file], fileName, {
        type: file.type,
      });
      // 添加到上传文件记录汇总
      files.value.push({
        fileName: fileName,
        fileOriginName: file.name,
        fileSize: Math.floor(file.size / 1024),
        progress: 0,
      });
      // 常规上传
      CdnUpload(newFile, (progress) => {
        handleProgressChange(fileName, progress);
      }).then((url) => {
        const item = files.value.find((e) => e.fileName === fileName);
        if (item) {
          item.fileUrl = url;
        }
        // 校验是否上传完成
        checkUploadComplete();
      }).catch((error) => {
        status.value = UploadStatusEnum.FAIL;
        if (error.code === 'ECONNABORTED') {
          Toast('网络环境较差,上传失败,请稍后重试一下吧~');
        } else {
          Toast('上传失败,请重试一下吧~');
        }
        console.log('上传失败:', error);
        reject(error);
      });
    };

    // input变更事件
    const handleInputChange = () => {
      const uploadFileList = uploadFiles || fileInputEle?.files;
      console.log(uploadFileList);
      // 判断文件为空
      if (!uploadFileList || uploadFileList.length === 0) {
        Toast('请先选择上传文件哦');
        return;
      }
      // 判断超出最大上传数
      if (config?.maxCount && uploadFileList.length > config?.maxCount) {
        Toast(`超出最大上传文件数哦,最多上传${config.maxCount}个文件`);
        return;
      }
      // 判断超出最大文件大小
      if (config?.maxSize) {
        for (let index = 0; index < uploadFileList.length; index++) {
          const file = uploadFileList[index];
          if (file.size / 1024 > config.maxSize) {
            Toast(`超出上传过大哦,最大上传${config.maxSize}K的文件`);
            return;
          }
        }
      }
      // 判断是否符合文件类型
      if (config?.accept) {
        let acceptArr = config.accept.split(',');
        acceptArr = acceptArr.map((e) => {
          e = e.replaceAll('/', '\/');
          e = e.replaceAll('*', '.*');
          return `(${e})`;
        });
        const reg = new RegExp(acceptArr.join('|'));
        for (let index = 0; index < uploadFileList.length; index++) {
          const file = uploadFileList[index];
          if (!reg.test(file.type)) {
            Toast(`文件格式错误,仅支持上传${config.accept}文件`);
            return;
          }
        }
      }
      status.value = UploadStatusEnum.UPLOADING;
      for (const file of uploadFileList) {
        fileUpload(file);
      }
    };

    // 如果没有input实例,则创建input file实例
    if (!fileInputEle) {
      fileInputEle = document.createElement('input');
      fileInputEle.type = 'file';
      fileInputEle.multiple = config?.maxCount !== 1;
      if (config?.accept) {
        fileInputEle.accept = config.accept;
      }
      fileInputEle.addEventListener('change', handleInputChange);
    }
    files.value = [];
    // 如果自定义了上传文件,则不触发input上传
    if (uploadFiles && uploadFiles.length > 0) {
      handleInputChange();
    } else {
      fileInputEle?.click();
    }
  });

  return {
    start,
    files,
    status,
  };
};

下面再附带一个该HOOKS的使用示例:

 const { start, files, status } = useUpload({
   accept: 'image/*,video/mp4',
   maxCount: 2,
   maxSize: 50 * 1024,
   path: 'test/'
 });

start().then((res) => {
  console.log(res[0]);
}).catch((err) => { console.log(err); });

TSX vs Vue

Vue3 静态提升

Vue3中针对静态的DOM做了静态提升,但是这个优化目前只能在template写法才能享受到。TSX的写法就无法享受到了。下面是我测试的一个例子:

Vue文件template写法

这里我用了最简单的页面展示来做静态提升测试

<template>
  <h1>h1</h1>
  <h1>h2</h1>
  <h1>{{ msg }}</h1>
</template>

<script>
import { defineComponent, ref } from "@vue/runtime-core";

export default defineComponent({
  name: "App",
  setup() {
    const msg = ref("hello world");
    return {
      msg,
    };
  },
});
</script>

然后我们进行打包看下转换后的源码

image-20210409004522740

从上面的代码中我们可以看到上面的两个静态DOM已经做了静态提升优化。接下来我们再尝试下TSX的写法。

TSX文件写法

这里我用TSX的写法再重新写一下上面的DOM结构

import { defineComponent, ref } from "@vue/runtime-core";
export default defineComponent({
  name: "App",
  setup() {
    const msg = ref("hello world");
    return ()=>(
      <div>
        <h1>h1</h1>
        <h1>h2</h1>
        <h1>{msg.value}</h1>
      </div>
    );
  },
});

然后我们再来看下TSX写法打包后的源码

image-20210409005406232

通过上面两组代码的测试,我们可以看到Vue3的TSX写法并不享受静态提升。毕竟Vue从一开始就不是为TSX而生,所以官方推荐的写法是用template而不是tsx。所以在日常业务开发中,还是建议使用template进行开发,除此优化之外,支持度也是template更优。所以我个人倾向于主要使用template写法,当开发灵活度要求高的组件时再使用tsx,两种方式混合去开发。

Vue中常用语法在TSX中的写法

v-show 隐藏/展示

template写法

<template>
  <h1 v-show="visible">Hello world</h1>
</template>

<script lang="ts">
import { defineComponent, ref } from '@vue/runtime-core'

export default defineComponent({
  setup () {
    const visible = ref(true)
    return { visible }
  }
})
</script>

tsx写法

import { defineComponent, ref } from 'vue'

export default defineComponent({
  name: 'Home',
  setup () {
    const visible = ref(false)
    return () => (
      <div>
        <h1 v-show={visible.value}>Hello world1</h1>
        <h1 vShow={visible.value}>Hello world2</h1>
      </div>
    )
  }
})

其实这里TSX的两种写法都是能够正常使用的,但是原生H5标签中并没有vShow、vHtml、vModel等声明,所以这样写TS会报错。暂时没有找到更好的办法去解决这个问题。

v-if 是否渲染

template写法

<template>
  <h1 v-if="visible">Hello world</h1>
</template>

<script lang="ts">
import { defineComponent, ref } from '@vue/runtime-core'

export default defineComponent({
  setup () {
    const visible = ref(true)
    return { visible }
  }
})
</script>

tsx写法

import { defineComponent, ref } from 'vue'
export default defineComponent({
  name: 'Home',
  setup () {
    const visible = ref(false)
    return () => (
      visible.value
        ? <div>
          <h1>Hello world</h1>
        </div>
        : null
    )
  }
})

v-if 在TSX中我们直接使用javascript的三元表达式来判断即可

v-for 循环

template写法

<template>
  <h1 v-for="i in list" :key="i">
    Hello world
  </h1>
</template>

<script lang="ts">
import { defineComponent, ref } from '@vue/runtime-core'

export default defineComponent({
  setup () {
    const list = ref([1, 2, 3, 4, 5])
    return { list }
  }
})
</script>

tsx写法

import { defineComponent, ref } from 'vue'

export default defineComponent({
  name: 'Home',
  setup () {
    const list = ref([1, 2, 3, 4, 5])
    return () => (
      <div>
        {
          list.value.map((e) => <h1>Hello world</h1>)
        }
      </div>
    )
  }
})

v-for 在TSX中我们直接使用javascript的循环即可

ref 原始DOM引用

template写法

<template>
  <h1 ref="h1Ref">Hello world</h1>
</template>

<script lang="ts">
import { defineComponent, onMounted, ref } from '@vue/runtime-core'

export default defineComponent({
  setup () {
    const h1Ref = ref<HTMLElement>()
    onMounted(() => {
      console.log(h1Ref.value)
    })
    return { h1Ref }
  }
})
</script>

tsx写法

import { defineComponent, onMounted, ref } from 'vue'

export default defineComponent({
  name: 'Home',
  setup () {
    const h1Ref = ref<HTMLElement>()
    onMounted(() => {
      console.log(h1Ref.value)
    })

    return () => (
      <div>
        <h1 ref={h1Ref}>Hello world</h1>
      </div>
    )
  }
})
slots 插槽

template写法

// 子组件
<template>
  <div>
    <p><slot /></p>
    <h1><slot name="strong" /></h1>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
  name: 'HelloWorld'
})
</script>

// 父组件
<template>
  <div>
    <HelloWorld>
      <span>default</span>
      <template #strong>Strong</template>
    </HelloWorld>
  </div>
</template>

<script lang="ts">
import { defineComponent } from '@vue/runtime-core'
import { HelloWorld } from '@/components/HelloWorld'

export default defineComponent({
  components: { HelloWorld },
  setup () {
    return {}
  }
})
</script>

tsx写法

// 子组件
import { defineComponent, renderSlot } from 'vue'
export const HelloWorld = defineComponent({
  name: 'HelloWorld',
  setup (props, ctx) {
    console.log(ctx.slots)
    return () =>
      <div>
        <p>{renderSlot(ctx.slots, 'default')}</p>
        <h1>{renderSlot(ctx.slots, 'strong')}</h1>
      </div>
  }
})

// 父组件
import { defineComponent } from 'vue'
import { HelloWorld } from '@/components/HelloWorld'
export default defineComponent({
  name: 'Home',
  setup () {
    return () => (
      <HelloWorld vSlots={{
        default: () => [<span>default</span>],
        strong: () => [<span>Strong</span>]
      }}>
      </HelloWorld>
    )
  }
})
v-model 双向绑定

template写法

<template>
  <div>
    <input v-model="form" type="text"/>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, watch } from '@vue/runtime-core'

export default defineComponent({
  setup () {
    const form = ref('')
    watch(
      () => form.value,
      () => console.log(form.value)
    )
    return {
      form
    }
  }
})
</script>

tsx写法

import { defineComponent, ref, watch } from 'vue'
export default defineComponent({
  name: 'Home',
  setup () {
    const form = ref('')
    watch(
      () => form.value,
      () => console.log(form.value)
    )
    return () => (
      <div>
        <input v-model={form.value} type="text" />
        <input vModel={form.value} type="text" />
      </div>
    )
  }
})

这里其实跟vShow有一样的问题,就是在原生DOM上使用vModel ts 会报错

.sync 修饰符

子组件

import { defineComponent } from 'vue'
export const HelloWorld = defineComponent({
  name: 'HelloWorld',
  props: ['visible'],
  setup (props, ctx) {
    const updateStatus = () => {
      ctx.emit('update:visible', !props.visible)
    }
    return () =>
      <button onClick={updateStatus}>点击变更</button>
  }
})

template写法

<template>
  <HelloWorld v-model:visible="visible" />
</template>

<script lang="ts">
import { defineComponent, ref, watch } from '@vue/runtime-core'
import { HelloWorld } from '@/components/HelloWorld'
export default defineComponent({
  components: { HelloWorld },
  setup () {
    const visible = ref(false)
    watch(
      () => visible.value,
      () => console.log(visible.value)
    )
    return {
      visible
    }
  }
})
</script>

tsx写法

import { defineComponent, ref, watch } from 'vue'
import { HelloWorld } from '@/components/HelloWorld'
export default defineComponent({
  name: 'Home',
  setup () {
    const visible = ref(false)
    watch(
      () => visible.value,
      () => console.log(visible.value)
    )
    return () => (
      <div>
        <HelloWorld vModel={[visible.value, 'visible']} />
      </div>
    )
  }
})

可以感觉到Vue3现在在TSX方面的支持比起React还是差得比较多的。所以还是建议日常业务开发时使用template写法,当写灵活度高的组件时再使用tsx的写法。这样也能让你的项目更大范围的去享受静态提升

TSX样式模块化

我们知道在vue文件中可以使用scoped来实现样式模块化,那么在tsx中vue cli4也帮我们内置了css modules,只需要我们在引入样式时,将样式文件名改为类似于*.module.scss即可,下面附带一个demo

首先我们需要在shims-vue.d.ts文件中添加一下声明,避免引入scss报错

//解决scss文件报错问题
declare module '*.scss'{
	const sass:any
	export default sass
}

在tsx文件中引入scss并使用style来赋值class

import { defineComponent, ref, watch } from 'vue'
import style from './home.module.scss'
export default defineComponent({
  name: 'Home',
  setup () {
    return () => (
      <div class={style.blue}>
        <div class={style.red}></div>
      </div>
    )
  }
})

这样就可以实现tsx中的css模块化了

image-20210410105923536

自定义组件onClick、vModel、vSlots等报错

在引入第三方组件库或者我们自己编写的组件时,会发现当我们监听onClick事件时ts会提示属性未定义,

image-20210410110221390

这里应该也算是Vue3在tsx支持中的不完善之处,大概翻阅了下资料,发现在ElementUI-Plus中有提到一个解决方案,就是我们自己在typings文件夹下新建ele.d.ts声明文件扩展@vue/runtime-core这个module,具体代码如下

import { VNodeChild } from '@vue/runtime-core';
import { HTMLAttributes } from '@vue/runtime-dom';

export type JsxNode = VNodeChild | JSX.Element;

export interface SlotDirective {
  [name: string]: () => JsxNode;
}

type JsxComponentCustomProps = {
  vModel?: unknown;
  vModels?: unknown[];
  vCustom?: unknown[];
  vShow?: boolean;
  vHtml?: JsxNode;
  vSlots?: SlotDirective;
} & Omit<HTMLAttributes, 'innerHTML'> & {
  innerHTML?: JsxNode;
};

declare module '@vue/runtime-core' {
  interface ComponentCustomProps extends JsxComponentCustomProps {
    onClick?: () => any;
    vSlots?: {
      [eleName: string]: JSX.Element;
    };
    [eleName: string]: any;
  }
}

image-20210410110542585