0%

响应式文本容器——Web Components 实现方案

做移动端项目,遇到了文本长度不固定但容器尺寸固定的场景,需要实现文本自适应显示。研究了一番后决定用 Web Components 封装一个响应式文本容器组件,可以根据容器大小动态调整字体大小,效果还不错!

需求分析

  在现代 Web 开发中,我们经常遇到需要在一个固定尺寸的容器中显示文本内容的场景。由于文本长度是动态的,而容器尺寸是固定的,这就产生了文本溢出的问题。传统的解决方案如 Css 的text-overflow: ellipsis虽然能解决溢出问题,但无法在视觉上最大化文本的可读性,也没有提供查看完整内容的便捷方式。

  理想的响应式文本容器应该具备以下功能:

  1. 根据容器尺寸动态调整字体大小,尽可能显示更多内容
  2. 当调整到最小字体大小仍然无法完整显示时,显示省略号
  3. 提供查看完整内容的交互方式(悬停或长按)
  4. 保持良好的性能表现
  5. 跨框架兼容使用

Web Components 实现方案

响应式文本容器基础类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
// components/responsive-text-container/responsive-text-container.JS
class ResponsiveTextContainer extends HtmlElement {
constructor() {
super();

// 创建 shadow root 以隔离样式 this.shadow = this.attachShadow({ mode: 'open' });

// 绑定事件处理器 this.handleResize = this.handleResize.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
this.handleTouchStart = this.handleTouchStart.bind(this);
this.handleTouchEnd = this.handleTouchEnd.bind(this);

// 初始化状态 this.state = {
fontSize: 16, // 默认字体大小 isTruncated: false, // 是否被截断 originalText: '', // 原始文本 truncatedText: '', // 截断后的文本 showTooltip: false, // 是否显示工具提示 resizeObserver: null,
touchTimer: null
};

// 默认配置 this.config = {
minFontSize: 12, // 最小字体大小 maxFontSize: 24, // 最大字体大小 lineHeight: 1.2, // 行高 truncateSuffix: '...', // 截断后缀 tooltipDelay: 500, // 工具提示延迟 enableTooltip: true // 是否启用工具提示
};
}

static get observedAttributes() {
return ['text', 'min-font-size', 'max-font-size', 'truncate-suffix', 'enable-tooltip'];
}

connectedCallback() {
this.initConfig();
this.render();
this.setupResizeObserver();
this.setupEventListeners();
this.calculateFontSize();
}

disconnectedCallback() {
this.cleanup();
}

attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
switch (name) {
case 'text':
this.setText(newValue);
break;
case 'min-font-size':
this.config.minFontSize = parseInt(newValue) || 12;
this.calculateFontSize();
break;
case 'max-font-size':
this.config.maxFontSize = parseInt(newValue) || 24;
this.calculateFontSize();
break;
case 'truncate-suffix':
this.config.truncateSuffix = newValue || '...';
this.calculateFontSize();
break;
case 'enable-tooltip':
this.config.enableTooltip = newValue === 'true';
break;
}
}
}

// 初始化配置 initConfig() {
// 从属性获取配置 const minFontSize = this.getAttribute('min-font-size');
const maxFontSize = this.getAttribute('max-font-size');
const truncateSuffix = this.getAttribute('truncate-suffix');
const enableTooltip = this.getAttribute('enable-tooltip');

if (minFontSize) this.config.minFontSize = parseInt(minFontSize);
if (maxFontSize) this.config.maxFontSize = parseInt(maxFontSize);
if (truncateSuffix) this.config.truncateSuffix = truncateSuffix;
if (enableTooltip !== null) this.config.enableTooltip = enableTooltip === 'true';
}

// 渲染组件 render() {
this.shadow.innerHTML = this.getTemplate();
this.applyStyles();

// 获取关键 DOM 元素 this.textElement = this.shadow.querySelector('.responsive-text');
this.tooltipElement = this.shadow.querySelector('.tooltip');
}

// 获取模板 Html
getTemplate() {
return `
<style>${this.getCss()}</style>
<div class="container">
<div class="responsive-text-wrapper">
<span class="responsive-text"></span>
</div>
<div class="tooltip hidden" role="tooltip">
<div class="tooltip-content"></div>
</div>
</div>
`;
}

