普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月3日首页

3.组合式函数

作者 Zohnny
2025年12月3日 18:14

组合式函数

组合式函数,本质上也就是代码复用的一种方式。

  • 组件:对结构、样式、逻辑进行复用
  • 组合式函数:侧重于对 有状态 的逻辑进行复用

快速上手

实现一个鼠标坐标值的追踪器。

<template>
  <div>当前鼠标位置: {{ x }}, {{ y }}</div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const x = ref(0)
const y = ref(0)

function update(event) {
  x.value = event.pageX
  y.value = event.pageY
}

onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>

<style scoped></style>

多个组件中复用这个相同的逻辑,该怎么办?

答:使用组合式函数。将包含了状态的相关逻辑,一起提取到一个单独的函数中,该函数就是组合式函数。

相关细节

1. 组合式函数本身还可以相互嵌套

2. 和Vue2时期mixin区别

解决了 Vue2 时期 mixin 的一些问题。

  1. 不清晰的数据来源:当使用多个 minxin 的时候,实例上的数据属性来自于哪一个 mixin 不太好分辨。

  2. 命名空间冲突:如果多个 mixin 来自于不同的作者,可能会注册相同的属性名,造成命名冲突

    mixin

    const mixinA = {
      methods: {
        fetchData() {
          // fetch data logic for mixin A
          console.log('Fetching data from mixin A');
        }
      }
    };
    
    const mixinB = {
      methods: {
        fetchData() {
          // fetch data logic for mixin B
          console.log('Fetching data from mixin B');
        }
      }
    };
    
    new Vue({
      mixins: [mixinA, mixinB],
      template: `
        <div>
          <button @click="fetchData">Fetch Data</button>
        </div>
      `
    });
    

    组合式函数:

    // useMixinA.js
    import { ref } from 'vue';
    
    export function useMixinA() {
      function fetchData() {
        // fetch data logic for mixin A
        console.log('Fetching data from mixin A');
      }
    
      return { fetchData };
    }
    
    // useMixinB.js
    import { ref } from 'vue';
    
    export function useMixinB() {
      function fetchData() {
        // fetch data logic for mixin B
        console.log('Fetching data from mixin B');
      }
    
      return { fetchData };
    }
    

    组件使用上面的组合式函数:

    import { defineComponent } from 'vue';
    import { useMixinA } from './useMixinA';
    import { useMixinB } from './useMixinB';
    
    export default defineComponent({
      setup() {
        // 这里必须要给别名
        const { fetchData: fetchDataA } = useMixinA();
        const { fetchData: fetchDataB } = useMixinB();
    
        fetchDataA();
        fetchDataB();
    
        return { fetchDataA, fetchDataB };
      },
      template: `
        <div>
          <button @click="fetchDataA">Fetch Data A</button>
          <button @click="fetchDataB">Fetch Data B</button>
        </div>
      `
    });
    
  3. 隐式的跨mixin交流

    mixin

    export const mixinA = {
      data() {
        return {
          sharedValue: 'some value'
        };
      }
    };
    
    export const minxinB = {
      computed: {
        dValue(){
          // 和 mixinA 具有隐式的交流
          // 因为最终 mixin 的内容会被合并到组件实例上面,因此在 mixinB 里面可以直接访问 mixinA 的数据
          return this.sharedValue + 'xxxx';
        }
      }
    }
    

    组合式函数:交流就是显式的

    import { ref } from 'vue';
    
    export function useMixinA() {
      const sharedValue = ref('some value');
      return { sharedValue };
    }
    
    import { computed } from 'vue';
    
    export function useMixinB(sharedValue) {
      const derivedValue = computed(() => sharedValue.value + ' extended');
      return { derivedValue };
    }
    
    <template>
      <div>
        {{ derivedValue }}
      </div>
    </template>
    
    <script>
    import { defineComponent } from 'vue';
    import { useMixinA } from './useMixinA';
    import { useMixinB } from './useMixinB';
    
    export default defineComponent({
      setup() {
        const { sharedValue } = useMixinA();
        
        // 两个组合式函数的交流是显式的
        const { derivedValue } = useMixinB(sharedValue);
    
        return { derivedValue };
      }
    });
    </script>
    

异步状态

根据异步请求的情况显示不同的信息:

<template>
  <div v-if="error">Oops! Error encountered: {{ error.message }}</div>
  <div v-else-if="data">
    Data loaded:
    <pre>{{ data }}</pre>
  </div>
  <div v-else>Loading...</div>
</template>

<script setup>
import { ref } from 'vue'

// 发送请求获取数据
const data = ref(null)
// 错误
const error = ref(null)

fetch('...')
  .then((res) => res.json())
  .then((json) => (data.value = json))
  .catch((err) => (error.value = err))
</script>

如何复用这段逻辑?仍然是提取成一个组合式函数。

如下:

import { ref } from 'vue'
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  fetch(url)
    .then((res) => res.json())
    .then((json) => (data.value = json))
    .catch((err) => (error.value = err))

  return { data, error }
}

现在重构上面的组件:

<template>
  <div v-if="error">Oops! Error encountered: {{ error.message }}</div>
  <div v-else-if="data">
    Data loaded:
    <pre>{{ data }}</pre>
  </div>
  <div v-else>Loading...</div>
</template>

<script setup>
import {useFetch} from './hooks/useFetch';
const {data, error} = useFetch('xxxx')
</script>

这里为了更加灵活,我们想要传递一个响应式数据:

const url = ref('first-url');
// 请求数据
const {data, error} = useFetch(url);
// 修改 url 的值后重新请求数据
url.value = 'new-url';

此时我们就需要重构上面的组合式函数:

import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  const fetchData = () => {
    // 每次执行 fetchData 的时候,重制 data 和 error 的值
    data.value = null
    error.value = null

    fetch(toValue(url))
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) => (error.value = err))
  }

  watchEffect(() => {
    fetchData()
  })

  return { data, error }
}

约定和最佳实践

1. 命名:组合式函数约定用驼峰命名法命名,并以“use”作为开头。例如前面的 useMouse、useEvent.

2. 输入参数:注意参数是响应式数据的情况。如果你的组合式函数在输入参数是 ref 或 getter 的情况下创建了响应式 effect,为了让它能够被正确追踪,请确保要么使用 watch( ) 显式地监视 ref 或 getter,要么在 watchEffect( ) 中调用 toValue( )。

3. 返回值

组合式函数中推荐返回一个普通对象,该对象的每一项是 ref 数据,这样可以保证在解构的时候仍然能够保持其响应式的特性:

// 组合式函数
export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  // ...
  
  return { x, y }
}
import { useMouse } from './hooks/useMouse'
// 可以解构
const { x, y } = useMouse()

如果希望以对象属性的形式来使用组合式函数中返回的状态,可以将返回的对象用 reactive 再包装一次即可:

import { useMouse } from './hooks/useMouse'
const mouse = reactive(useMouse())

4. 副作用

在组合式函数中可以执行副作用,例如添加 DOM 事件监听器或者请求数据。但是请确保在 onUnmounted 里面清理副作用。

例如在一个组合式函数设置了一个事件监听器,那么就需要在 onUnmounted 的时候移除这个事件监听器。

export function useMouse() {
  // ...

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

// ...
}

也可以像前面 useEvent 一样,专门定义一个组合式函数来处理副作用:

import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  // 专门处理副作用的组合式函数
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}

5. 使用限制

  1. 只能在 <script setup>或 setup( ) 钩子中调用:确保在组件实例被创建时,所有的组合式函数都被正确初始化。特别如果你使用的是选项式 API,那么需要在 setup 方法中调用组合式函数,并且返回,这样才能暴露给 this 及其模板使用

    import { useMouse } from './mouse.js'
    import { useFetch } from './fetch.js'
    
    export default {
      setup() {
        // 因为组合式函数会返回一些状态
        // 为了后面通过 this 能够正确访问到这些数据状态
        // 必须在 setup 的时候调用组合式函数
        const { x, y } = useMouse()
        const { data, error } = useFetch('...')
        return { x, y, data, error }
      },
      mounted() {
        // setup() 暴露的属性可以在通过 `this` 访问到
        console.log(this.x)
      }
      // ...其他选项
    }
    
  2. 只能被同步调用:组合式函数需要同步调用,以确保在组件实例的初始化过程中,所有相关的状态和副作用都能被正确地设置和处理。如果组合式函数被异步调用,可能会导致在组件实例还未完全初始化时,尝试访问未定义的实例数据,从而引发错误。

  3. 可以在像 onMounted 生命周期钩子中调用:在某些情况下,可以在如 onMounted 生命周期钩子中调用组合式函数。这些生命周期钩子也是同步执行的,并且在组件实例已经被初始化后调用,因此可以安全地使用组合式函数。


-EOF-

vue3 上传文件,图片,视频组件

作者 小周同学
2025年12月3日 17:27

上传文件

<!-- eslint-disable vue/multi-word-component-names -->
<template>
  <div class="upload-file">
    <el-upload
      ref="uploadRef"
      :multiple="true"
      :action="uploadFileUrl"
      :before-upload="handleBeforeUpload"
      v-model="fileList"
      :file-list="fileList"
      :limit="limit"
      :on-error="handleUploadError"
      :on-exceed="handleExceed"
      :on-success="handleUploadSuccess"
      :show-file-list="false"
      :headers="headers"
      :auto-upload="true"
      class="upload-file-uploader"
    >
      <!-- 上传按钮 -->
      <el-button type="primary" v-show="isShow">选取文件</el-button>
    </el-upload>
    <!-- 上传提示 -->
    <div class="el-upload__tip" v-if="showTip" v-show="isShow">
      请上传
      <template v-if="fileSize">
        大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
      </template>
      <template v-if="fileType">
        格式为 <b style="color: #f56c6c">{{ fileType.join('/') }}</b>
      </template>
  的文件
