头图

前言

最近有小伙伴在开发新项目时遇到了一些问题,从中挑出两个比较通用的问题分享出来,有需要的小伙伴可以参考参考:

  • 项目开发后期接到通知需要统计页面所有文案内容交由业务方进行统一翻译,等待业务翻译完成后,需要再将翻译结果关联到页面中,手动处理繁琐、低效,多人操作同一文件容易冲突
  • 由于 用户屏幕大小不一致,部分用户在查阅通过 滚动加载更多数据的列表页面 中想看到更多内容,于是对页面进行缩放,但 缩放到一定程度会导致页面滚动事件失效,无法查看更多数据

接下来我们就逐个分析,并给出对应的解决方案!!!

48648DFC.jpg

i18n 项目的文案收集和生成

关于 i18n 项目文案收集生成翻译文件,如果通过开发人员一个个文件收集、统计、修改,必然是需要花费不少时间和精力,而且这属于是重复劳动,是需要避免的,因此我们就需要通过脚本来帮我们实现 自动化,而这个过程是需要涉及 文件读/写 的,那自然就需要和 Node 打交道。

image.png

i18n 项目待翻译文案的收集

由于小伙伴的项目技术选型是 Vue,所以这里就以 Vue 项目来进行示例,适用于 Vue(2/3)。

vue-i18n-extract

在 vue 中如果需要收集项目中和 i18n 相关的文案,那么可以直接使用 vue-i18n-extract,它会对 Vue.js 源代码进行 静态分析,查找任何 vue-i18n 用法,其实就是去匹配使用到 $t()、$tc、t()、tc() 所包裹的内容,实际上我们通过 node 也可以自己实现该功能。

大致使用步骤如下:

  • 在项目根目录新增配置文件 vue-i18n-extract.config.js

    module.exports = {
        vueFiles: './src/**/*.?(js|vue|ts|jsx|tsx)', // 需要提取 i18n 字符串的文件,可以是文件夹或文件的路径
        languageFiles: './src/lang/**/*.?(json)', // 项目中 i18n 文案收集的文件,收集时会对比这个语言文件,可以是文件夹或文件的路径
        output: './i18n/extractOutput.json', // false | string => 如果需得到一个收集后 json 文件,就填入输出地址
        add: true, // 是否在翻译文件中添加没有的key
        remove: false, // 是否移除没有使用的key
        noEmptyTranslation: 'en-US',
    }
  • package.json 中配置 i18n-ext 脚本命令

      "scripts": {
        "i18n-ext": "npx vue-i18n-extract"
      }

如果你在 vue-i18n-extract.config.js 配置了 output: './i18n/extractOutput.json' 需要生成的收集结果 .json 文件,并想对其进行 额外的处理,你可以在将脚本名更改为:

     "scripts": {
       "i18n-ext": "npx vue-i18n-extract && node ./i18n/i18n-extract.js"
     }

示例效果

image.png

待翻译文案 和 翻译文案 的关联

xlsx2json

通过上面的处理,已经实现了自动 收集 i18n 文案 的功能,但是 翻译文案 不是简单的通过 机器翻译 就得到的,而是需要业务根据 业务场景 来进行提供的一个 .xlsx 文件,难道后续还需要一个个从 .xlsx 文件 中复制过来吗?

当然没有必要,由于 node 的存在我们可以很方便的读写文件,所以其实只要我们把目标的 .xlsx 文件 转换成 json 数据 在根据定义的规则写入目标文件即可,而实现这个功能的库也很多,可供大家自行选择,这里就使用 xlsx-to-json 来演示。

大致步骤如下:

  • 安装 npm install xlsx-to-json
  • 新建 xlsx2json 的目录,用于存放相关内容
    image.png

    • index.js 文件中添加如下内容

        const xlsx2json = require('xlsx-to-json');
        const path = require('path');
        const fs = require('fs');
      
        xlsx2json(
          {
            input: path.join(__dirname, `./lang.xlsx`),
            output: path.join(__dirname, './translation.json'),
          },
          function (err, result) {
            console.log(result);
          });
  • package.json 中配置 i18n-trans 脚本命令

      "scripts": {
        ...,
        "i18n-trans": "node ./i18n/xlsx2json/index.js"
      }

假设收到的翻译 .xlsx 文件 内容如下:

image.png

通过 npm run i18n-trans 运行脚本后,得到如下结果:

image.png

这里就需要考虑两个问题:

  • 列表项中会存在 空的内容
  • 待翻译文案翻译文案 的关系可能是混杂的,既包含 中 -> 英,也包含 英 -> 中

