JS实现多行溢出省略号思路

可能是最全的 “文本溢出截断省略” 方案合集

https://github.com/libin1991/ellipsis-text

说起多行溢出省略号,用CSS实现最简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.one-line {
display: -webkit-box !important;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
/*clip 修剪文本。*/
}

.more-line {
display: -webkit-box !important;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}

下面就摸索下用JS如何实现:

Github DEMO

先看两个API:

getClientRects 获取元素占据页面的所有矩形区域 :

getClientRects 返回一个TextRectangle集合,就是TextRectangleList对象。TextRectangle对象包含了, top left bottom right width height 六个属性TextRectangle对于文本对象,W3C提供了一个 TextRectangle 对象,这个对象是对文本区域的一个解释。这里的文本区域只针对inline
元素,比如:a, span, em这类标签元素。浏览器差异getClientRects() 最先由MS IE提出,后被W3C引入并制订了标准。目前主流浏览器都支持该标准,而IE只支持TextRectangle的top left bottom right四个属性。IE下可以通过right-left来计算width、bottom-top来计算height。ie 和非ie浏览器在使用getClientRects还是有些差别的,ie获取TextRectangleList的范围很大。而非ie获取的范围比较小,
只有display:inline的对象才能获取到TextRectangleList,例如em i span 等标签。应用场景getClientRects常用于获取鼠标的位置,如放大镜效果。微博的用户信息卡也是通过改方法获得的。
总结:只能用于行内元素,返回每一行的信息,返回信息和getBoundingClientRect返回类似。

DEMO:

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
<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<title></title>
<style type="text/css">
* {
padding: 0px;
margin: 0px;
}

div {
width: 80%;
height: 90px;
border: 1px solid red;
}

#test{
width:400px;
height: 10px;
background: red;
}
</style>
</head>

<body>
<div>
<span id="main">返回值是ClientRect对象集合,该对象是与该元素相关的CSS边框。每个ClientRect对象包含一组描述该边框的只读属性——left、top、right和bottom,单位为像素,这些属性值是相对于视口的top-left的。即使当表格的标题在表格的边框外面,该标题仍会被计算在内。</span>
</div>
<div id="test"></div>
</body>
<script type="text/javascript">
console.log(document.getElementById("main").getClientRects());

</script>

</html>

getBoundingClientRect获取元素位置 getBoundingClientRect用于获得页面中某个元素的左,上,右和下分别相对浏览器视窗的位置。getBoundingClientRect是DOM元素到浏览器可视范围的距离(不包含文档卷起的部分)。该函数返回一个Object对象,该对象有6个属性:top,lef,right,bottom,width,height;这里的top、left和css中的理解很相似,width、height是元素自身的宽高,但是right,bottom和css中的理解有点不一样。right是指元素右边界距窗口最左边的距离,bottom是指元素下边界距窗口最上面的距离。

getBoundingClientRect()最先是IE的私有属性,现在已经是一个W3C标准。所以你不用当心浏览器兼容问题,不过还是有区别的:IE只返回top,lef,right,bottom四个值,

返回差异:

getClientRects 和 getBoundingClientRect 的区别返回类型差异: getClientRects 返回一个TextRectangle集合,就是TextRectangleList对象。getBoundingClientRect返回 一个TextRectangle对象,即使DOM里没有文本也能返回TextRectangle对象.浏览器差异:除了safari,firefox2.0外所有浏览器都支持getClientRects和getBoundingClientRect,firefox 3.1给TextRectangle增加了 width 和 height。ie 和非ie浏览器在使用getClientRects还是有些差别的,ie获取TextRectangleList的范围很大。而非ie获取的范围比较小, 只有display:inline的对象才能获取到TextRectangleList,例如em i span 等标签。通过测试,至少Chrome 2+\Safari 4\Firefox3.5\0pera 9.63+已经支持getBoundingClientRect方法。使用场景差异:出于浏览器兼容的考虑,现在用得最多的是getBoundingClientRect,经常用来获取一个element元素的viewport坐标。

Vue多行溢出省略号

监听DOM尺寸变化

1
2
3
4
5
6
7
8
9
10
11
import { addListener, removeListener } from 'resize-detector'

if (this.autoresize) {
let resizeCallback = () => {
this.update()
}
addListener(this.$el, resizeCallback) //监听
this.unregisterResizeCallback = () => { //移除
removeListener(this.$el, resizeCallback)
}
}

判断是否溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
isOverflow () {
if (!this.maxLines && !this.maxHeight) {
return false
}

if (this.maxLines) {
//获取全部显示的行数
let actualLines = this.$refs.content.getClientRects().length
if (actualLines > this.maxLines) {
return true
}
}

if (this.maxHeight) {
if (this.$el.scrollHeight > this.$el.offsetHeight) {
return true
}
}
return false
},

二分查找多行截取字符临界值

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
moveEdge (steps) {
this.clampAt(this.offset + steps)
},
clampAt (offset) {
this.offset = offset
this.applyChange()
},
applyChange () {
this.$refs.text.textContent = this.realText
},
stepToFit () {
this.fill()
this.clamp()
},
fill () {
while (!this.isOverflow() && this.offset < this.text.length) {
this.moveEdge(1)
}
},
clamp () {
while (this.isOverflow() && this.offset > 0) {
this.moveEdge(-1)
}
},
search (...range) {
let [from = 0, to = this.offset] = range
if (to - from <= 3) {
this.stepToFit()
return
}
let target = Math.floor((to + from) / 2) //从中间找临界值
this.clampAt(target)
if (this.isOverflow()) {
this.search(from, target)
} else {
this.search(target, to)
}
}