</div>
<!-- 文件列表 -->
<transition-group
  class="upload-file-list el-upload-list el-upload-list--text"
  name="el-fade-in-linear"
  tag="ul"
>
  <li
    :key="file.uid"
    class="el-upload-list__item ele-upload-list__item-content"
    v-for="(file, index) in fileList"
  >
    <el-link :underline="false" target="_blank">
      <span class="el-icon-document">
        {{ file.attachmentName }}
      </span>
    </el-link>
    <div class="ele-upload-list__item-content-action">
      <el-link v-show="isShow" :underline="false" @click="handleDelete(index)" type="danger"
        >删除</el-link
      >
      <el-link
        :underline="false"
        type="primary"
        @click="downloadFile(file)"
        v-if="dowloadStatus"
        >下载</el-link
      >
    </div>
  </li>
</transition-group>
<script lang="ts" setup>
import cache from '@/utils/cache';
import { log } from 'console';
import { ElMessage, UploadUserFile } from 'element-plus';
import { ref, computed, watch } from 'vue';
import { Download } from '@element-plus/icons-vue';
import { useUserStore } from '@/stores';
const uploadRef = ref();

const props = defineProps({
  modelValue: [String, Object, Array],
  // 数量限制
  limit: {
    type: Number,
    default: 5
  },
  // 大小限制(MB)
  fileSize: {
    type: Number,
    default: 1100
  },
  // 文件类型, 例如['png', 'jpg', 'jpeg']
  fileType: {
    type: Array,
    default: () => ['doc', 'xls', 'xlsx', 'pdf', 'docx']
  },
  // 是否显示提示
  isShowTip: {
    type: Boolean,
    default: true
  },
  //是否显示删除按钮
  isShow: {
    type: Boolean,
    default: true
  },
  //是否显示下载
  dowloadStatus: {
    type: Boolean,
    default: false
  }
});

// @ts-ignore
const { proxy } = getCurrentInstance();
// eslint-disable-next-line vue/valid-define-emits
const emit = defineEmits();
const number = ref(0);
const uploadFileUrl = import.meta.env.VITE_BASE_API + '/minio/upload'; // 上传文件服务器地址
const headers = ref({
  Authorization: 'Bearer ' + useUserStore().token,
  'bg-debug': 1
});
const fileList = ref<UploadUserFile[]>([]);
const showTip = computed(() => props.isShowTip && (props.fileType || props.fileSize));

watch(
  [() => props.modelValue, () => props.dowloadStatus],
  (val: any) => {
    if (val) {
      fileList.value = props.modelValue;
    }
  },
  { deep: true, immediate: true }
);
// 上传前校检格式和大小
function handleBeforeUpload(file: { name: string; size: number }) {
  // 校检文件类型
  if (props.fileType.length) {
    const fileName = file.name.split('.');
    const fileExt = fileName[fileName.length - 1];
    const isTypeOk = props.fileType.indexOf(fileExt) >= 0;
    if (!isTypeOk) {
      ElMessage.error(`文件格式不正确, 请上传${props.fileType.join('/')}格式文件!`);
      return false;
    }
  }
  // 校检文件大小
  if (props.fileSize) {
    const isLt = file.size / 1024 / 1024 < props.fileSize;
    if (!isLt) {
      ElMessage.error(`上传文件大小不能超过 ${props.fileSize} MB!`);
      return false;
    }
  }
  number.value++;
  return true;
}

//下载文件
const downloadPdf = (data: any) => {
  const fileName = data.attachmentName;
  const fileUrl = data.attachmentPath;
  const request = new XMLHttpRequest();
  request.responseType = 'blob';
  request.open('Get', fileUrl);
  request.onload = () => {
    const url = window.URL.createObjectURL(request.response);
    const a = document.createElement('a');
    document.body.appendChild(a);
    a.href = url;
    a.download = fileName;
    a.click();
  };
  request.send();
};

//下载文件
const downloadFile = (file: { attachmentPath: any; attachmentName: any }) => {
  const lastDotIdx = file.attachmentPath.lastIndexOf('.');
  const type = file.attachmentPath.slice(lastDotIdx + 1).toUpperCase();
  if (type === 'PDF') {
    downloadPdf(file);
  } else {
    const link = document.createElement('a');
    link.href = file.attachmentPath;
    link.download = file.attachmentName;
    document.body.appendChild(link);
    link.click();
  }
};
// 文件个数超出
function handleExceed() {
  ElMessage.error(`上传文件数量不能超过 ${props.limit} 个!`);
}

// 上传失败
function handleUploadError(err: any) {
  ElMessage.error('上传文件失败');
}
/** 文件上传成功处理 */
const handleUploadSuccess: UploadProps['onSuccess'] = (
  response: { data: { url: any } },
  file: { name: any }
) => {
  const newFile = { attachmentName: file.name, attachmentPath: response.data.url };
  fileList.value.push(newFile);
  uploadRef.value.submit();
  emit('update:modelValue', fileList.value);
};
// 删除文件
function handleDelete(index: number) {
  fileList.value.splice(index, 1);
  // @ts-ignore
  emit('update:modelValue', fileList.value);
}
</script>

<style scoped lang="scss">
.upload-file-uploader {
  margin-bottom: 5px;
}
.upload-file-list .el-upload-list__item {
  border: 1px solid #e4e7ed;
  line-height: 2;
  margin-bottom: 10px;
  position: relative;
}
.upload-file-list .ele-upload-list__item-content {
  display: flex;
  justify-content: space-between;
  align-items: center;
  color: inherit;
}
.ele-upload-list__item-content-action .el-link {
  margin-right: 10px;
  margin-left: 20px;
}
.ele-upload-list__item-content-action .el-icon {
  margin-right: 10px;
  margin-top: 10px;
}
</style>

上传图片

<template>
  <div class="pro-upload-img-box">
    <div class="pro-upload-img-content">
      <!-- 已上传图片列表 -->
      <div
        class="upload-img-card"
        v-for="(item, index) in fileList"
        :key="index"
      >
        <!-- 图片预览 -->
        <el-image
          class="img-sty"
          :preview-src-list="[item.url]"
          fit="cover"
          :src="item.url"
          alt=""
        />
        <!-- 删除按钮 -->
        <el-image
          v-if="!disabled"
          src="https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/img-close.png"
          class="img-close"
          @click="handleRemove(item, index)"
        />
        <!-- 图片遮罩层 -->
        <div class="img-mask">
          <el-image
            src="https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/img-preview.png"
            class="img-preview"
          />
        </div>
      </div>
      <!-- 上传组件 -->
      <el-upload
        v-loading="loading"
        ref="proUploadImgRef"
        :class="['pro-upload-img', { 'is-disabled': disabled }]"
        v-bind="uploadProps"
        :before-upload="beforeUpload"
        :on-success="handleSuccess"
        :on-error="handleError"
        :on-exceed="handleExceed"
      >
        <slot>
          <div class="upload-card">
            <el-icon class="upload-icon" style="font-size: 30px;">
              <CirclePlus  />
            </el-icon>
            <div v-if="uploadText" class="upload-text">
              {{ uploadText }}
            </div>
          </div>
        </slot>
      </el-upload>
    </div>
    <!-- 提示信息 -->
    <slot name="tip">
      <div class="upload-tip" v-if="tip">
        {{ tip }}
      </div>
    </slot>
  </div>
</template>