// 应用样式 applyStyles() {
if (this.textElement) {
this.textElement.style.fontSize = this.state.fontSize + 'px';
this.textElement.style.lineHeight = this.config.lineHeight;
}
}

// 获取 Css 样式 getCss() {
return `
:host {
display: block;
width: 100%;
height: 100%;
}

.container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}

.responsive-text-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 4px;
box-sizing: border-box;
}

.responsive-text {
font-family: inherit;
font-weight: inherit;
color: inherit;
word-break: break-word;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: font-size 0.1s ease;
}

.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
white-space: nowrap;
z-index: 1000;
max-width: 300px;
word-wrap: break-word;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
}

.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
}

.tooltip.visible {
opacity: 1;
visibility: visible;
}

.tooltip.hidden {
display: none;
}
`;
}

// 设置文本内容 setText(text) {
this.state.originalText = text || '';
if (this.textElement) {
this.textElement.textContent = this.state.originalText;
}
this.calculateFontSize();
}

// 计算字体大小 calculateFontSize() {
if (!this.textElement || !this.state.originalText) {
return;
}

const containerWidth = this.clientWidth || this.offsetWidth;
const containerHeight = this.clientHeight || this.offsetHeight;

if (!containerWidth || !containerHeight) {
return;
}

// 从最大字体开始向下调整 let fontSize = this.config.maxFontSize;
let fits = false;

while (fontSize >= this.config.minFontSize) {
this.textElement.style.fontSize = fontSize + 'px';

if (this.fitsInContainer()) {
fits = true;
break;
}

fontSize -= 1; // 每次减小1px
}

// 如果最小字体都无法适应,需要截断文本 if (!fits) {
fontSize = this.config.minFontSize;
this.textElement.style.fontSize = fontSize + 'px';
this.truncateText();
this.state.isTruncated = true;
} else {
this.state.isTruncated = false;
this.textElement.textContent = this.state.originalText;
this.hideTooltip();
}

this.state.fontSize = fontSize;

// 触发自定义事件 this.dispatchEvent(new CustomEvent('font-size-change', {
detail: { fontSize, isTruncated: this.state.isTruncated },
bubbles: true,
composed: true
}));
}

// 检查文本是否适应容器 fitsInContainer() {
// 临时设置为 nowrap 来检查宽度 const originalWhiteSpace = this.textElement.style.whiteSpace;
this.textElement.style.whiteSpace = 'nowrap';

const widthFits = this.textElement.scrollWidth <= this.textElement.clientWidth;
const heightFits = this.textElement.scrollHeight <= this.textElement.clientHeight;

// 恢复原始样式 this.textElement.style.whiteSpace = originalWhiteSpace;

return widthFits && heightFits;
}

// 截断文本 truncateText() {
if (!this.textElement) return;

const originalText = this.state.originalText;
let text = originalText;
const ellipsis = this.config.truncateSuffix;

// 逐渐减少文本直到适应容器 while (text.length > 0 && !this.fitsInContainer()) {
text = text.slice(0, -1);
this.textElement.textContent = text + ellipsis;
}

// 确保最终文本适应容器 if (text.length < originalText.length) {
this.textElement.textContent = text + ellipsis;
this.state.truncatedText = text + ellipsis;
}
}

// 设置 resize observer
setupResizeObserver() {
if (window.ResizeObserver) {
this.state.resizeObserver = new ResizeObserver(() => {
this.calculateFontSize();
});
this.state.resizeObserver.observe(this);
} else {
// 降级方案:使用 window resize 事件 window.addEventListener('resize', this.handleResize);
}
}

// 设置事件监听器 setupEventListeners() {
if (this.config.enableTooltip) {
if (this.textElement) {
this.textElement.addEventListener('mouseenter', this.handleMouseEnter);
this.textElement.addEventListener('mouseleave', this.handleMouseLeave);
this.textElement.addEventListener('touchstart', this.handleTouchStart);
this.textElement.addEventListener('touchend', this.handleTouchEnd);
this.textElement.addEventListener('touchcancel', this.handleTouchEnd);
}
}
}

