🍎用 pretext 搞定输入框动态宽度:一个困扰了我三天的 CSS 问题

1 阅读5分钟

pretext-cover.png

通过 @chenglou/pretext 库在前端精确计算文本宽度,实现了搜索表单输入框的动态宽度适配。告别 min-width 的粗暴限制,让 UI 更精致。

背景

做企业管理系统,搜索表单是最常见的组件。一个头疼的问题是:输入框宽度怎么定?

定死了,长文本挤成省略号;用 min-width,不同字段长度不同,结果参差不齐。

pretext1.png

直到我发现了 pretext 这个库。

问题场景

看这个搜索表单:

<!-- SearchForm/index.vue -->
<template>
  <div class="search-area">
    <el-form :inline="true" :model="formData" class="search-form">
      <el-form-item v-for="field in fields" :key="field.prop">
        <el-input
            v-if="field.type === 'input'"
            v-model="formData[field.prop]"
            :placeholder="field.placeholder || `请输入${field.label}`"
            :style="getFieldStyle(field)"
        />
        <!-- 日期范围选择 -->
        <el-date-picker
            v-else-if="field.type === 'dateRange'"
            v-model="formData[field.prop]"
            type="datetimerange"
            :style="getFieldStyle(field)"
        />
        <!-- 下拉选择 -->
        <el-select
            v-else-if="field.type === 'select'"
            v-model="formData[field.prop]"
            :placeholder="field.placeholder || `请选择${field.label}`"
            :style="getFieldStyle(field)"
        />
      </el-form-item>
    </el-form>
  </div>
</template>

字段配置是动态的:

const fields = [
  { prop: 'name', label: '姓名', type: 'input' },
  { prop: 'idCard', label: '身份证号', type: 'input' },
  { prop: 'createTime', label: '创建时间', type: 'dateRange' },
  { prop: 'status', label: '状态', type: 'select', options: [...] }
]

字段有长有短:

  • "请输入姓名" → 短
  • "请输入身份证号码" → 长
  • "请选择开始时间 至 请选择结束时间" → 更长

核心问题:每个字段的 placeholder 长度不同,如何让输入框宽度刚刚好?

常见的"摆烂"方案

方案 1:固定宽度

.el-input {
  width: 200px; /* 要不挤死,要不太空 */
}

方案 2:min-width

.el-input {
  min-width: 180px;
  width: auto;
}

结果就是参差不齐——"姓名"和"身份证号"都是 200px,但明显应该不同宽度。

方案 3:后端返回宽度配置

每个字段配一个宽度值,后端告诉我该多宽。

工作量大,而且字段改了要同步改配置。

解决方案:pretext 文本测量

@chenglou/pretext 是一个纯 JS 的文本渲染库,能精确计算给定字体样式下文本的像素宽度。

核心原理:

  1. 传入文本 + 字体样式
  2. 返回每个字符的位置信息
  3. 由此计算出文本总宽度

安装

yarn add @chenglou/pretext

核心代码

// fieldStyle.js
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

// 字体样式,与 Element Plus el-input 保持一致
const FONT_STYLE = '14px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif';

/**
 * 根据 placeholder 文本计算宽度
 * @param {string} placeholderText - 占位符文本
 * @param {number} extraPadding - 额外的内边距(默认 50px)
 * @returns {number} 计算后的宽度
 */
export function getPlaceholderWidth(placeholderText, extraPadding = 50) {
  if (!placeholderText) return 200;
  const prepared = prepareWithSegments(placeholderText, FONT_STYLE);
  const result = layoutWithLines(prepared, 1000, 14);
  return Math.ceil(result.lines[0].width) + extraPadding;
}

/**
 * 获取字段宽度样式
 * @param {Object} field - 字段配置
 * @returns {Object} 宽度样式对象 { width: string }
 */
export function getFieldStyle(field) {
  let placeholderText = field.placeholder || `请输入${field.label}`;
  let extraPadding = 50;

  // dateRange 类型需要更宽(显示两个日期 + 分隔符)
  if (field.type === 'dateRange') {
    const startPlaceholder = field.startPlaceholder || `${field.label}开始时间`;
    const endPlaceholder = field.endPlaceholder || `${field.label}结束时间`;
    placeholderText = startPlaceholder + endPlaceholder;
    extraPadding = 80; // dateRange 控件本身更宽
  }

  const width = getPlaceholderWidth(placeholderText, extraPadding);
  return { width: `${width}px` };
}

使用效果

字段placeholder计算宽度
姓名请输入姓名132px
身份证号请输入身份证号172px
创建时间请选择开始时间至请选择结束时间340px

输入框宽度自适应文本长度,视觉上整齐划一。

image.png

这样看是不是舒服多了!!!

踩坑记录

坑 1:字体必须完全一致

Element Plus 的 input 使用系统字体,如果 pretext 的字体定义和它不一致,计算出来的宽度会有偏差。

解决:直接从浏览器 DevTools 抄 Element Plus 的实际字体样式:

// Chrome DevTools Elements 面板
// 检查 .el-input__inner 的 computed styles
const FONT_STYLE = '14px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif';

坑 2:dateRange 需要特殊处理

日期范围选择器显示的是两个日期 + "至"分隔符,宽度明显比普通输入框大。

解决:针对 dateRange 类型,把开始和结束的 placeholder 拼接起来计算,并且增大 extraPadding

坑 3:计算结果偏小