<script setup name="ProUploadImg">
  import { ref, computed } from 'vue';
  import { Plus } from '@element-plus/icons-vue';
  import { ElMessage } from 'element-plus';

  // Props 定义
  const props = defineProps({
    /** 上传地址 */
    action: {
      type: String,
      required: true,
    },
    /** 请求头 */
    headers: {
      type: Object,
      default: () => ({}),
    },
    /** 是否支持多选 */
    multiple: {
      type: Boolean,
      default: false,
    },
    /** 最大上传数量,0表示不限制 */
    limit: {
      type: Number,
      default: 0,
    },
    /** 接受的文件类型,如:.jpg,.png,.jpeg */
    accept: {
      type: String,
      default: '.jpg,.png,.jpeg',
    },
    /** 文件大小限制 */
    maxSize: {
      type: Number,
      default: 0,
    },
    /** 文件大小单位(KB/MB) */
    sizeUnit: {
      type: String,
      default: 'MB',
      validator: (value) => ['KB', 'MB'].includes(value),
    },
    /** 图片宽度限制 */
    width: {
      type: Number,
      default: 0,
    },
    /** 图片高度限制 */
    height: {
      type: Number,
      default: 0,
    },
    /** 上传提示文字 */
    uploadText: {
      type: String,
      default: '点击上传',
    },
    /** 上传提示说明 */
    tip: {
      type: String,
      default: '',
    },
    /** 是否禁用 */
    disabled: {
      type: Boolean,
      default: false,
    },
  });
  /** 初始文件列表 */
  const fileList = defineModel('fileList', {
    type: Array,
    default: () => [],
  });

  // 事件定义
  const emit = defineEmits(['success', 'error', 'exceed', 'remove']);

  const proUploadImgRef = ref();
  const loading = ref(false);

  const uploadProps = computed(() => ({
    action: props.action,
    accept: props.accept,
    limit: props.limit,
    multiple: props.multiple,
    listType: 'picture-card',
    showFileList: false,
    headers: props.headers,
    fileList: fileList.value,
    disabled: props.disabled,
  }));

  /**
   * 验证图片尺寸是否符合要求
   * @param {number} width - 图片宽度
   * @param {number} height - 图片高度
   * @returns {boolean} 是否符合要求
   */
  const validateImageSize = (width, height) => {
    if (props.width && props.height) {
      return width === props.width && height === props.height;
    }
    if (props.width) {
      return width === props.width;
    }
    if (props.height) {
      return height === props.height;
    }
    return true;
  };

  /**
   * 上传前校验
   * @param {File} file - 待上传的文件
   * @returns {Promise<boolean>} 是否通过校验
   */
  const beforeUpload = async (file) => {
    // 校验文件类型
    const fileTypeList = props.accept
      .split(',')
      .map((item) => item.replace('.', ''));
    const fileType = file.name.split('.').pop();

    if (!fileTypeList.includes(fileType)) {
      ElMessage({
        message: `仅支持 ${fileTypeList.join('、')} 格式`,
        type: 'warning',
      });
      return false;
    }

    // 校验文件大小
    if (props.maxSize) {
      const fileSize = file.size / 1024;
      const maxSizeInKB =
        props.sizeUnit === 'MB' ? props.maxSize * 1024 : props.maxSize;
      if (fileSize > maxSizeInKB) {
        ElMessage({
          message: `大小不能超过 ${props.maxSize}${props.sizeUnit}!`,
          type: 'warning',
        });
        return false;
      }
    }

    // 校验图片尺寸
    // return new Promise((resolve, reject) => {
    //   const img = new Image();
    //   img.src = URL.createObjectURL(file);
    //   img.onload = () => {
    //     URL.revokeObjectURL(img.src);
    //     const { width, height } = img;

    //     if (!validateImageSize(width, height)) {
    //       const message =
    //         props.width && props.height
    //           ? `图片尺寸必须为 ${props.width}x${props.height}`
    //           : props.width
    //             ? `图片宽度必须为 ${props.width}px`
    //             : `图片高度必须为 ${props.height}px`;

    //       ElMessage({
    //         message,
    //         type: 'warning',
    //       });
    //       reject(false);
    //       return;
    //     }
    //     loading.value = true;
    //     resolve(true);
    //   };
    //   img.onerror = () => {
    //     URL.revokeObjectURL(img.src);
    //     ElMessage({
    //       message: '图片加载失败',
    //       type: 'error',
    //     });
    //     reject(false);
    //   };
    // });
  };

  /**
   * 上传成功回调
   * @param {Object} response - 服务器响应数据
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleSuccess = (response, uploadFile, uploadFiles) => {
    console.log(response, uploadFile, uploadFiles,12345666)
    loading.value = false;
    if (response.code === 200) {
      fileList.value.push({ url: response.data.url });
      console.log(fileList.value,12345)
    } else {
      proUploadImgRef.value.handleRemove(uploadFile);
      ElMessage({
        message: response.msg || response.message || '上传失败',
        type: 'error',
      });
    }
    emit('success', response, uploadFile, uploadFiles);
  };

  /**
   * 上传失败回调
   * @param {Error} error - 错误信息
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleError = (error, uploadFile, uploadFiles) => {
    loading.value = false;
    ElMessage({
      message: '上传失败',
      type: 'error',
    });
    emit('error', error, uploadFile, uploadFiles);
  };

  /**
   * 超出限制回调
   * @param {Array} files - 超出限制的文件列表
   * @param {Array} uploadFiles - 已上传的文件列表
   */
  const handleExceed = (files, uploadFiles) => {
    ElMessage({
      message: `最多只能上传 ${props.limit} 张图片`,
      type: 'warning',
    });
    emit('exceed', files, uploadFiles);
  };

  /**
   * 移除图片
   * @param {Object} file - 要移除的文件对象
   * @param {number} index - 文件索引
   */
  const handleRemove = (file, index) => {
    fileList.value.splice(index, 1);
    proUploadImgRef.value.handleRemove(file);
    emit('remove', file);
  };
</script>

<style lang="scss" scoped>
.pro-upload-img-box {
  .pro-upload-img-content {
    display: flex;
    flex-wrap: wrap;
    // 已上传图片卡片样式
    .upload-img-card {
      width: 100px;
      height: 100px;
      position: relative;
      margin: 0 12px 12px 0;
      // 图片样式
      .img-sty {
        width: 100%;
        height: 100%;
        overflow: hidden;
        border-radius: 6px;
      }
      // 删除按钮样式
      .img-close {
        position: absolute;
        right: -6px;
        top: -6px;
        width: 20px;
        height: 20px;
        cursor: pointer;
        z-index: 2;
      }
      // 遮罩层样式
      .img-mask {
        background: rgba(0, 0, 0, 0.3);
        border-radius: 6px;
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        pointer-events: none;

        .img-preview {
          position: absolute;
          right: 8px;
          bottom: 8px;
          width: 20px;
          height: 20px;
          pointer-events: none;
        }
      }
    }

    // 禁用状态样式
    .is-disabled {
      :deep(.el-upload--picture-card) {
        cursor: not-allowed;
      }
    }
    // 上传按钮样式
    .pro-upload-img {
      margin-bottom: 12px;
      .upload-card {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        .upload-icon {
          font-size: 20px;
          color: #333;
          text-align: center;
          line-height: 100px;
        }

        .upload-text {
          line-height: 24px;
          color: #333;
          font-size: 14px;
          text-align: center;
          margin-top: 10px;
        }
      }
    }

    // 上传组件样式覆盖
    :deep(.el-upload--picture-card) {
      width: 100px;
      height: 100px;
      background-color: #F8F8F9;
    }
    :deep(.el-upload-list__item) {
      width: auto;
      height: auto;
      overflow: visible;
    }
  }
  // 提示文字样式
  .upload-tip {
    font-size: 12px;
    color: #909399;
  }
}
</style>

上传视频

<template>
  <div class="pro-upload-video-box">
    <div class="pro-upload-video-content">
      <!-- 已上传视频列表 -->
      <div
        class="upload-video-card"
        v-for="(item, index) in fileList"
        :key="index"
      >
        <!-- 视频缩略图/播放按钮 -->
        <div class="video-thumbnail" @click="playVideo(item.url)">
          <el-icon class="video-icon"><VideoPlay /></el-icon>
        </div>
        <!-- 视频信息 -->
        <div class="video-info" @click="playVideo(item.url)">
          <div class="video-name">{{ getFileName(item.url) }}</div>
          <div class="video-size">{{ getFileSize(item.size) }}</div>
        </div>
        <!-- 删除按钮 -->
        <el-image
          v-if="!disabled"
          src="https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/img-close.png"
          class="video-close"
          @click="handleRemove(item, index)"
        />
      </div>
      <!-- 上传组件 -->
      <el-upload
        v-if="!disabled"
        v-loading="loading"
        ref="proUploadVideoRef"
        :class="['pro-upload-video', { 'is-disabled': disabled }]"
        v-bind="uploadProps"
        :before-upload="beforeUpload"
        :on-success="handleSuccess"
        :on-error="handleError"
        :on-exceed="handleExceed"
        :on-progress="handleProgress"
      >
        <slot>
          <div class="upload-card">
            <el-icon class="upload-icon">
              <Plus />
            </el-icon>
            <div v-if="uploadText" class="upload-text">
              {{ uploadText }}
            </div>
          </div>
        </slot>
      </el-upload>
    </div>
    <!-- 提示信息 -->
    <slot name="tip"  v-if="!disabled">
      <div class="upload-tip" v-if="tip">
        {{ tip }}
      </div>
    </slot>
  </div>
</template>