实际上也好解决:

  • 空的列表项就过滤 不进行任何处理
  • 通过 正则表达式 来判断当前列表项是 中 -> 英 还是 英 -> 中,然后进行不同处理即可

于是最终的 i18n/xlsx2json/index.js 文件内容如下:

const xlsx2json = require("xlsx-to-json");
const path = require("path");
const fs = require("fs");

xlsx2json(
  {
    input: path.join(__dirname, `./translation.xlsx`),
    output: path.join(__dirname, "./translation.json"),
  },
  function (err, result) {
    if (err) {
      console.error(err);
    } else {
      // 用于判断翻译内容属于【中 -> 英】或【英 -> 中】
      const ChineseReg = new RegExp("[\\u4E00-\\u9FFF]+");

      // 目标文件路径
      const targetDirectory = path.join(__dirname, "../../src/lang"),
      targetEnPath = path.join(targetDirectory, './en.json'),
      targetZhPath = path.join(targetDirectory, './zh.json');

      // 读取已有的目标文件,用于去重
      const lastZhContent = fs.readFileSync(targetZhPath, "utf8"),
        lastEnContent = fs.readFileSync(targetZhPath, "utf8"),
        lastZhJson = JSON.parse(lastZhContent),
        lastEnJson = JSON.parse(lastEnContent);

      result.forEach((item) => {
        const Contents = Object.keys(item).map((key) => item[key]);

        // 表格列数据是倒序的,此处取表格第一列的值作为 key
        const key = Contents[1];

        // 过滤掉空值
        if (key) {
          // 此处判断可混杂【中 -> 英】和【英 -> 中】的情况
          const isZh2eEn = ChineseReg.test(key);

          if (isZh2eEn) {
            lastZhJson[key] = key;
            lastEnJson[key] = Contents[0];
          } else {
            lastEnJson[key] = key;
            lastZhJson[key] = Contents[0];
          }
        }
      });

      // 创建或覆盖文件
      try {
        // 写入对应的 中/英 文件
        fs.writeFileSync(targetZhPath, JSON.stringify(lastZhJson));
        fs.writeFileSync(targetEnPath, JSON.stringify(lastEnJson));
        console.log(`
==================================================================================================

翻译内容转换成功,请查看【${targetDirectory}】下的【zh.json、en.json】文件

==================================================================================================
        `);
      } catch (error) {
        console.log("翻译内容转换异常:", error);
      }
    }
  }
);

示例效果

回顾下在上面提取页面中的 待翻译文案 时,对应的 src/lang/[zh、en].json 文件内容如下:

image.png

现在我们在 i18n/xlsx2json/translation.xlsx 补齐这两个内容,如下:

image.png

再次运行 npm run i18n-trans 运行脚本后,即可得到如下结果:

image.png

缩放页面,滚动失效

具体场景已经在前面描述过了,简单来讲就是用户希望在首屏中看到更多的内容,因此对页面进行 缩放,然后引发的一系列问题。