// 清理资源 cleanup() {
if (this.state.resizeObserver) {
this.state.resizeObserver.disconnect();
} else {
window.removeEventListener('resize', this.handleResize);
}

if (this.textElement) {
this.textElement.removeEventListener('mouseenter', this.handleMouseEnter);
this.textElement.removeEventListener('mouseleave', this.handleMouseLeave);
this.textElement.removeEventListener('touchstart', this.handleTouchStart);
this.textElement.removeEventListener('touchend', this.handleTouchEnd);
this.textElement.removeEventListener('touchcancel', this.handleTouchEnd);
}

if (this.state.touchTimer) {
clearTimeout(this.state.touchTimer);
}
}

// 事件处理器 handleResize() {
this.calculateFontSize();
}

handleMouseEnter() {
if (this.state.isTruncated) {
this.showTooltip();
}
}

handleMouseLeave() {
this.hideTooltip();
}

handleTouchStart() {
if (this.state.isTruncated) {
this.state.touchTimer = setTimeout(() => {
this.showTooltip();
}, this.config.tooltipDelay);
}
}

handleTouchEnd() {
if (this.state.touchTimer) {
clearTimeout(this.state.touchTimer);
this.state.touchTimer = null;
}

if (this.state.showTooltip) {
this.hideTooltip();
}
}

// 显示工具提示 showTooltip() {
if (this.tooltipElement && this.config.enableTooltip) {
const tooltipContent = this.tooltipElement.querySelector('.tooltip-content');
if (tooltipContent) {
tooltipContent.textContent = this.state.originalText;
}

this.tooltipElement.classList.remove('hidden');
this.tooltipElement.classList.add('visible');
this.state.showTooltip = true;

// 调整工具提示位置以防止超出视口 this.adjustTooltipPosition();
}
}

// 隐藏工具提示 hideTooltip() {
if (this.tooltipElement) {
this.tooltipElement.classList.remove('visible');
setTimeout(() => {
this.tooltipElement.classList.add('hidden');
}, 200); // 等待过渡动画完成 this.state.showTooltip = false;
}
}

// 调整工具提示位置 adjustTooltipPosition() {
if (!this.tooltipElement) return;

const tooltipRect = this.tooltipElement.getBoundingClientRect();
const containerRect = this.getBoundingClientRect();

// 检查是否超出左侧 if (tooltipRect.left < 0) {
this.tooltipElement.style.transform = `translateX(${containerRect.left - tooltipRect.left + 8}px)`;
}
// 检查是否超出右侧 else if (tooltipRect.right > window.innerWidth) {
this.tooltipElement.style.transform = `translateX(${window.innerWidth - tooltipRect.right - 8}px)`;
}
// 重置为居中 else {
this.tooltipElement.style.transform = 'translateX(-50%)';
}
}

// 公共方法 getText() {
return this.state.originalText;
}

getFontSize() {
return this.state.fontSize;
}

isTruncated() {
return this.state.isTruncated;
}

// 强制重新计算 recalculate() {
this.calculateFontSize();
}
}

// 注册自定义元素 customElements.define('responsive-text-container', ResponsiveTextContainer);