<script setup name="ProUploadVideo">
  import { ref, computed } from 'vue';
  import { Plus, VideoPlay } from '@element-plus/icons-vue';
  import { ElMessage } from 'element-plus';

  // Props 定义
  const props = defineProps({
    /** 上传地址 */
    action: {
      type: String,
      required: true,
    },
    /** 请求头 */
    headers: {
      type: Object,
      default: () => ({}),
    },
    /** 是否支持多选 */
    multiple: {
      type: Boolean,
      default: false,
    },
    /** 最大上传数量,0表示不限制 */
    limit: {
      type: Number,
      default: 0,
    },
    /** 接受的文件类型,如:.mp4,.avi,.mov */
    accept: {
      type: String,
      default: '.mp4,.avi,.mov,.wmv,.flv,.webm',
    },
    /** 文件大小限制 */
    maxSize: {
      type: Number,
      default: 0,
    },
    /** 文件大小单位(KB/MB) */
    sizeUnit: {
      type: String,
      default: 'MB',
      validator: (value) => ['KB', 'MB'].includes(value),
    },
    /** 上传提示文字 */
    uploadText: {
      type: String,
      default: '上传视频',
    },
    /** 上传提示说明 */
    tip: {
      type: String,
      default: '',
    },
    /** 是否禁用 */
    disabled: {
      type: Boolean,
      default: false,
    },
  });
  /** 初始文件列表 */
  const fileList = defineModel('fileList', {
    type: Array,
    default: () => [],
  });

  // 事件定义
  const emit = defineEmits(['success', 'error', 'exceed', 'remove', 'deleteAnnex', 'progress']);

  const proUploadVideoRef = ref();
  const loading = ref(false);

  const uploadProps = computed(() => ({
    action: props.action,
    accept: props.accept,
    limit: props.limit,
    multiple: props.multiple,
    listType: 'text',
    showFileList: false,
    headers: props.headers,
    fileList: fileList.value,
    disabled: props.disabled,
  }));

  /**
   * 获取文件名
   * @param {string} url - 文件路径
   * @returns {string} 文件名
   */
  const getFileName = (url) => {
    if (!url) return '';
    const fileName = url.substring(url.lastIndexOf('/') + 1);
    return fileName.length > 15 ? fileName.substring(0, 15) + '...' : fileName;
  };

  /**
   * 获取文件大小显示
   * @param {number} size - 文件大小(字节)
   * @returns {string} 格式化后的文件大小
   */
  const getFileSize = (size) => {
    if (!size) return '';
    const units = ['B', 'KB', 'MB', 'GB'];
    let unitIndex = 0;
    let fileSize = size;

    while (fileSize >= 1024 && unitIndex < units.length - 1) {
      fileSize /= 1024;
      unitIndex++;
    }

    return `${fileSize.toFixed(2)} ${units[unitIndex]}`;
  };

  /**
   * 上传前校验
   * @param {File} file - 待上传的文件
   * @returns {Promise<boolean>} 是否通过校验
   */
  const beforeUpload = async (file) => {
    // 校验文件类型
    const fileTypeList = props.accept
      .split(',')
      .map((item) => item.replace('.', '').toLowerCase());
    const fileType = file.name.split('.').pop().toLowerCase();

    if (!fileTypeList.includes(fileType)) {
      ElMessage({
        message: `仅支持 ${props.accept} 格式`,
        type: 'warning',
      });
      return false;
    }

    // 校验文件大小
    if (props.maxSize) {
      const fileSize = file.size;
      const maxSizeInBytes =
        props.sizeUnit === 'MB' ? props.maxSize * 1024 * 1024 : props.maxSize * 1024;
      if (fileSize > maxSizeInBytes) {
        ElMessage({
          message: `大小不能超过 ${props.maxSize}${props.sizeUnit}!`,
          type: 'warning',
        });
        return false;
      }
    }

    loading.value = true;
    return true;
  };

  /**
   * 上传进度回调
   * @param {Object} event - 进度事件对象
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleProgress = (event, uploadFile, uploadFiles) => {
    emit('progress', event, uploadFile, uploadFiles);
  };

  /**
   * 上传成功回调
   * @param {Object} response - 服务器响应数据
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleSuccess = (response, uploadFile, uploadFiles) => {
    loading.value = false;
    if (response.code === 200) {
      fileList.value.push({
        url: response.data.url,
        name: uploadFile.name,
        size: uploadFile.size
      });
    } else {
      proUploadVideoRef.value.handleRemove(uploadFile);
      ElMessage({
        message: response.msg || response.message || '上传失败',
        type: 'error',
      });
    }
    emit('success', response, uploadFile, uploadFiles);
  };

  /**
   * 上传失败回调
   * @param {Error} error - 错误信息
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleError = (error, uploadFile, uploadFiles) => {
    loading.value = false;
    ElMessage({
      message: '上传失败',
      type: 'error',
    });
    emit('error', error, uploadFile, uploadFiles);
  };

  /**
   * 超出限制回调
   * @param {Array} files - 超出限制的文件列表
   * @param {Array} uploadFiles - 已上传的文件列表
   */
  const handleExceed = (files, uploadFiles) => {
    ElMessage({
      message: `最多只能上传 ${props.limit} 个视频`,
      type: 'warning',
    });
    emit('exceed', files, uploadFiles);
  };

  /**
   * 移除视频
   * @param {Object} file - 要移除的文件对象
   * @param {number} index - 文件索引
   */
  const handleRemove = (file, index) => {
    fileList.value.splice(index, 1);
    proUploadVideoRef.value.handleRemove(file);
    emit('deleteAnnex', index);
  };

  /**
   * 播放视频
   * @param {string} url - 视频地址
   */
  const playVideo = (url) => {
    if (!url) return;

    // 创建视频播放弹窗
    const videoDialog = document.createElement('div');
    videoDialog.style.cssText = `
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(0, 0, 0, 0.9);
      display: flex;
      justify-content: center;
      align-items: center;
      z-index: 9999;
    `;

    // 创建视频元素
    const videoWrapper = document.createElement('div');
    videoWrapper.style.cssText = `
      position: relative;
      max-width: 90%;
      max-height: 90%;
    `;

    // 创建加载提示
    const loadingIndicator = document.createElement('div');
    loadingIndicator.innerHTML = '视频加载中...';
    loadingIndicator.style.cssText = `
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      color: white;
      font-size: 16px;
      z-index: 1;
    `;
    videoWrapper.appendChild(loadingIndicator);

    const videoElement = document.createElement('video');
    videoElement.controls = true;
    videoElement.autoplay = true;
    videoElement.style.cssText = `
      max-width: 100%;
      max-height: 80vh;
      outline: none;
      background: black;
      display: none; /* 初始隐藏,等待加载完成后再显示 */
    `;

    // 尝试多种视频格式
    const fileExtension = url.split('.').pop().toLowerCase();
    const sourceElement = document.createElement('source');
    sourceElement.src = url;

    // 根据文件扩展名设置正确的 MIME 类型
    const mimeTypes = {
      'mp4': 'video/mp4',
      'webm': 'video/webm',
      'ogg': 'video/ogg',
      'avi': 'video/avi',
      'mov': 'video/quicktime',
      'wmv': 'video/x-ms-wmv',
      'flv': 'video/x-flv'
    };

    sourceElement.type = mimeTypes[fileExtension] || 'video/mp4';
    videoElement.appendChild(sourceElement);

    // 视频加载成功的处理
    videoElement.onloadeddata = () => {
      // 隐藏加载指示器并显示视频
      if (videoWrapper.contains(loadingIndicator)) {
        videoWrapper.removeChild(loadingIndicator);
      }
      videoElement.style.display = 'block';
    };

    // 视频加载失败的处理
    videoElement.onerror = () => {
      // 隐藏加载指示器
      if (videoWrapper.contains(loadingIndicator)) {
        videoWrapper.removeChild(loadingIndicator);
      }

      // 显示错误信息
      const errorIndicator = document.createElement('div');
      errorIndicator.innerHTML = '视频加载失败,请稍后重试';
      errorIndicator.style.cssText = `
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        color: #ff6b6b;
        font-size: 16px;
        background: rgba(0, 0, 0, 0.7);
        padding: 10px 20px;
        border-radius: 4px;
        z-index: 1;
      `;
      videoWrapper.appendChild(errorIndicator);

      // 3秒后自动关闭
      setTimeout(() => {
        if (document.body.contains(videoDialog)) {
          document.body.removeChild(videoDialog);
        }
      }, 3000);
    };

    // 创建关闭按钮
    const closeButton = document.createElement('button');
    closeButton.innerHTML = '&times;';
    closeButton.style.cssText = `
      position: absolute;
      top: -40px;
      right: 0;
      background: transparent;
      border: none;
      color: white;
      font-size: 36px;
      cursor: pointer;
      width: 40px;
      height: 40px;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: transform 0.2s;
    `;

    closeButton.onmouseover = () => {
      closeButton.style.transform = 'scale(1.1)';
    };

    closeButton.onmouseout = () => {
      closeButton.style.transform = 'scale(1)';
    };

    closeButton.onclick = () => {
      // 暂停视频并移除弹窗
      videoElement.pause();
      if (document.body.contains(videoDialog)) {
        document.body.removeChild(videoDialog);
      }
    };

    videoWrapper.appendChild(videoElement);
    videoWrapper.appendChild(closeButton);
    videoDialog.appendChild(videoWrapper);
    document.body.appendChild(videoDialog);

    // 点击背景关闭
    videoDialog.onclick = (e) => {
      if (e.target === videoDialog) {
        videoElement.pause();
        if (document.body.contains(videoDialog)) {
          document.body.removeChild(videoDialog);
        }
      }
    };

    // ESC键关闭
    const handleEscKey = (e) => {
      if (e.key === 'Escape') {
        videoElement.pause();
        if (document.body.contains(videoDialog)) {
          document.body.removeChild(videoDialog);
        }
        document.removeEventListener('keydown', handleEscKey);
      }
    };

    document.addEventListener('keydown', handleEscKey);
  };
</script>

<style lang="scss" scoped>
.pro-upload-video-box {
  .pro-upload-video-content {
    display: flex;
    // flex-wrap: wrap;
    // 已上传视频卡片样式
    .upload-video-card {
      width: 100%;
      max-width: 300px;
      height: 100px;
      position: relative;
      margin: 0 12px 12px 0;
      display: flex;
      align-items: center;
      border: 1px solid #ebeef5;
      border-radius: 6px;
      padding: 10px;
      box-sizing: border-box;

      // 视频缩略图样式
      .video-thumbnail {
        width: 50px;
        height: 50px;
        background-color: #ecf5ff;
        border-radius: 6px;
        display: flex;
        align-items: center;
        justify-content: center;
        margin-right: 10px;
        cursor: pointer;
        transition: all 0.3s;

        &:hover {
          background-color: #409eff;
          .video-icon {
            color: white;
          }
        }

        .video-icon {
          font-size: 24px;
          color: #409eff;
        }
      }

      // 视频信息样式
      .video-info {
        flex: 1;
        min-width: 0;
        cursor: pointer;

        .video-name {
          font-size: 14px;
          color: #606266;
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
          margin-bottom: 5px;
        }

        .video-size {
          font-size: 12px;
          color: #909399;
        }
      }

      // 删除按钮样式
      .video-close {
        position: absolute;
        right: -8px;
        top: -8px;
        width: 20px;
        height: 20px;
        cursor: pointer;
        z-index: 2;
      }
    }

    // 禁用状态样式
    .is-disabled {
      :deep(.el-upload--text) {
        cursor: not-allowed;
      }
    }
    // 上传按钮样式
    .pro-upload-video {
      margin-bottom: 12px;
      .upload-card {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        width: 100px;
        height: 100px;
        border: 1px dashed #d9d9d9;
        border-radius: 6px;
        cursor: pointer;
        transition: border-color 0.3s;

        &:hover {
          border-color: #409eff;
        }

        .upload-icon {
          font-size: 28px;
          color: #8c939d;
          margin-bottom: 5px;
        }

        .upload-text {
          line-height: 24px;
          color: #8c939d;
          font-size: 14px;
          text-align: center;
        }
      }
    }

    // 上传组件样式覆盖
    :deep(.el-upload) {
      width: auto;
      height: auto;
    }
  }
  // 提示文字样式
  .upload-tip {
    font-size: 12px;
    color: #909399;
  }
}
</style>

