-
-
Notifications
You must be signed in to change notification settings - Fork 8.4k
/
stringifyStatic.ts
318 lines (298 loc) · 9.35 KB
/
stringifyStatic.ts
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
/**
* This module is Node-only.
*/
import {
NodeTypes,
ElementNode,
TransformContext,
TemplateChildNode,
SimpleExpressionNode,
createCallExpression,
HoistTransform,
CREATE_STATIC,
ExpressionNode,
ElementTypes,
PlainElementNode,
JSChildNode,
TextCallNode
} from '@vue/compiler-core'
import {
isVoidTag,
isString,
isSymbol,
isKnownAttr,
escapeHtml,
toDisplayString,
normalizeClass,
normalizeStyle,
stringifyStyle,
makeMap
} from '@vue/shared'
export const enum StringifyThresholds {
ELEMENT_WITH_BINDING_COUNT = 5,
NODE_COUNT = 20
}
type StringifiableNode = PlainElementNode | TextCallNode
/**
* Turn eligible hoisted static trees into stringified static nodes, e.g.
*
* ```js
* const _hoisted_1 = createStaticVNode(`<div class="foo">bar</div>`)
* ```
*
* A single static vnode can contain stringified content for **multiple**
* consecutive nodes (element and plain text), called a "chunk".
* `@vue/runtime-dom` will create the content via innerHTML in a hidden
* container element and insert all the nodes in place. The call must also
* provide the number of nodes contained in the chunk so that during hydration
* we can know how many nodes the static vnode should adopt.
*
* The optimization scans a children list that contains hoisted nodes, and
* tries to find the largest chunk of consecutive hoisted nodes before running
* into a non-hoisted node or the end of the list. A chunk is then converted
* into a single static vnode and replaces the hoisted expression of the first
* node in the chunk. Other nodes in the chunk are considered "merged" and
* therefore removed from both the hoist list and the children array.
*
* This optimization is only performed in Node.js.
*/
export const stringifyStatic: HoistTransform = (children, context, parent) => {
// bail stringification for slot content
if (context.scopes.vSlot > 0) {
return
}
let nc = 0 // current node count
let ec = 0 // current element with binding count
const currentChunk: StringifiableNode[] = []
const stringifyCurrentChunk = (currentIndex: number): number => {
if (
nc >= StringifyThresholds.NODE_COUNT ||
ec >= StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
) {
// combine all currently eligible nodes into a single static vnode call
const staticCall = createCallExpression(context.helper(CREATE_STATIC), [
JSON.stringify(
currentChunk.map(node => stringifyNode(node, context)).join('')
),
// the 2nd argument indicates the number of DOM nodes this static vnode
// will insert / hydrate
String(currentChunk.length)
])
// replace the first node's hoisted expression with the static vnode call
replaceHoist(currentChunk[0], staticCall, context)
if (currentChunk.length > 1) {
for (let i = 1; i < currentChunk.length; i++) {
// for the merged nodes, set their hoisted expression to null
replaceHoist(currentChunk[i], null, context)
}
// also remove merged nodes from children
const deleteCount = currentChunk.length - 1
children.splice(currentIndex - currentChunk.length + 1, deleteCount)
return deleteCount
}
}
return 0
}
let i = 0
for (; i < children.length; i++) {
const child = children[i]
const hoisted = getHoistedNode(child)
if (hoisted) {
// presence of hoisted means child must be a stringifiable node
const node = child as StringifiableNode
const result = analyzeNode(node)
if (result) {
// node is stringifiable, record state
nc += result[0]
ec += result[1]
currentChunk.push(node)
continue
}
}
// we only reach here if we ran into a node that is not stringifiable
// check if currently analyzed nodes meet criteria for stringification.
// adjust iteration index
i -= stringifyCurrentChunk(i)
// reset state
nc = 0
ec = 0
currentChunk.length = 0
}
// in case the last node was also stringifiable
stringifyCurrentChunk(i)
}
const getHoistedNode = (node: TemplateChildNode) =>
((node.type === NodeTypes.ELEMENT && node.tagType === ElementTypes.ELEMENT) ||
node.type == NodeTypes.TEXT_CALL) &&
node.codegenNode &&
node.codegenNode.type === NodeTypes.SIMPLE_EXPRESSION &&
node.codegenNode.hoisted
const dataAriaRE = /^(data|aria)-/
const isStringifiableAttr = (name: string) => {
return isKnownAttr(name) || dataAriaRE.test(name)
}
const replaceHoist = (
node: StringifiableNode,
replacement: JSChildNode | null,
context: TransformContext
) => {
const hoistToReplace = (node.codegenNode as SimpleExpressionNode).hoisted!
context.hoists[context.hoists.indexOf(hoistToReplace)] = replacement
}
const isNonStringifiable = /*#__PURE__*/ makeMap(
`caption,thead,tr,th,tbody,td,tfoot,colgroup,col`
)
/**
* for a hoisted node, analyze it and return:
* - false: bailed (contains runtime constant)
* - [nc, ec] where
* - nc is the number of nodes inside
* - ec is the number of element with bindings inside
*/
function analyzeNode(node: StringifiableNode): [number, number] | false {
if (node.type === NodeTypes.ELEMENT && isNonStringifiable(node.tag)) {
return false
}
if (node.type === NodeTypes.TEXT_CALL) {
return [1, 0]
}
let nc = 1 // node count
let ec = node.props.length > 0 ? 1 : 0 // element w/ binding count
let bailed = false
const bail = (): false => {
bailed = true
return false
}
// TODO: check for cases where using innerHTML will result in different
// output compared to imperative node insertions.
// probably only need to check for most common case
// i.e. non-phrasing-content tags inside `<p>`
function walk(node: ElementNode): boolean {
for (let i = 0; i < node.props.length; i++) {
const p = node.props[i]
// bail on non-attr bindings
if (p.type === NodeTypes.ATTRIBUTE && !isStringifiableAttr(p.name)) {
return bail()
}
if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') {
// bail on non-attr bindings
if (
p.arg &&
(p.arg.type === NodeTypes.COMPOUND_EXPRESSION ||
(p.arg.isStatic && !isStringifiableAttr(p.arg.content)))
) {
return bail()
}
}
}
for (let i = 0; i < node.children.length; i++) {
nc++
const child = node.children[i]
if (child.type === NodeTypes.ELEMENT) {
if (child.props.length > 0) {
ec++
}
walk(child)
if (bailed) {
return false
}
}
}
return true
}
return walk(node) ? [nc, ec] : false
}
function stringifyNode(
node: string | TemplateChildNode,
context: TransformContext
): string {
if (isString(node)) {
return node
}
if (isSymbol(node)) {
return ``
}
switch (node.type) {
case NodeTypes.ELEMENT:
return stringifyElement(node, context)
case NodeTypes.TEXT:
return escapeHtml(node.content)
case NodeTypes.COMMENT:
return `<!--${escapeHtml(node.content)}-->`
case NodeTypes.INTERPOLATION:
return escapeHtml(toDisplayString(evaluateConstant(node.content)))
case NodeTypes.COMPOUND_EXPRESSION:
return escapeHtml(evaluateConstant(node))
case NodeTypes.TEXT_CALL:
return stringifyNode(node.content, context)
default:
// static trees will not contain if/for nodes
return ''
}
}
function stringifyElement(
node: ElementNode,
context: TransformContext
): string {
let res = `<${node.tag}`
for (let i = 0; i < node.props.length; i++) {
const p = node.props[i]
if (p.type === NodeTypes.ATTRIBUTE) {
res += ` ${p.name}`
if (p.value) {
res += `="${escapeHtml(p.value.content)}"`
}
} else if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') {
// constant v-bind, e.g. :foo="1"
let evaluated = evaluateConstant(p.exp as SimpleExpressionNode)
const arg = p.arg && (p.arg as SimpleExpressionNode).content
if (arg === 'class') {
evaluated = normalizeClass(evaluated)
} else if (arg === 'style') {
evaluated = stringifyStyle(normalizeStyle(evaluated))
}
res += ` ${(p.arg as SimpleExpressionNode).content}="${escapeHtml(
evaluated
)}"`
}
}
if (context.scopeId) {
res += ` ${context.scopeId}`
}
res += `>`
for (let i = 0; i < node.children.length; i++) {
res += stringifyNode(node.children[i], context)
}
if (!isVoidTag(node.tag)) {
res += `</${node.tag}>`
}
return res
}
// __UNSAFE__
// Reason: eval.
// It's technically safe to eval because only constant expressions are possible
// here, e.g. `{{ 1 }}` or `{{ 'foo' }}`
// in addition, constant exps bail on presence of parens so you can't even
// run JSFuck in here. But we mark it unsafe for security review purposes.
// (see compiler-core/src/transformExpressions)
function evaluateConstant(exp: ExpressionNode): string {
if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
return new Function(`return ${exp.content}`)()
} else {
// compound
let res = ``
exp.children.forEach(c => {
if (isString(c) || isSymbol(c)) {
return
}
if (c.type === NodeTypes.TEXT) {
res += c.content
} else if (c.type === NodeTypes.INTERPOLATION) {
res += toDisplayString(evaluateConstant(c.content))
} else {
res += evaluateConstant(c)
}
})
return res
}
}