相信有部分小伙伴肯定会觉得这不算 BUG,属于用户的非正常操作,难道连这种都要管吗?(小伙伴第一反应就是这样的

486AD3C2.gif

只能说这就是开发思维和用户思维的碰撞,总之结果是影响了使用体验,所以肯定是需要优化滴 \~

下面我们逐步实现,接着复现问题,最后解决问题。

image.png

如何实现滚动加载?

实际上最容易想到的就这两种方式:

  • 监听 列表项的父元素scroll 事件

    • 需要为直接父元素设定目标高度(heightmax-height),值得注意的是在 不同缩放比 下是固定的
  • 监听 窗口 windowscroll 事件

    • 窗口的高度在 不同缩放比 下会自动变化

如何判断是否触底?

无论是基于谁做的监听滚动,其实都少不了要判断当前的 滚动条是否触底,因为目标就是要在触底的时候去加载下一页的数据,那么如何判断是否触底呢?

这里涉及到滚动相关的 三个数据,如下所示:

  • 滚动容器的内容高度:clientHeight
  • 已滚动的高度:scrollTop
  • 滚动的高度:scrollHeight

image.png

它们的关系其实就是:

滚动的高度:scrollHeight = 滚动容器的内容高度:clientHeight + 已滚动的高度:scrollTop

因此触底的判断就简单了,假设滚动容器是整个窗口,就可以很快的写出如下内容:

const handleScroll = debounce((e) => {
  const scrollTop =
    document.documentElement.scrollTop || document.body.scrollTop;
  const scrollHeight =
    document.documentElement.scrollHeight || document.body.scrollHeight;
  const clientHeight =
    document.documentElement.clientHeight || document.body.clientHeight;

  if (scrollTop + clientHeight >= scrollHeight) {
    console.log("触底...");
  }
}, 300);

486CF8D4.gif

然后会发现这个判断不生效,滚动时的输出效果如下:

image.png

一眼看去就知道有精度问题,在这里我们并不关心精确到小数点多少位,所以的处理就很简单了,直接对 scrollTop + clientHeight 向上取整就好了,顺便把它变成通用的判断,于是就变成了:

const isScrollBottom = (target) => {
  target = target === document ? document.documentElement : target;
  
  const scrollTop = target.scrollTop,
  scrollHeight = target.scrollHeight,
  clientHeight = target.clientHeight;

  return Math.ceil(scrollTop + clientHeight) >= scrollHeight;
};

const handleScroll = debounce((e) => {
  console.log("scroll...");

  if (isScrollBottom(e.target)) {
    console.log("触底");
  }
}, 300);

页面缩放滚动失效怎么办?

原因很简单,当进行页面缩放时,滚动容器的高度自动发生了变化,页面越缩小,滚动容器内容高度越大,如下所示:

1.gif

容器的高度从小变大,就能够装下原本装不下的内容,装得下之后自然就不会产生滚动,而触发加载下一页数据的动作又是和滚动操作相关联,此时自然就无法继续加载更多的数据,从而就变成了一个 "Bug"

解决方案也简单,滚动触底的目的是加载下一页数据,现在是由于页面缩放滚动失效无法加载更多数据,那么可以在监听一个 resize 事件,当页面发生缩放时就会触发该事件,此时就去加载下一页数据即可:

const isScrollBottom = (target) => {
  target = target === document ? document.documentElement : target;
  const scrollTop = target.scrollTop,
    scrollHeight = target.scrollHeight,
    clientHeight = target.clientHeight;

  return Math.ceil(scrollTop + clientHeight) >= scrollHeight;
};

const handleScroll = debounce((e) => {
  const isResize = e.type === "resize";

  console.log(isResize ? "resize..." : "scroll...");

  if (isResize || isScrollBottom(e.target)) {
    getData();
  }
}, 500);

onMounted(() => {
  window.addEventListener("resize", handleScroll);

  window.addEventListener("scroll", handleScroll);
});

效果如下:

1.gif

事情好像没有那么简单!

看着好像可以了是吧,但实际上还有几个问题:

  • 监听的 resize 事件 不只是 高度变化 时触发,宽度变化 时候也会触发,而这个时候是没必要的

    1.gif

  • 加载下一页的数据在 不同缩放程度 下,不一定 能让当前页面 产生滚动效果,没有滚动就没法在不缩放页面的情况下加载更多数

    • 【场景一】由于添加 debounce 防抖 所以在目标时间内只会触发一次,而加载到的数据不一定能让目标容器产生滚动
    • 【场景二】在缩放到一定程度的页面,直接刷新页面,此时只会加载第一页的数据(例如每页只加载 5 条 数据),此时也未必会产生滚动效果
    • 【场景三】用户觉得明明后面还有数据,却没有自动填满这个空白区域,体验不好

      image.png

因此,在触发 resize 事件 的时候,还需要计算一下 本次需要加载多少数据 足以让当前页面产生滚动效果,此时逻辑变更为:

// 获取滚动相关信息
const getScrollInfo = (target) => {
  target = [window, document].includes(target) ? document.documentElement : target;

  const scrollTop = target.scrollTop,
    scrollHeight = target.scrollHeight,
    clientHeight = target.clientHeight;
    
  return {
    isScrollBottom: Math.ceil(scrollTop + clientHeight) >= scrollHeight,
    scrollTop,
    scrollHeight,
    clientHeight
  };
};

// resize 和 scroll 事件
const handleResizeAndScroll = debounce((e) => {
  const isResize = e.type === "resize";
  
  const { isScrollBottom, clientHeight } = getScrollInfo(e.target);

  // 计算本次需要加载多少数据
  let pageSize = 6, listItemMinHeight = 200;
  if(isResize){
      const remainingHeight = clientHeight - data.value.length * listItemMinHeight;
      if(remainingHeight > 0){
        pageSize = Math.ceil(remainingHeight / listItemMinHeight);
      }
  }

  if (isScrollBottom) {
    console.log("触底", pageSize);
    getData(pageSize);
  }
}, 500);

// 监听事件
onMounted(() => {
  window.addEventListener("resize", handleResizeAndScroll);
  window.addEventListener("scroll", handleResizeAndScroll);
});

// 初始化加载
onMounted(() => {
  handleScroll({ target: document, type: "resize" });
});

// 移除事件
onBeforeUnmount(() => {
  window.removeEventListener("resize", handleResizeAndScroll);
  window.removeEventListener("scroll", handleResizeAndScroll);
});

效果如下:

1.gif

封装成 useResizeAndScroll

上面的实现已经能解决问题了,但是要考虑页面其他地方需要复用的可能,因此我们需要做个小小封装,如下:

// useResizeAndScroll.ts

import { ref, onMounted, onBeforeUnmount } from "vue";
import type { Ref } from "vue";
import { debounce } from "lodash";

interface Options {
  pageSize: number;
  listItemMinHeight: number;
  listData: any[] | Ref<any[]>;
  triggerAction: (pageSize: number) => any;
  offsetBottom?: number;
  scrollContainerSelector?: string;
  immdiate?: boolean;
}

export default function useResizeAndScroll({
  pageSize,
  listItemMinHeight,
  listData,
  scrollContainerSelector = "",
  offsetBottom = 0,
  immdiate = true,
  triggerAction,
}: Options) {
  // 滚动容器
  let scrollContainer: Element | Window = window;

  // 滚动容器高度
  let scrollContainerHieght = ref(0);

  // 获取 html 元素
  const getHTMLElement = (target) => {
    return [window, document].includes(target)
      ? document.documentElement
      : target;
  };

  // 获取滚动信息
  const getScrollInfo = (target) => {
    target = getHTMLElement(target);

    const scrollTop = target.scrollTop,
      scrollHeight = target.scrollHeight,
      clientHeight = target.clientHeight;

    return {
      isScrollBottom: Math.ceil(scrollTop + clientHeight) >= scrollHeight,
      scrollTop,
      scrollHeight,
      clientHeight,
    };
  };

  // 获取满足条件的 pageSize
  const getPageSize = (clientHeight) => {
    let result = 0;

    const listLength = listData.__v_isRef
        ? listData.value.length
        : listData.length,
      remainingHeight = clientHeight - listLength * listItemMinHeight;

    if (remainingHeight > 0) {
      result = Math.ceil(remainingHeight / listItemMinHeight);
    }

    return result < pageSize ? pageSize : result;
  };

  // 滚动和缩放事件
  const handleResizeAndScroll = debounce((e) => {
    const isResize = e.type === "resize";

    console.log(isResize ? "resize..." : "scroll...");

    const { isScrollBottom, clientHeight } = getScrollInfo(e.target);

    let newPageSize = pageSize;

    // 页面缩放时
    if (isResize) {
      // 计算本次需要加载多少数据
      newPageSize = getPageSize(clientHeight);

      // 计算滚动容器的高度
      scrollContainerHieght.value = scrollContainerSelector
        ? clientHeight -
          getHTMLElement(scrollContainer).offsetTop -
          offsetBottom
        : clientHeight;
    }

    if (isScrollBottom) {
      console.log("trigger...", newPageSize);

      triggerAction(newPageSize);
    }
  }, 500);

  // 初始化执行
  onMounted(() => {
    scrollContainer = scrollContainerSelector
      ? document.querySelector(scrollContainerSelector)
      : scrollContainer;
    immdiate && handleResizeAndScroll({ target: document, type: "resize" });
  });

  // 注册监听事件
  onMounted(() => {
    window.addEventListener("resize", handleResizeAndScroll);
    scrollContainer.addEventListener("scroll", handleResizeAndScroll);
  });

  // 移除监听事件
  onBeforeUnmount(() => {
    window.removeEventListener("resize", handleResizeAndScroll);
    scrollContainer.removeEventListener("scroll", handleResizeAndScroll);
  });

  return scrollContainerHieght;
}

通过如下方式使用即可:

  • 滚动容器为【窗口】
useResizeAndScroll({
  pageSize: 6,
  listItemMinHeight: 200,
  listData: data,
  triggerAction: getData,
});
  • 滚动容器为【特定元素】
<script setup lang="ts">

...

const scrollContainerHieght = useResizeAndScroll({
  pageSize: 6,
  listItemMinHeight: 200,
  scrollContainerSelector: ".list-box",
  listData: data,
  offsetBottom: 70,
  triggerAction: getData,
});
</script>

<template>
  ...
  
  <ul class="list-box" :style="{maxHeight: scrollContainerHieght + 'px', overflowY: 'auto' }">
    <li v-for="item in data">列表项:{{ item }}</li>
  </ul>
  
  ...
</template>

最后

以上就是本文的全部的内容了,有更好的解决方案可以在评论区给出来,大家可以一起讨论讨论。

希望本文对你有所帮助!!!

image.png


熊的猫
966 声望339 粉丝

业精于勤立不易方,而后鹏程万里!