一次uniapp问题排查

2025年12月2日 14:47

最近在开发uniapp,虽然第一次写,但是有vue开发经验,也没觉得有啥不一样的,上手还是比较简单的,但是明显我还是低估了uniapp的复杂度,这东西难点不在于业务开发,而是各个不同机型的适配,相比于传统的web开发,还是繁琐了很多。

业务开发中遇到了一个蛮有趣的问题,这里总结下。

背景与环境

首先简单的描述下背景:就是在app登出的时候,发现了苹果6Plus在退出登录时表现不一样的地方,H5和安卓以及苹果16、ipad都表现正常,就是点击退出登录按钮,弹出一个弹窗,点击退出登录,跳转到到登录页,很常规的一个操作了,但是在苹果6Plus机型上,出现了异常,弹窗点击不动,页面出现了卡死,只能杀死应用重新进入。

这里贴下相关代码:

message.comform({
    msg:"确定退出登录嘛",
    title:"退出登录"
}).then(res => {
    await userStore.logout()
    uni.redirectTo({
        url:'/pages/login/index'
    })
})

这里就是清除下userStore中的状态,然后就跳转路由到登录页。

这里总结下uniapp路由跳转的方式

  1. uni.navigateTo 保留当掐你页面,跳转到某个应用内的页面
// 带参数跳转
uni.navigateTo({
  url: '/pages/detail/detail?id=123&name=test'
})

// 对象参数(需要编码)
uni.navigateTo({
  url: '/pages/detail/detail?data=' + encodeURIComponent(JSON.stringify({
    id: 123,
    name: 'test'
  }))
})
  1. uni.redirectTo 关闭当前页面 跳转到应用内的某个页面
uni.redirectTo({
  url: '/pages/home/home'
})
  1. uni.reLaunch 关闭所有页面,打开到应用内的某个页面
uni.reLaunch({
  url: '/pages/index/index'
})
  1. uni.switchTab 跳转到tabBar页面,并关闭其他所有非tabBar页面
uni.switchTab({
  url: '/pages/home/home'
})
  1. uni.navigateBack 返回上一级或者多级页面
// 返回上一页
uni.navigateBack()

// 返回多级页面
uni.navigateBack({
  delta: 2  // 返回2级
})

其中logout也是比较简单了,代码如下:

const layout = async () => { 
    removeToken()
    resetState()
}

也就是登录前移除token和store中的一些状态,看代码没有任何问题,但是就是在苹果6Plus中出现卡顿,很费解,光看代码看不出任何问题,就带着问题问了下gpt,知道了WKWebView 导航队列锁这个东西。

WKWebView 导航队列锁

WKWebView 导航队列锁定是 WebKit 内核中的一个线程安全机制,旨在防止在页面加载过程中的竞态条件。但在老版本中,这个机制实现得过于保守,导致并发导航操作容易被阻塞。

主要有这么几个场景会触发

  1. 快速连续导航调用

    // 微观时序问题 - 导航竞争
    const startTime = performance.now();
    
    // 导航请求1 - 第0ms
    uni.navigateTo({ url: '/pageA' });
    
    // 在导航1还未完成状态转换时...
    setTimeout(() => {
        // 导航请求2 - 第5ms (此时导航1可能还在 WKNavigationStateScheduled)
        uni.navigateTo({ url: '/pageB' });
    }, 5);
    
  2. 资源加载与导航竞争

    // WebKit 内部资源加载时序
    - (void)startNavigation:(WKNavigation *)navigation {
        [self acquireNavigationLock]; // 获取导航锁
        
        // 开始加载主文档
        [self loadMainDocument];
        
        // 此时如果主文档中有同步资源请求
        // <script src="sync-script.js"></script>
        // 资源加载会阻塞导航锁释放
        
        [self releaseNavigationLock]; // 延迟释放!
    }
    
  3. js桥接与导航交互

    // uni-app 框架层可能的问题
    // 1. 页面生命周期钩子与导航竞争
    export default {
        onLoad() {
            // 在 onLoad 中执行耗时操作
            this.loadHeavyData(); // 阻塞导航完成
        },
        
        onShow() {
            // 触发 UI 更新,需要渲染锁
            this.startAnimation(); 
        }
    }
    
    

    这个实例中,onLoad的触发时机是页面首次创建时 , onShow触发是页面显示的时候会触发,当onLoad中执行loadHeavyData耗时操作时,等页面显示执行onShow中的startAnimation就会导致竞争资源,就会触发队列锁,导致页面卡死,比较好的方法就是让他们再一个方法中执行,然后将他们的步骤拆分下,比如这样:

    onLoad() {
        // 阶段1: 立即执行 (导航锁持有期间)
        this.initUIState();
    
        // 阶段2: 延迟执行 (导航锁释放后)
        this.deferHeavyTask();
    
        // 阶段3: 空闲时执行
        this.idleNonCriticalTask();
    },
    
  4. css、js动画与js冲突

    function startAnimation() {
        const element = document.querySelector('.animated');
        element.style.transform = 'translateX(0)';
        
        // 触发 CSS 动画
        requestAnimationFrame(() => {
            element.style.transform = 'translateX(100px)';
            
            // 在同一帧内触发导航
            uni.navigateTo({ url: '/next' }); // 危险!
        });
    }
    

然后回到项目相关代码,似乎也没有很大的计算量导致资源竞争,触发队列锁,看下gpt5优化后的代码:

const clicking = ref(false)
const handleLogout = async () => {
    if(clicking.value) return
    click.value = false
    await message.confirm({msg:'确定退出登录?',title:'退出登录'})
    useStore.logOut()
    setTimeout(() => clicking.value=false,300)
}
const delay = ms => new Promise(r => setTimeout(r,ms))
const loggingOut = ref(false)
const LogOut = async () => {
    if(loggingOut.value) return
    loggingOut.value = true
    removeToken()
    resetState()
    uni.$emit?.('router:unlock')
    uni.$emit?.('ui:closs-popups')
    
    await delay(220)
    
    try {uni.reLaunch({url:'/pages/login/index'}) } catch {}
    setTimeout(() => try{uni.relaunch({url:'/pages/login/index'}) catch {}, 700)
    
    loggingOut.value = false
}

看了下代码,似乎也没有啥变更,只是都加了个开关,然后加了个延时操作,但是问题确实解决了。确实非常奇怪的问题。

前端组件二次封装实战:Vue+React基于Element UI/AntD的高效封装策略

作者 鹏多多
2025年12月2日 08:39

在中后台项目开发中,Element UI(Vue)和Ant Design(AntD,React)是主流的组件库,但原生组件往往无法直接适配业务场景,比如:统一的表单校验规则、标准化的表格交互、个性化的弹窗样式等。此时,基于组件库的二次封装成为平衡开发效率、代码复用与团队规范的核心手段。我将围绕何时封装为何封装如何封装,三个核心问题,聚焦Element UI/AntD的二次封装技巧,结合Vue 3和React 18的实战案例,拆解高效且易扩展的封装方法论。

1. 什么时候值得封装一个组件

组件封装不是“为了封装而封装”,当满足以下场景时,二次封装的收益远大于成本:

1.1. 重复场景出现时:减少复制粘贴

当同一类UI/交互在2个及以上模块出现(如Element UI的Table+分页、AntD的Form+搜索按钮),且仅参数不同,封装可避免重复代码。

  • 示例:多个列表页都用Element UI的Table,且都需要“分页+多选+操作列”,封装BaseTable组件统一逻辑。

1.2. 业务规则需统一时:规避风格混乱

当组件需要遵循统一的业务规则(如按钮权限控制、日期格式渲染、表单校验提示),封装可收口规则。

  • 示例:AntD的Button需根据用户角色控制显示/禁用,封装AuthButton统一处理权限逻辑,所有页面复用。

1.3. 原生组件能力不足时:补齐个性化需求

Element UI/AntD的通用能力无法覆盖业务场景(如Element UI的Dialog需拖拽、AntD的Select需最多显示3个多选标签),二次封装可定制化扩展。

1.4. 逻辑与UI耦合复杂时:降低维护成本

当一个功能包含“数据请求+交互逻辑+样式定制”(如带远程搜索的部门选择器),封装可拆分复杂逻辑,符合单一职责原则。

2. 封装组件的核心目的

降本提效:一次封装,多处复用。后续需求变更(如表格分页样式调整),只需修改封装组件,所有引用处自动生效,无需逐个页面修改。

逻辑内聚:高内聚、低耦合。将业务逻辑(如数据请求、校验规则)封装在组件内部,页面只需关注“传参”和“接收结果”,降低代码耦合度。

扩展灵活:适配未来业务变化。预留扩展接口,新增需求(如表格新增导出功能)时,仅需扩展组件内部,不影响外部调用方式。

统一标准:对齐团队开发规范。避免不同开发者对Element UI/AntD的定制方式不一致(如按钮尺寸、表单间距),保证项目风格统一。

3. Element UI/AntD二次封装核心技巧:透传原生Props

二次封装的关键是“不丢失原生组件的能力”——即让封装后的组件能隐式传递原生组件的所有Props、事件和样式,同时新增业务逻辑。以下分Vue(Element Plus)和React(AntD)讲解核心实现方式。

核心概念:透传的本质

  • Vue:通过v-bind="$attrs"透传Props,v-on="$listeners"(Vue 3已合并到$attrs)透传事件,inheritAttrs: false避免属性透传到根元素。
  • React:通过扩展运算符{...props}透传所有Props,通过children透传子元素,区分“业务Props”和“原生Props”。

3.1. Vue 3 + Element Plus 二次封装实战

以封装BaseDialog(基于ElDialog)为例,实现“拖拽+默认样式+透传原生Props”:

步骤1:基础封装(透传原生Props)

<template>
  <!-- 根元素禁用属性继承,避免$attrs透传到div -->
  <div class="base-dialog">
    <el-dialog
      v-bind="$attrs" <!-- 透传ElDialog的所有原生Props(如title、visible、width) -->
      :close-on-click-modal="false" <!-- 业务默认值,可被外部Props覆盖 -->
      @close="handleClose" <!-- 内部处理基础事件,也可透传外部事件 -->
      class="base-dialog__inner"
    >
      <!-- 插槽:透传ElDialog的默认插槽 -->
      <slot />
      <!-- 插槽:自定义底部按钮 -->
      <template #footer>
        <slot name="footer">
          <!-- 默认底部按钮 -->
          <el-button @click="handleCancel">取消</el-button>
          <el-button type="primary" @click="handleConfirm">确认</el-button>
        </slot>
      </template>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ElDialog, ElButton, ElMessage } from 'element-plus';