文本测量工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// components/responsive-text-container/text-measurer.JS
export class TextMeasurer {
constructor() {
// 创建隐藏的测量元素 this.measurer = document.createElement('div');
this.measurer.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
visibility: hidden;
white-space: nowrap;
font-family: inherit;
font-weight: inherit;
padding: 0;
margin: 0;
border: none;
outline: none;
`;
document.body.appendChild(this.measurer);
}

measureText(text, fontSize, fontFamily = 'inherit', fontWeight = 'inherit') {
this.measurer.style.fontSize = fontSize + 'px';
this.measurer.style.fontFamily = fontFamily;
this.measurer.style.fontWeight = fontWeight;
this.measurer.textContent = text;

return {
width: this.measurer.offsetWidth,
height: this.measurer.offsetHeight
};
}

measureTextWithLineBreaks(text, fontSize, maxWidth, fontFamily = 'inherit', fontWeight = 'inherit') {
this.measurer.style.fontSize = fontSize + 'px';
this.measurer.style.fontFamily = fontFamily;
this.measurer.style.fontWeight = fontWeight;
this.measurer.style.width = maxWidth + 'px';
this.measurer.style.whiteSpace = 'normal';
this.measurer.style.wordWrap = 'break-word';
this.measurer.textContent = text;

return {
width: this.measurer.offsetWidth,
height: this.measurer.offsetHeight
};
}

cleanup() {
if (this.measurer.parentNode) {
this.measurer.parentNode.removeChild(this.measurer);
}
}
}

高级文本处理功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
// components/responsive-text-container/advanced-text-processor.JS
export class AdvancedTextProcessor {
constructor() {
this.textMeasurer = new TextMeasurer();
}

// 计算最适合的字体大小 calculateOptimalFontSize(text, containerWidth, containerHeight, options = {}) {
const {
minFontSize = 12,
maxFontSize = 24,
fontFamily = 'inherit',
fontWeight = 'inherit',
lineHeight = 1.2
} = options;

// 二分查找最优字体大小 let low = minFontSize;
let high = maxFontSize;
let optimalSize = minFontSize;

while (low <= high) {
const mid = Math.floor((low + high) / 2);
const fits = this.checkTextFits(text, containerWidth, containerHeight, mid, {
fontFamily,
fontWeight,
lineHeight
});

if (fits) {
optimalSize = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}

return optimalSize;
}

// 检查文本是否适应容器 checkTextFits(text, containerWidth, containerHeight, fontSize, options = {}) {
const { fontFamily, fontWeight, lineHeight } = options;
const measured = this.textMeasurer.measureTextWithLineBreaks(
text,
fontSize,
containerWidth,
fontFamily,
fontWeight
);

return measured.width <= containerWidth && measured.height <= containerHeight;
}

// 智能截断文本 smartTruncate(text, containerWidth, containerHeight, fontSize, options = {}) {
const { suffix = '...', fontFamily, fontWeight, lineHeight } = options;

// 首先检查完整文本是否适应 if (this.checkTextFits(text, containerWidth, containerHeight, fontSize, {
fontFamily, fontWeight, lineHeight
})) {
return text;
}

// 逐步截断文本 let left = 0;
let right = text.length;
let result = '';

while (left <= right) {
const mid = Math.floor((left + right) / 2);
const truncated = text.substring(0, mid) + suffix;

if (this.checkTextFits(truncated, containerWidth, containerHeight, fontSize, {
fontFamily, fontWeight, lineHeight
})) {
result = truncated;
left = mid + 1;
} else {
right = mid - 1;
}
}

return result;
}

// 多行文本处理 processMultilineText(text, containerWidth, containerHeight, options = {}) {
const {
fontSize = 16,
fontFamily = 'inherit',
fontWeight = 'inherit',
lineHeight = 1.2,
maxLines = Infinity
} = options;

// 检查单行是否适应 const singleLineFits = this.checkTextFits(text, containerWidth, containerHeight, fontSize, {
fontFamily, fontWeight, lineHeight
});

if (singleLineFits) {
return {
text,
lines: 1,
fits: true
};
}

// 按单词分割文本 const words = text.split(/\s+/);
let lines = [];
let currentLine = '';

for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
const testLineFits = this.checkTextFits(testLine, containerWidth, containerHeight, fontSize, {
fontFamily, fontWeight, lineHeight
});

if (testLineFits && lines.length + 1 <= maxLines) {
currentLine = testLine;
} else {
if (currentLine) {
lines.push(currentLine);
}

// 如果单个词就超过了宽度,需要截断 if (lines.length >= maxLines) {
break;
}

currentLine = word;
}
}

if (currentLine) {
lines.push(currentLine);
}

const resultText = lines.join('\n');
const totalHeight = lines.length * fontSize * lineHeight;

return {
text: resultText,
lines: lines.length,
fits: totalHeight <= containerHeight
};
}

destroy() {
this.textMeasurer.cleanup();
}
}

性能优化版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
// components/responsive-text-container/optimized-responsive-text-container.JS
class OptimizedResponsiveTextContainer extends HtmlElement {
constructor() {
super();

this.shadow = this.attachShadow({ mode: 'open' });

// 使用 requestAnimationFrame 优化重绘 this.rafId = null;
this.needsRecalculation = false;

// 节流配置 this.resizeThrottle = null;
this.throttleDelay = 100;

// 初始化 this.state = {
fontSize: 16,
isTruncated: false,
originalText: '',
truncatedText: '',
animationFrame: null
};

this.config = {
minFontSize: 12,
maxFontSize: 24,
lineHeight: 1.2,
truncateSuffix: '...',
enableTooltip: true
};
}

connectedCallback() {
this.initConfig();
this.render();
this.setupResizeObserver();
this.setupEventListeners();

// 使用 MutationObserver 监听文本变化 this.mutationObserver = new MutationObserver(() => {
this.scheduleRecalculation();
});

this.mutationObserver.observe(this, {
attributes: true,
attributeFilter: ['text']
});

// 初始计算 this.scheduleRecalculation();
}

disconnectedCallback() {
this.cleanup();
}

// 调度重新计算(节流)
scheduleRecalculation() {
if (!this.needsRecalculation) {
this.needsRecalculation = true;
this.animationFrame = requestAnimationFrame(() => {
this.calculateFontSize();
this.needsRecalculation = false;
});
}
}

// 高效的字体大小计算 calculateFontSize() {
if (!this.textElement || !this.state.originalText || this.needsRecalculation) {
return;
}

const containerWidth = this.getBoundingClientRect().width;
const containerHeight = this.getBoundingClientRect().height;

if (containerWidth <= 0 || containerHeight <= 0) {
return;
}

// 使用二分查找优化性能 let minSize = this.config.minFontSize;
let maxSize = this.config.maxFontSize;
let optimalSize = minSize;

while (minSize <= maxSize) {
const midSize = Math.floor((minSize + maxSize) / 2);

if (this.testFontSize(midSize)) {
optimalSize = midSize;
minSize = midSize + 1;
} else {
maxSize = midSize - 1;
}
}

// 应用计算出的字体大小 this.applyFontSize(optimalSize);
}

// 测试字体大小是否合适(使用离线 canvas 进行快速测量)
testFontSize(fontSize) {
if (!this.textElement) return false;

// 创建临时 canvas 进行快速测量 const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

ctx.font = `${fontSize}px ${getComputedStyle(this.textElement).fontFamily}`;
const textWidth = ctx.measureText(this.state.originalText).width;

const containerWidth = this.textElement.clientWidth;
const containerHeight = this.textElement.clientHeight;

// 简单的宽高估算(实际应用中可能需要更精确的计算)
const estimatedHeight = fontSize * this.config.lineHeight;

return textWidth <= containerWidth && estimatedHeight <= containerHeight;
}

// 应用字体大小 applyFontSize(fontSize) {
if (this.textElement) {
this.textElement.style.fontSize = fontSize + 'px';
}

this.state.fontSize = fontSize;
}

// 其他方法保持不变...
render() {
this.shadow.innerHTML = this.getTemplate();
this.applyStyles();
this.textElement = this.shadow.querySelector('.responsive-text');
this.tooltipElement = this.shadow.querySelector('.tooltip');
}

getTemplate() {
return `
<style>${this.getCss()}</style>
<div class="container">
<div class="responsive-text-wrapper">
<span class="responsive-text"></span>
</div>
<div class="tooltip hidden" role="tooltip">
<div class="tooltip-content"></div>
</div>
</div>
`;
}

getCss() {
return `
:host {
display: block;
width: 100%;
height: 100%;
}

.container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}

.responsive-text-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 4px;
box-sizing: border-box;
}