完整代码:

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
import { addListener, removeListener } from 'resize-detector'
import Vue from 'vue'

const UPDATE_TRIGGERS = ['maxLines', 'maxHeight', 'ellipsis']
const INIT_TRIGGERS = ['tag', 'text', 'autoresize']

export default {
name: 'vue-clamper',
props: {
tag: {
type: String,
default: 'div'
},
autoresize: {
type: Boolean,
default: false
},
maxLines: Number,
maxHeight: [String, Number],
ellipsis: {
type: String,
default: '…'
},
expanded: Boolean
},
data () {
return {
offset: null,
text: this.getText(),
localExpanded: !!this.expanded
}
},
computed: {
clampedText () {
return this.text.slice(0, this.offset) + this.ellipsis
},
isClamped () {
if (!this.text) {
return false
}
return this.offset !== this.text.length
},
realText () {
return this.isClamped ? this.clampedText : this.text
},
realMaxHeight () {
if (this.localExpanded) {
return null
}
let { maxHeight } = this
if (!maxHeight) {
return null
}
return typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
}
},
watch: {
expanded (val) {
this.localExpanded = val
},
localExpanded (val) {
if (val) {
this.clampAt(this.text.length)
} else {
this.update()
}
if (this.expanded !== val) {
this.$emit('update:expanded', val)
}
}
},
mounted () {
this.init()

INIT_TRIGGERS.forEach(prop => {
this.$watch(prop, this.init)
})

UPDATE_TRIGGERS.forEach(prop => {
this.$watch(prop, this.update)
})
},
updated () {
this.text = this.getText()
this.applyChange()
},
beforeDestroy () {
this.cleanUp()
},
methods: {
init () {
let contents = this.$slots.default
if (!contents) {
return
}
if (Array.isArray(contents) && contents.length > 1) {
Vue.util.warn(
'VueClamper only supports clamping plain text content.',
this
)
return
}
let [content] = contents
if (content && content.tag) {
Vue.util.warn(
'VueClamper only supports clamping plain text content.',
this
)
return
}

this.offset = this.text.length

this.cleanUp()

if (this.autoresize) {
let resizeCallback = () => {
this.update()
}
addListener(this.$el, resizeCallback)
this.unregisterResizeCallback = () => {
removeListener(this.$el, resizeCallback)
}
}
this.update()
},
update () {
if (this.localExpanded) {
return
}
this.applyChange()
if (this.isOverflow() || this.isClamped) {
this.search()
}
},
expand () {
this.localExpanded = true
},
collapse () {
this.localExpanded = false
},
toggle () {
this.localExpanded = !this.localExpanded
},
isOverflow () {
if (!this.maxLines && !this.maxHeight) {
return false
}

if (this.maxLines) {
let actualLines = this.$refs.content.getClientRects().length
if (actualLines > this.maxLines) {
return true
}
}

if (this.maxHeight) {
if (this.$el.scrollHeight > this.$el.offsetHeight) {
return true
}
}
return false
},
getText () {
let [content] = this.$slots.default || []
return content ? content.text : ''
},
moveEdge (steps) {
this.clampAt(this.offset + steps)
},
clampAt (offset) {
this.offset = offset
this.applyChange()
},
applyChange () {
this.$refs.text.textContent = this.realText
},
stepToFit () {
this.fill()
this.clamp()
},
fill () {
while (!this.isOverflow() && this.offset < this.text.length) {
this.moveEdge(1)
}
},
clamp () {
while (this.isOverflow() && this.offset > 0) {
this.moveEdge(-1)
}
},
search (...range) {
let [from = 0, to = this.offset] = range
if (to - from <= 3) {
this.stepToFit()
return
}
let target = Math.floor((to + from) / 2)
this.clampAt(target)
if (this.isOverflow()) {
this.search(from, target)
} else {
this.search(target, to)
}
},
cleanUp () {
if (this.unregisterResizeCallback) {
this.unregisterResizeCallback()
}
}
},
render (h) {
let contents = [
h(
'span',
{
ref: 'text',
attrs: {
'aria-label': this.text.trim()
}
},
this.realText
)
]

let { expand, collapse, toggle } = this
let scope = { expand, collapse, toggle }
let before = this.$scopedSlots.before
? this.$scopedSlots.before(scope)
: this.$slots.before
if (before) {
contents.unshift(...(Array.isArray(before) ? before : [before]))
}
let after = this.$scopedSlots.after
? this.$scopedSlots.after(scope)
: this.$slots.after
if (after) {
contents.push(...(Array.isArray(after) ? after : [after]))
}
let lines = [
h(
'span',
{
style: {
boxShadow: 'transparent 0 0'
},
ref: 'content'
},
contents
)
]
return h(
this.tag,
{
style: {
maxHeight: this.realMaxHeight,
overflow: 'hidden'
}
},
lines
)
}
}

使用:

1
import VClamp from './components/Clamp.js'

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<v-clamp
:class="{
demo: true,
hyphens: hyphens1
}"
:max-lines="lines"
autoresize
:style="{
width: `${width1}px`
}"
>
{{ zh ? textZh : text }}
<button
slot="after"
slot-scope="{ toggle }"
class="toggle btn btn-sm"
@click="toggle"
>
{{ zh ? '切换' : 'Toggle' }}
</button>
</v-clamp>