// 引入拖拽指令(可选,扩展功能)
import { vDialogDrag } from '@/directives/dialogDrag';

// 禁用根元素的属性继承,确保$attrs只透传给ElDialog
defineOptions({
  inheritAttrs: false
});

// 定义业务Props(与原生Props区分)
const props = defineProps<{
  // 业务自定义Props,非ElDialog原生属性
  confirmText?: string;
  cancelText?: string;
}>();

// 定义事件:透传原生事件 + 自定义业务事件
const emit = defineEmits<{
  (e: 'confirm'): void; // 自定义确认事件
  (e: 'cancel'): void; // 自定义取消事件
  (e: 'close'): void; // 透传ElDialog的close事件
}>();

// 内部处理确认逻辑
const handleConfirm = () => {
  emit('confirm');
  // 可扩展:统一的确认提示
  ElMessage.success('操作成功');
};

// 内部处理取消逻辑
const handleCancel = () => {
  emit('cancel');
  // 触发ElDialog的关闭(通过透传的visible属性由外部控制)
  emit('close');
};

// 透传ElDialog的close事件
const handleClose = () => {
  emit('close');
};
</script>

<style scoped>
.base-dialog {
  --el-dialog-width: 600px; /* 自定义默认宽度,可被外部覆盖 */
}
.base-dialog__inner :deep(.el-dialog__header) {
  padding: 16px 20px;
  border-bottom: 1px solid #eee;
}
</style>

步骤2:指令扩展(拖拽功能)

// src/directives/dialogDrag.ts
import type { Directive } from 'vue';

export const vDialogDrag: Directive = {
  mounted(el) {
    const dialogHeaderEl = el.querySelector('.el-dialog__header');
    const dragDom = el.querySelector('.el-dialog') as HTMLElement;
    if (!dialogHeaderEl || !dragDom) return;

    // 设置拖拽元素可拖动
    dialogHeaderEl.style.cursor = 'move';
    dialogHeaderEl.addEventListener('mousedown', (e) => {
      // 鼠标按下,计算当前元素距离可视区的距离
      const disX = e.clientX - dialogHeaderEl.offsetLeft;
      const disY = e.clientY - dialogHeaderEl.offsetTop;
      const dragDomWidth = dragDom.offsetWidth;
      const dragDomHeight = dragDom.offsetHeight;
      const screenWidth = document.body.clientWidth;
      const screenHeight = document.body.clientHeight;

      // 最大移动距离
      const maxX = screenWidth - dragDomWidth;
      const maxY = screenHeight - dragDomHeight;

      // 鼠标移动事件
      const moveFn = (e: MouseEvent) => {
        let left = e.clientX - disX;
        let top = e.clientY - disY;

        // 边界处理
        if (left < 0) left = 0;
        if (left > maxX) left = maxX;
        if (top < 0) top = 0;
        if (top > maxY) top = maxY;

        dragDom.style.left = `${left}px`;
        dragDom.style.top = `${top}px`;
      };

      // 鼠标松开事件
      const upFn = () => {
        document.removeEventListener('mousemove', moveFn);
        document.removeEventListener('mouseup', upFn);
      };

      document.addEventListener('mousemove', moveFn);
      document.addEventListener('mouseup', upFn);
    });
  },
};

步骤3:父组件调用(透传原生Props + 扩展)

<template>
  <el-button @click="dialogVisible = true">打开弹窗</el-button>
  
  <!-- 调用封装后的BaseDialog,可透传ElDialog所有原生Props -->
  <BaseDialog
    v-model="dialogVisible" <!-- 透传ElDialog的visible属性(v-model语法糖) -->
    title="自定义弹窗"
    width="800px" <!-- 覆盖默认宽度 -->
    confirm-text="提交" <!-- 自定义业务Props -->
    @confirm="handleConfirm"
    @close="handleClose"
  >
    <div>弹窗内容</div>
    <!-- 自定义底部按钮(覆盖默认插槽) -->
    <template #footer>
      <el-button @click="dialogVisible = false">取消</el-button>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
    </template>
  </BaseDialog>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import BaseDialog from './components/BaseDialog.vue';

const dialogVisible = ref(false);

const handleConfirm = () => {
  console.log('确认');
  dialogVisible.value = false;
};

const handleClose = () => {
  console.log('关闭');
};

const handleSubmit = () => {
  console.log('自定义提交');
  dialogVisible.value = false;
};
</script>

3.2. React 18 + AntD 二次封装实战

以封装BaseTable(基于AntD的Table)为例,实现“分页封装+透传原生Props+统一操作列”:

步骤1:基础封装(区分业务Props与原生Props)

import React, { useState, useEffect } from 'react';
import { Table, Pagination, Space, Button, Typography } from 'antd';
import type { TableProps, PaginationProps } from 'antd';

// 定义业务Props:与AntD Table原生Props区分
interface BaseTableProps<T = any> extends Omit<TableProps<T>, 'pagination'> {
  // 业务自定义分页Props
  paginationConfig?: PaginationProps;
  // 统一操作列配置
  actionColumn?: {
    width?: number;
    fixed?: 'left' | 'right';
    // 操作项配置
    actions: {
      text: string;
      onClick: (record: T) => void;
      type?: 'primary' | 'default' | 'danger';
    }[];
  };
}

const BaseTable = <T,>({
  columns,
  dataSource,
  paginationConfig,
  actionColumn,
  ...restProps // 剩余Props:透传AntD Table的原生Props
}: BaseTableProps<T>) => {
  // 合并列配置:新增操作列
  const mergedColumns = React.useMemo(() => {
    const cols = [...(columns || [])];
    if (actionColumn) {
      cols.push({
        title: '操作',
        key: 'action',
        width: actionColumn.width || 200,
        fixed: actionColumn.fixed || 'right',
        render: (_, record) => (
          <Space size="small">
            {actionColumn.actions.map((action, index) => (
              <Button
                key={index}
                type={action.type || 'default'}
                onClick={() => action.onClick(record)}
              >
                {action.text}
              </Button>
            ))}
          </Space>
        ),
      });
    }
    return cols;
  }, [columns, actionColumn]);

  // 分页状态管理
  const [pagination, setPagination] = useState<PaginationProps>({
    current: 1,
    pageSize: 10,
    showSizeChanger: true,
    showQuickJumper: true,
    showTotal: (total) => `共 ${total} 条`,
    ...paginationConfig,
  });

  // 监听数据总数,更新分页
  useEffect(() => {
    if (paginationConfig?.total !== undefined) {
      setPagination(prev => ({ ...prev, total: paginationConfig.total }));
    }
  }, [paginationConfig?.total]);

  // 分页变更回调
  const handleTableChange = (
    pagination: PaginationProps,
    filters: any,
    sorter: any
  ) => {
    setPagination(pagination);
    // 透传原生onChange事件
    restProps.onChange?.(pagination, filters, sorter);
  };

  return (
    <div style={{ background: '#fff', padding: 16, borderRadius: 4 }}>
      {/* 透传AntD Table的所有原生Props */}
      <Table<T>
        columns={mergedColumns}
        dataSource={dataSource}
        pagination={false} // 禁用原生分页,自定义
        onChange={handleTableChange}
        bordered // 业务默认值,可被restProps覆盖
        {...restProps} // 透传剩余原生Props(如rowKey、loading、scroll)
      />
      {/* 自定义分页组件 */}
      <div style={{ marginTop: 16, textAlign: 'right' }}>
        <Pagination
          {...pagination}
          {...paginationConfig}
          onChange={(page, pageSize) => {
            setPagination(prev => ({ ...prev, current: page, pageSize }));
          }}
        />
      </div>
    </div>
  );
};

export default BaseTable;

步骤2:父组件调用(透传原生Props + 扩展)

import React from 'react';
import BaseTable from './components/BaseTable';
import { Button, message } from 'antd';

// 模拟数据
const dataSource = [
  { id: 1, name: '张三', age: 20, status: '启用' },
  { id: 2, name: '李四', age: 22, status: '禁用' },
];