.responsive-text {
font-family: inherit;
font-weight: inherit;
color: inherit;
word-break: break-word;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: font-size 0.1s ease;
}

.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
white-space: nowrap;
z-index: 1000;
max-width: 300px;
word-wrap: break-word;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
}

.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
}

.tooltip.visible {
opacity: 1;
visibility: visible;
}

.tooltip.hidden {
display: none;
}
`;
}

setText(text) {
this.state.originalText = text || '';
if (this.textElement) {
this.textElement.textContent = this.state.originalText;
}
this.scheduleRecalculation();
}

setupResizeObserver() {
if (window.ResizeObserver) {
this.resizeObserver = new ResizeObserver((entries) => {
// 使用节流避免频繁计算 if (this.resizeThrottle) {
clearTimeout(this.resizeThrottle);
}

this.resizeThrottle = setTimeout(() => {
this.scheduleRecalculation();
}, this.throttleDelay);
});
this.resizeObserver.observe(this);
}
}

setupEventListeners() {
if (this.config.enableTooltip) {
if (this.textElement) {
this.textElement.addEventListener('mouseenter', this.handleMouseEnter.bind(this));
this.textElement.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
this.textElement.addEventListener('touchstart', this.handleTouchStart.bind(this));
this.textElement.addEventListener('touchend', this.handleTouchEnd.bind(this));
}
}
}

cleanup() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}

if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
}

if (this.resizeThrottle) {
clearTimeout(this.resizeThrottle);
}

if (this.mutationObserver) {
this.mutationObserver.disconnect();
}
}

// 事件处理器 handleMouseEnter() {
if (this.state.isTruncated) {
this.showTooltip();
}
}

handleMouseLeave() {
this.hideTooltip();
}

handleTouchStart() {
if (this.state.isTruncated) {
this.touchTimer = setTimeout(() => {
this.showTooltip();
}, 500);
}
}

handleTouchEnd() {
if (this.touchTimer) {
clearTimeout(this.touchTimer);
this.touchTimer = null;
}

if (this.state.showTooltip) {
this.hideTooltip();
}
}

showTooltip() {
if (this.tooltipElement && this.config.enableTooltip) {
const tooltipContent = this.tooltipElement.querySelector('.tooltip-content');
if (tooltipContent) {
tooltipContent.textContent = this.state.originalText;
}

this.tooltipElement.classList.remove('hidden');
this.tooltipElement.classList.add('visible');
this.state.showTooltip = true;
}
}

hideTooltip() {
if (this.tooltipElement) {
this.tooltipElement.classList.remove('visible');
setTimeout(() => {
this.tooltipElement.classList.add('hidden');
}, 200);
this.state.showTooltip = false;
}
}
}

// 注册优化版本 customElements.define('optimized-responsive-text-container', OptimizedResponsiveTextContainer);

组件入口文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// components/responsive-text-container/index.JS
import { ResponsiveTextContainer } from './responsive-text-container.JS';
import { OptimizedResponsiveTextContainer } from './optimized-responsive-text-container.JS';
import { TextMeasurer } from './text-measurer.JS';
import { AdvancedTextProcessor } from './advanced-text-processor.JS';

// 确保 DOM 加载完成后注册组件 if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
registerComponents();
});
} else {
registerComponents();
}

function registerComponents() {
// 注册普通版本 if (!customElements.get('responsive-text-container')) {
customElements.define('responsive-text-container', ResponsiveTextContainer);
}

// 注册优化版本 if (!customElements.get('optimized-responsive-text-container')) {
customElements.define('optimized-responsive-text-container', OptimizedResponsiveTextContainer);
}
}

// 提供工厂函数 export function createResponsiveTextContainer(options = {}) {
const container = document.createElement('responsive-text-container');

if (options.text !== undefined) {
container.setAttribute('text', options.text);
}

if (options.minFontSize !== undefined) {
container.setAttribute('min-font-size', options.minFontSize.toString());
}

if (options.maxFontSize !== undefined) {
container.setAttribute('max-font-size', options.maxFontSize.toString());
}

if (options.enableTooltip !== undefined) {
container.setAttribute('enable-tooltip', options.enableTooltip.toString());
}

return container;
}

// 导出类和工具 export {
ResponsiveTextContainer,
OptimizedResponsiveTextContainer,
TextMeasurer,
AdvancedTextProcessor
};

// 全局命名空间导出 window.ResponsiveTextComponents = {
createResponsiveTextContainer,
ResponsiveTextContainer,
OptimizedResponsiveTextContainer,
TextMeasurer,
AdvancedTextProcessor
};

使用示例

基本 Html 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
<!DOCTYPE Html>
<Html>
<head>
<title>Responsive Text Container Demo</title>
<style>
.demo-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
padding: 20px;
}

.demo-item {
border: 1px solid #ddd;
padding: 10px;
border-radius: 4px;
min-height: 50px;
background: #f9f9f9;
}

.fixed-size-box {
width: 150px;
height: 50px;
border: 1px solid #ccc;
margin: 10px 0;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<h1>响应式文本容器演示</h1>

<div class="demo-container">
<div class="demo-item">
<h3>短文本</h3>
<div class="fixed-size-box">
<responsive-text-container text="Hello World" min-font-size="10" max-font-size="20"></responsive-text-container>
</div>
</div>

<div class="demo-item">
<h3>长文本</h3>
<div class="fixed-size-box">
<responsive-text-container
text="这是一个很长的文本内容,需要根据容器大小自动调整字体大小以确保完整显示"
min-font-size="10"
max-font-size="16">
</responsive-text-container>
</div>
</div>

<div class="demo-item">
<h3>超长文本</h3>
<div class="fixed-size-box">
<responsive-text-container
text="这是一个超级超级超级超级超级超级超级超级超级超级超级长的文本内容,即使调整到最小字体也无法完全显示,这时会显示省略号并提供悬停查看完整内容的功能"
min-font-size="8"
max-font-size="14">
</responsive-text-container>
</div>
</div>

<div class="demo-item">
<h3>动态文本</h3>
<div class="fixed-size-box">
<responsive-text-container id="dynamic-text" min-font-size="8" max-font-size="18"></responsive-text-container>
</div>
<button onclick="changeDynamicText()">改变文本</button>
</div>
</div>

<script type="module">
import { createResponsiveTextContainer } from './components/responsive-text-container/index.JS';

// 动态改变文本 let textOptions = [
'短文本',
'这是一个中等长度的文本内容',
'这是一个非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常长的文本',
'超超超超超超超超超超超超超超超超超超超超超超超超超超超超超超超超超超超超超超长文本'
];
let currentIndex = 0;

function changeDynamicText() {
const container = document.getElementById('dynamic-text');
container.setAttribute('text', textOptions[currentIndex]);
currentIndex = (currentIndex + 1) % textOptions.length;
}

// 监听字体大小变化事件 document.addEventListener('font-size-change', (event) => {
console.log('字体大小变化:', event.detail);
});
</script>
</body>
</Html>

React 中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import React, { useEffect, useRef } from 'React';

const ResponsiveTextContainer = ({ text, minFontSize = 12, maxFontSize = 24, enableTooltip = true }) => {
const containerRef = useRef(null);

useEffect(() => {
const container = containerRef.current;
if (container) {
container.setAttribute('text', text);
container.setAttribute('min-font-size', minFontSize.toString());
container.setAttribute('max-font-size', maxFontSize.toString());
container.setAttribute('enable-tooltip', enableTooltip.toString());
}
}, [text, minFontSize, maxFontSize, enableTooltip]);

useEffect(() => {
// 确保组件已注册 if (!customElements.get('responsive-text-container')) {
import('./components/responsive-text-container/index.JS');
}
}, []);

return <responsive-text-container ref={containerRef}></responsive-text-container>;
};

// 使用示例 const App = () => {
const [texts] = useState([
'短文本',
'这是一个中等长度的文本内容',
'这是一个非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常长的文本'
]);
const [currentIndex, setCurrentIndex] = useState(0);

return (
<div>
<ResponsiveTextContainer
text={texts[currentIndex]}
minFontSize={10}
maxFontSize={20}
/>
<button onClick={() => setCurrentIndex((prev) => (prev + 1) % texts.length)}>
更改文本
</button>
</div>
);
};

Vue 中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<template>
<div>
<responsive-text-container
ref="textContainer"
:text="currentText"
:min-font-size="minFontSize"
:max-font-size="maxFontSize"
:enable-tooltip="enableTooltip"
></responsive-text-container>
<button @click="changeText">更改文本</button>
</div>
</template>

<script>
export default {
name: 'ResponsiveTextDemo',
data() {
return {
texts: [
'短文本',
'这是一个中等长度的文本内容',
'这是一个非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常长的文本'
],
currentIndex: 0,
minFontSize: 10,
maxFontSize: 20,
enableTooltip: true
};
},
computed: {
currentText() {
return this.texts[this.currentIndex];
}
},
mounted() {
// 确保 Web Component 已注册 if (!customElements.get('responsive-text-container')) {
import('./components/responsive-text-container/index.JS');
}
},
methods: {
changeText() {
this.currentIndex = (this.currentIndex + 1) % this.texts.length;
}
}
};
</script>

性能优化建议

1. 避免频繁的 DOM 操作

1
2
3
4
5
6
7
8
9
10
11
// 使用文档片段批量操作 function batchUpdate(elements) {
const fragment = document.createDocumentFragment();

elements.forEach(el => {
const newEl = document.createElement('div');
newEl.textContent = el.text;
fragment.appendChild(newEl);
});

container.appendChild(fragment);
}

2. 使用虚拟滚动处理大量文本项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 虚拟滚动示例 class VirtualTextScroller {
constructor(container, items, itemHeight = 50) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.visibleCount = Math.ceil(container.clientHeight / itemHeight) + 2;
this.startIndex = 0;

this.setup();
}

setup() {
this.container.style.height = `${this.items.length * this.itemHeight}px`;
this.container.style.overflow = 'auto';

this.renderVisibleItems();
this.container.addEventListener('scroll', () => this.handleScroll());
}

handleScroll() {
const scrollTop = this.container.scrollTop;
const startIndex = Math.floor(scrollTop / this.itemHeight);

if (startIndex !== this.startIndex) {
this.startIndex = startIndex;
this.renderVisibleItems();
}
}

renderVisibleItems() {
const endIndex = Math.min(this.startIndex + this.visibleCount, this.items.length);
this.container.innerHTML = ''; // 清空 for (let i = this.startIndex; i < endIndex; i++) {
const itemContainer = document.createElement('div');
itemContainer.style.cssText = `
position: absolute;
top: ${i * this.itemHeight}px;
height: ${this.itemHeight}px;
width: 100%;
`;

const responsiveText = document.createElement('responsive-text-container');
responsiveText.setAttribute('text', this.items[i].text);
responsiveText.setAttribute('min-font-size', '10');
responsiveText.setAttribute('max-font-size', '16');

itemContainer.appendChild(responsiveText);
this.container.appendChild(itemContainer);
}
}
}

最佳实践

1. 组件设计最佳实践

  • 使用 Shadow DOM 确保样式的隔离性
  • 实现适当的事件处理和资源清理
  • 提供合理的默认配置和属性验证
  • 考虑可访问性,如 ARIA 标签和键盘导航

2. 性能优化最佳实践

  • 使用 ResizeObserver 而非 window.resize 事件
  • 实现防抖和节流机制
  • 使用 requestAnimationFrame 优化动画
  • 避免在计算过程中进行 DOM 查询

3. 代码组织最佳实践

1
2
3
4
5
6
7
8
9
// 清晰的文件结构
// components/
// responsive-text-container/
// index.JS # 入口文件
// responsive-text-container.JS # 主组件
// text-measurer.JS # 文本测量工具
// advanced-text-processor.JS # 高级文本处理
// optimized-container.JS # 优化版本
// styles.Css # 样式文件

总结

  • 响应式文本容器解决了文本长度不确定但容器尺寸固定的问题
  • Web Components 提供了跨框架复用的能力
  • 智能的字体大小计算确保了最佳的文本可读性
  • 省略号和工具提示提供了完整的用户体验
  • 性能优化确保了在复杂场景下的流畅运行
  • 模块化设计便于维护和扩展

最近做的移动端项目中,这个响应式文本容器帮了大忙。以前处理文本溢出总是在 Css 上想办法,现在有了这个组件,无论文本多长都能优雅地显示,用户体验好了不少。特别是触摸设备上的长按查看功能,用户反馈很好。

扩展阅读

  • Web Components Specifications
  • Responsive Design Principles
  • Text Rendering Hacks
  • Css Font Loading
  • Accessibility Guidelines

参考资料

  • Web Components Working Group: https://www.w3.org/WebComponents/
  • Resize Observer API: https://drafts.csswg.org/resize-observer/
  • Font Loading API: https://www.w3.org/TR/Css-font-loading/
  • Web Accessibility Initiative: https://www.w3.org/WAI/
bulb