单独计算每个字段后,实际渲染还是有点挤。

原因:输入框还有 padding、border 等自身宽度。

解决:加了 extraPadding = 50px 的缓冲,不同类型调整这个值。

完整组件代码

utils/fieldStyle.js

import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

const FONT_STYLE = '14px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif';

export function getPlaceholderWidth(placeholderText, extraPadding = 50) {
  if (!placeholderText) return 200;
  const prepared = prepareWithSegments(placeholderText, FONT_STYLE);
  const result = layoutWithLines(prepared, 1000, 14);
  return Math.ceil(result.lines[0].width) + extraPadding;
}

export function getFieldStyle(field) {
  let placeholderText = field.placeholder || `请输入${field.label}`;
  let extraPadding = 50;

  if (field.type === 'dateRange') {
    const startPlaceholder = field.startPlaceholder || `${field.label}开始时间`;
    const endPlaceholder = field.endPlaceholder || `${field.label}结束时间`;
    placeholderText = startPlaceholder + endPlaceholder;
    extraPadding = 80;
  }

  const width = getPlaceholderWidth(placeholderText, extraPadding);
  return { width: `${width}px` };
}

components/SearchForm/index.vue

<template>
  <div class="search-area">
    <el-form :inline="true" :model="formData" class="search-form">
      <el-form-item v-for="field in fields" :key="field.prop">
        <!-- 输入框 -->
        <el-input
            v-if="field.type === 'input'"
            v-model="formData[field.prop]"
            :placeholder="field.placeholder || `请输入${field.label}`"
            clearable
            :style="getFieldStyle(field)"
        />
        <!-- 日期范围选择 -->
        <el-date-picker
            v-else-if="field.type === 'dateRange'"
            v-model="formData[field.prop]"
            type="datetimerange"
            range-separator="至"
            :start-placeholder="field.startPlaceholder || `${field.label}开始时间`"
            :end-placeholder="field.endPlaceholder || `${field.label}结束时间`"
            value-format="YYYY-MM-DD HH:mm:ss"
            format="YYYY-MM-DD HH:mm:ss"
            :style="getFieldStyle(field)"
        />
        <!-- 单个日期选择 -->
        <el-date-picker
            v-else-if="field.type === 'date'"
            v-model="formData[field.prop]"
            type="datetime"
            :placeholder="field.placeholder || `请选择${field.label}`"
            value-format="YYYY-MM-DD HH:mm:ss"
            format="YYYY-MM-DD HH:mm:ss"
            :style="getFieldStyle(field)"
        />
        <!-- 下拉选择 -->
        <el-select
            v-else-if="field.type === 'select'"
            v-model="formData[field.prop]"
            :placeholder="field.placeholder || `请选择${field.label}`"
            clearable
            :style="getFieldStyle(field)"
        >
          <el-option
              v-for="option in field.options"
              :key="option.value"
              :label="option.label"
              :value="option.value"
          />
        </el-select>
      </el-form-item>
      <el-form-item class="search-btn-group">
        <el-button type="primary" @click="handleSearch">搜索</el-button>
        <el-button @click="handleReset">重置</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { ref, watch, reactive } from 'vue';
import { getFieldStyle } from '@/utils/fieldStyle';

const props = defineProps({
  modelValue: { type: Object, default: () => ({}) },
  fields: { type: Array, default: () => [] }
});

const emit = defineEmits(['update:modelValue', 'search', 'reset']);

const formData = reactive({ ...props.modelValue });

const initFormData = () => {
  props.fields.forEach(field => {
    if (!(field.prop in formData)) {
      formData[field.prop] = field.type === 'dateRange' ? [] : '';
    }
  });
};

watch(() => props.fields, () => { initFormData(); }, { immediate: true, deep: true });
watch(() => props.modelValue, (val) => { Object.assign(formData, val); }, { deep: true });

const handleSearch = () => {
  emit('search', { ...formData });
};

const handleReset = () => {
  props.fields.forEach(field => {
    formData[field.prop] = field.type === 'dateRange' ? [] : '';
  });
  emit('reset');
};
</script>

<style lang="scss" scoped>
.search-area {
  background: #fff;
  padding: 10px;
  margin-bottom: 0;
  border-radius: 4px;

  .search-form {
    .el-form-item {
      margin-right: 10px;
      margin-bottom: 12px;
    }
  }
}
</style>

对比效果

方案姓名输入框身份证号输入框日期范围选择器
固定 200px200px (空旷)200px (刚好)200px (挤)
min-width: 180px180px (挤)200px+ (不统一)200px+ (不统一)
pretext 动态宽度132px172px340px

适用场景

适合用 pretext 的场景:

  • 动态表单,字段配置来自后端
  • 多语言系统,不同语言文本长度差异大
  • 需要精细控制 UI 尺寸的企业级应用

不适合用的场景:

  • 固定的几 个字段,直接配固定宽度更简单
  • 性能敏感的热路径,pretext 计算有开销
  • 响应式布局,容器宽度本身就在变

总结

pretext 解决了文本宽度计算的难题,让输入框宽度能"自适应"文本长度。核心就两个 API:

const prepared = prepareWithSegments(text, fontStyle);
const result = layoutWithLines(prepared, maxWidth, fontSize);
result.lines[0].width; // 文本宽度

配上调优的 extraPadding,基本能覆盖大部分场景。


有问题欢迎留言交流。