const Page = () => {
  // 列配置
  const columns = [
    { title: '姓名', dataIndex: 'name', key: 'name' },
    { title: '年龄', dataIndex: 'age', key: 'age' },
    { title: '状态', dataIndex: 'status', key: 'status' },
  ];

  // 操作列配置
  const actionColumn = {
    width: 200,
    fixed: 'right',
    actions: [
      {
        text: '编辑',
        type: 'primary',
        onClick: (record) => {
          message.success(`编辑${record.name}`);
        },
      },
      {
        text: '删除',
        type: 'danger',
        onClick: (record) => {
          message.warning(`删除${record.name}`);
        },
      },
    ],
  };

  return (
    <div style={{ padding: 20 }}>
      <BaseTable
        rowKey="id" // 透传AntD Table原生Props
        columns={columns}
        dataSource={dataSource}
        scroll={{ x: 1000 }} // 透传原生Props横向滚动
        loading={false} // 透传原生Props加载状态
        paginationConfig={{
          total: 2,
          pageSize: 10,
        }}
        actionColumn={actionColumn}
        // 透传原生事件
        onRow={(record) => ({
          onClick: () => console.log('点击行', record),
        })}
      />
    </div>
  );
};

export default Page;

4. 高效且易扩展的封装原则

下面是一些封装时候的原则,Vue/React通用:

4.1. Props设计

分层透传,不丢失原生能力

  • Vue:用$attrs透传所有原生Props,defineProps仅声明业务自定义Props,inheritAttrs: false避免属性污染;
  • React:用Omit剔除业务Props,剩余Props通过{...restProps}透传,区分“业务逻辑Props”和“原生组件Props”。

4.2. 扩展点设计

插槽/Children优先

  • Vue:预留具名插槽(如Dialog的footer、Table的action),支持局部替换;
  • React:通过children和自定义插槽对象(如slots)实现扩展,避免硬编码。

4.3. 状态管理

内部隔离,外部可控

  • 组件内部维护基础状态(如分页的current/pageSize),外部通过Props覆盖默认值;
  • 事件透传:内部处理基础逻辑后,通过emit/回调将结果暴露给外部。

4.4. 样式封装

有默认样式+可覆盖

  • Vue:用scoped+:deep()穿透样式,预留CSS变量(如--el-dialog-width)支持外部定制;
  • React:用CSS Modules隔离样式,支持传递className覆盖默认样式。

4.5. 边界处理

需要有兜底与兼容

  • 对空数据、空列配置做兜底(如Table无数据时显示“暂无数据”);
  • 兼容原生组件的所有事件(如Dialog的close、Table的onChange)。

5. 封装的与团队规范

下面是一些封装的"度",与团队规范:

5.1. 避免过度封装

  • 不封装“一次性”组件:仅单个页面使用、无复用价值的逻辑无需封装;
  • 不滥用透传:核心业务Props显式声明,避免所有属性都透传导致维护困难。

5.2. 组件分层:基础组件 vs 业务组件

类型 示例 特点
基础组件 BaseDialog、BaseTable 基于Element UI/AntD封装,全项目复用
业务组件 OrderTable、UserForm 绑定具体业务逻辑,仅业务模块复用

5.3. 文档化:标注透传能力

封装组件需注明“支持透传XX原生组件的所有Props/事件”,示例:

/**
 * BaseTable 基于AntD Table的二次封装
 * @param {BaseTableProps} props - 组件属性
 * @param {PaginationProps} props.paginationConfig - 分页配置(业务自定义)
 * @param {Object} props.actionColumn - 操作列配置(业务自定义)
 * @param {TableProps} ...restProps - 透传AntD Table的所有原生Props(除pagination)
 */

6. 总结

基于Element UI/AntD的二次封装,核心是“保留原生能力+新增业务逻辑”——通过透传Props确保不丢失组件库的原生功能,通过自定义Props和插槽实现业务定制,最终达到“复用、统一、易扩展”的目标。

Vue中通过$attrsinheritAttrs: false实现透传,React中通过剩余参数{...restProps}区分业务与原生Props,两者核心思路一致:让封装后的组件既满足业务需求,又保持原生组件的灵活性。

好的二次封装组件,应该是“对开发者友好”的——调用方无需关心内部实现,只需通过简单的Props配置即可完成业务需求,同时能灵活扩展原生能力,真正做到封装不封死,以上。


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

浅谈 AI 搜索前端打字机效果的实现方案演进

2025年11月27日 11:10

作者:vivo 互联网前端团队 - He Yanjun

在当代前端开发领域,打字机效果作为一种极具创造力与吸引力的交互元素,被广泛运用于各类网站和应用程序中,为用户带来独特的视觉体验和信息呈现方式,深受广大用户的喜爱。

本文将深入介绍在AI搜索输出响应的过程中,打字机效果是怎样逐步演进的。力求以通俗的语言和严谨的思路深入剖析打字机效果在不同阶段的关键技术难点和优劣势。

1分钟看图掌握核心观点👇

图片

一、前言

在如今基于AI搜索的对话舞台上,如果一段文字像老式打字机一样逐字逐句展现在屏幕上,那将是一种具有独特魅力的吸引力。

话不多说,先来看下最终的实现效果。

图片

二、引言

在AI搜索场景中,由于大模型基于流式输出文本,需要多次响应结果到前端,因此这种场景十分适合使用打字机效果。

打字机效果是指在生成内容的场景中,文字逐字符动态显示,模拟人工打字的过程,主要是出于提升用户体验、优化交互逻辑和增强心理感知等方面的考量:

缓解等待焦虑,降低“无反馈”的负面体验。

内容是逐步响应的,打字机效果可以很好地提供“实时反馈”,用户可以感知到系统正在工作,从而减少了等待过程中的不确定性和焦虑感。

模拟自然交互,增强“类人对话”的沉浸感。

对话交流具有停顿、强调等节奏感,通过实时打字的模拟,跟容易拉近与用户的心理距离,增强对话感和沉浸感。

优化信息接收效率,避免“信息过载”。

如果一次性展示大量密密麻麻的文字,用户需要花时间筛选重点,容易产生抵触,通过打字机效果可以缓和阅读节奏,减少视觉和认知负担。

强化“AI生成”的感知,降低对“标准答案”的预期。

使用户感知到是AI实时计算结果,而非预存的标准答案,有助于用户理性客观地使用工具。

三、早期实现方案——纯文本逐字符打字效果

最开始的产品功能,需要根据用户输入的搜索词,流式输出并逐字符展示到页面上,这可以说是打字机效果的入门级实现了,不依赖任何复杂的技术,其流程图大致如下所示。

图片

3.1 详细说明

前端会定义一个字段用来缓存全量的markdown文本,每次服务端流式响应markdown文本到前端时,前端都会将其追加到这个缓存字段后,然后基于marked依赖库将全量的markdown文本转换为html片段。

要实现逐字符渲染的动画效果,就需要定时更新文本。定时功能一般采用setTimeout或setInterval来实现,而更新文本可以考虑innerHTML和appendChild的方式,这里采用的innerHTML方式插入文本,核心代码如下所示。

let fullText = 'test text';// 全量的html文本
let index = 0;// 当前打印到的下标
let timer = window.setInterval(() => {
  ++index;
  $dom.innerHTML = fullText.substring(0, index);
}, 40);

3.2 innerHTML与appendChild的核心区别对比

图片

为什么选择innerHTML而非appendChild?

由于服务端是流式返回markdown文本,因此每次返回的markdown文本可能不是完整的。

举个例子如下。

先返回下面一段markdown文本

** 这是一个
再返回下面一段markdown文本

标题 **
先返回的文本会当作纯文本展示,再返回的文本会与先返回的文本结合生成html片段如下

<strong>这是一个标题</strong>

如果使用appendChild的话,就不好处理上述场景。

3.3 小结

这种方式的优点就是简单易懂,很容易上手实现,也没有任何依赖。

但是,它的缺点也是显而易见的。比如,我们无法方便的添加一些额外的动画效果来增强视觉体验,如光标闪烁效果;对于一些复杂文本内容,或者需要更加灵活地控制展示细节时也会显得捉襟见肘;并且每次通过innerHTML渲染文本时,都触发了dom的销毁与创建,性能消耗大。

四、需求难度进一步提升

随着产品的迭代,业务要求打字内容不仅是纯文本,还需要穿插展示卡片等复杂样式效果,如下图所示。

卡片的类型包括应用、股票、影视等,需要可扩展、可配置,并且还会包括复杂的交互效果,如点击、跳转等。

图片

很明显,基于早期的实现方案已经远远不能满足日益增强的业务诉求了,必须考虑更加灵活高效的技术方案。

五、现代框架下的实现——基于Vue虚拟dom动态更新

通过上述的分析,打字内容中要穿插展示卡片,显然需要使用单例模式,否则如果每次打字都重新创建元素的话,不仅性能低下,而且数据和状态还无法保持一致。

而要使用单例模式,就必须根据现有数据对已插入节点进行插入、更新、移除等操作以保持数据的一致性,这就很自然地会想到使用现代前端框架来对打字机效果进行改进。

Vue是基于虚拟dom的渐进式javascript框架,仅在数据变化时计算差异并更新必要的部分,因此可以借助其数据驱动开发、组件化开发等特性,轻松地构建一个可复用的打字机效果组件。

5.1 设计思路

要实现打字正文中穿插卡片的效果,首先需要定义好返回的数据结构,它需要具备可扩展,方便解析,兼容markdown等特性,所以使用html标签是一种比较合适的方式,例如要展示一个应用卡片,可以下发如下所示数据。

<app id="" />

从下发的数据中可以获取到标签名和属性键值对,这样就可以通过标签名来渲染关联到的组件模板,通过属性键值对去服务端加载对应的数据,于是就可以水到渠成的把应用卡片展示出来,其流程图如下图所示。

图片

5.2 详细说明

组件模板文件按照一定规则组织在特定的目录下,在构建时打包到资源里,关键代码如下所示。

privateinit(){  
    let fileList = require.context('@/components/common/box', true, /\.vue$/);  
    fileList.keys().forEach((filePath) => {  
        let startIndex = filePath.lastIndexOf('/');  
        let endIndex = filePath.lastIndexOf('.');  
        let tagName = filePath.substring(startIndex + 1, endIndex);  
        this.widgetMap[tagName] = fileList(filePath).default;  
    });  
}

之前版本在每次接收到服务端下发的markdown文本时,都会做一次转换成html的操作,如果多次响应之间的间隔时间很短,则会出现略微卡顿的现象,因此这里转换为html时再增加一个防抖功能,可以很有效的避免卡顿。

每次定时截取到待渲染的html文本以后,会基于ultrahtml库将其转换为dom树,并过滤掉注释、脚本等标签,核心代码如下。

let toRenderHtml = this.rawHtml.substring(0, this.curIndex);  
let dom = {  
    type: ELEMENT_NODE,  
    name: 'p',  
    children: parse(toRenderHtml).children  
};

最后就是全局注册一个递归组件用来渲染转换后的dom树,核心代码如下。

render(h: any) {  
    // 此处省略若干代码

    // 处理子节点
    let children = this.dom['children'] || [];  
    let renderChildren = children.map((child: any, index: number) => { 
        return h(CommonDisplay, {  
            props: {  
                dom: child,  
                displayCursor: this.displayCursor,  
                lastLine: this.lastLine && index === children.length - 1,  
                ignoreBoxTag: this.ignoreBoxTag  
            }  
        });  
    });
  
    // 此处省略若干代码

    // 处理文本节点
    if (this.dom['type'] === TEXT_NODE) {  
        returnthis.renderTextNode({h, element: this.dom});  
    }

    // 处理自定义组件标签
    let tagName = this.dom['type'] === ELEMENT_NODE ? this.dom['name'] : 'div';  
    if (this.$factory.hasTag(tagName)) {  
        // 此处省略若干代码
        let widget = this.$factory.getWidget(tagName);
        return h(widget, {  
            key: tagId,  
            props: {  
                displayCursor: this.displayCursor,  
                lastLine: this.lastLine,  
                text,  
                ...attributes  
            }  
        }, isLastLeaf && this.displayCursor ? [h(commonCursor)] : []);
    }

    // 处理html原始标签
    return h(tagName, {  
        attrs: {  
            displayCursor: this.displayCursor,  
            lastLine: this.lastLine,  
            ...this.dom['attributes']  
        }  
    }, renderChildren);  
}

5.3 问题整理和解决

打字机功能终于正常运行了,流畅度还是不错的,但是在体验的过程中,也发现了一些细节问题

①打字文本中如果存在标签,如

xxx

,会出现先展示 < ,再展示 <p ,最后展示空的效果,也就是字符回退,极大影响阅读体验。

原因分析

定时截取待渲染文本时是通过定义一个下标递增逐字符截取的,这就导致标签并没有作为一个原子结构被整体截取,于是就出现了字符回退的现象。

解决方案

当下标指向的字符为 < 时,则往后截取到 > 的位置,核心代码如下。

if (curChar === '<') {  
    let lastGtIndex = this.rawHtml.indexOf('>', this.curIndex);
    if (lastGtIndex > -1) {
        this.curIndex = lastGtIndex + 1;
        returnfalse;
    }
}

② 打字文本中如果存在转义字符,如 " ,则会依次出现这些字符,最后再展示 " ,也就是字符闪烁,也十分影响阅读体验。

原因分析

原因同上述字符回退一样,也是没有把转义字符当作一个整体截取。

解决方案

当下标指向的字符为 & 时,则往后截取到转义字符结束的位置,核心代码如下。

// 大模型大概率只下发有限类别的转义字符,做成配置动态下发,不仅解析方便,定制下发也很及时  
if (curChar === '&') {  
    let matchEscape = this.config['writer']['escapeArr'].find((item: any) => {  
        returnthis.rawHtml.indexOf(item, this.curIndex) === this.curIndex;  
    });  
    if (matchEscape) {  
        this.curIndex += matchEscape.length;  
        returnfalse;  
    }  
}

③ 打字过程中的速度是固定的,缺少一点抑扬顿挫的节奏感,不够自然。

原因分析

定时器的间隔时间是固定的一个数值,所以表现为一个固定不变的打字节奏。

解决方案

可以根据未打印字符数来动态调整每次打字的速度,一种可选的实现方案如下。

假设未打印字符数为 N ,速度平滑指数为 a ,实际打字速度为 Vcurrent ,逻辑应达到的打字速度为 Vnew 。

if N <= 10 , Vnew = 100 ms / 1字符

if 10 < N <= 20 , Vnew = 100 - 8 * ( N - 10 ) ms / 1字符

if 20 < N , Vnew = 20 ms / 4字符

Vcurrent = a * Vcurrent + ( 1 - a ) * Vnew

上述策略可能会比较多,而且上线以后还有可能更换数值对照效果,因此为了支持配置化,我们可以对Vnew进行表达式归纳,如下所示。

Vnew = Vinit - w * ( N - min ) + b

Vinit 为默认初始打字速度,w 为每条策略的权重值,N 为未打印字符数,min 为每条策略的最小字符数量比较值,b 为每条策略的偏置。关键代码如下所示。

privatespeedFn({curSpeed, curIndex, totalLength}: any){  
    let leftCharLength = Math.max(0, totalLength - curIndex);  
    let matchStrategy = this.config['writer']['strategy'].find((item: any) => {  
        return (!item['min'] || item['min'] < leftCharLength)  
            && (!item['max'] || item['max'] >= leftCharLength);  
    });  
    let speed = this.config['writer']['initSpeed'] - matchStrategy['w'] * (leftCharLength - (matchStrategy['min'] || 0)) + matchStrategy['b'];  
    returnthis.config['writer']['smoothParam'] * curSpeed + (1 - this.config['writer']['smoothParam']) * speed;  
}

④ 打字过程中,会时不时的回退到之前字符的位置重新开始打字,例如当前展示 a = b + c ,等到下一次渲染时会从 a 开始重新打完这一段。

原因分析

由于markdown文本结合会生成html标签,从而导致字符数量增多,那么当前下标指向的字符就相对之前落后了。

let curIndex = 5;// 当前下标
let prevMarkdown = '**hello';// 上一次打印时的全量markdown文本
let prevHtml = '<p>**hello</p>';// 上一次打印时的全量html片段
let prevRenderHtml = '<p>**<p>';// 上一次打印到页面上的html片段
// 页面上会渲染 **

// 当服务端继续下发了 ** 的markdown文本时,curIndex会递增1变为6
let curMarkdown = '**hello**';// 当前打印时的全量markdown文本
let curHtml = '<p><strong>hello</strong></p>';// 当前打印时的全量html片段
let curRenderHtml = '<p><strong></strong><p>';// 当前打印到页面上的html片段
// 页面上会渲染空标签,然后重新开始打字,尤其是在数学公式场景中非常容易复现

解决方案

解决这个问题,需要分两步走。

首先需要判断打印到页面上的html片段是否有变化,因为只有变化时才会出现这种情况,而判断是否有变化只需要记录一下上一次的html片段和这一次的html片段是否不同即可,比较好处理。

其次就是需要重新定位下标到上一次打印到的位置,这里相对比较难处理,因为html的结构和内容都在变化,很难准确的定位到下标应该移动到什么位置。虽然我们不能准确定位,但是只要能够使当前打印到页面上的字符比上一次的字符多,就可以满足诉求了。于是我想到了textContent这个属性,它可以获取当前节点及其后代的所有文本内容。那么问题就转化为:找到一个下标,使得当前截取的html片段的textContent长度要比上一次的textContent长度大。

综上所述,可以得到核心代码如下所示。

if (this.isHtmlChanged()) {  
    let domRange: any = document.createRange();  
    let prevFrag = domRange.createContextualFragment(this.prevRenderHtml);  
    let prevTextContent = prevFrag.textContent;  
    let diffNum = 1;  
    do {  
        this.curIndex += diffNum;  
        let curHtml = this.rawHtml.substring(0, this.curIndex);  
        let curFrag = domRange.createContextualFragment(curHtml);  
        let curTextContent = curFrag.textContent;  
        diffNum = prevTextContent.length - curTextContent.length;  
        if (diffNum <= 0) {  
            break;  
        }  
    } while (this.curIndex < this.rawHtml.length);  
}

5.4 小结

通过现代前端框架构建打字机组件,不仅减少了不必要的渲染和性能消耗,而且还能高效灵活的穿插各种酷炫的样式效果,实现更多复杂的产品功能。

六、未来展望

本文详细介绍了AI搜索中前端打字机效果的实现方案演进过程,从最初的纯文本逐字符打字效果,到借助现代前端框架实现灵活可复用的打字机组件,每一个技术难点的技术突破无不体现了前端技术的持续进步和产品不断追求卓越的态度。同时我也希望本文可以抛砖引玉,为读者打开思路,提供借鉴。

随着人工智能和前端技术的不断发展和创新生态的日益完善,未来一定会不断涌现大量的新技术和新理念。我相信只要时刻保持积极学习和不断尝试的探索精神,就能开拓出更多精彩创新的实现方案和应用场景。

❌
❌