stringifyStatic.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. /**
  2. * This module is Node-only.
  3. */
  4. import {
  5. NodeTypes,
  6. ElementNode,
  7. TransformContext,
  8. TemplateChildNode,
  9. SimpleExpressionNode,
  10. createCallExpression,
  11. HoistTransform,
  12. CREATE_STATIC,
  13. ExpressionNode,
  14. ElementTypes,
  15. PlainElementNode,
  16. JSChildNode,
  17. TextCallNode
  18. } from '@vue/compiler-core'
  19. import {
  20. isVoidTag,
  21. isString,
  22. isSymbol,
  23. isKnownAttr,
  24. escapeHtml,
  25. toDisplayString,
  26. normalizeClass,
  27. normalizeStyle,
  28. stringifyStyle,
  29. makeMap
  30. } from '@vue/shared'
  31. export const enum StringifyThresholds {
  32. ELEMENT_WITH_BINDING_COUNT = 5,
  33. NODE_COUNT = 20
  34. }
  35. type StringifiableNode = PlainElementNode | TextCallNode
  36. /**
  37. * Turn eligible hoisted static trees into stringified static nodes, e.g.
  38. *
  39. * ```js
  40. * const _hoisted_1 = createStaticVNode(`<div class="foo">bar</div>`)
  41. * ```
  42. *
  43. * A single static vnode can contain stringified content for **multiple**
  44. * consecutive nodes (element and plain text), called a "chunk".
  45. * `@vue/runtime-dom` will create the content via innerHTML in a hidden
  46. * container element and insert all the nodes in place. The call must also
  47. * provide the number of nodes contained in the chunk so that during hydration
  48. * we can know how many nodes the static vnode should adopt.
  49. *
  50. * The optimization scans a children list that contains hoisted nodes, and
  51. * tries to find the largest chunk of consecutive hoisted nodes before running
  52. * into a non-hoisted node or the end of the list. A chunk is then converted
  53. * into a single static vnode and replaces the hoisted expression of the first
  54. * node in the chunk. Other nodes in the chunk are considered "merged" and
  55. * therefore removed from both the hoist list and the children array.
  56. *
  57. * This optimization is only performed in Node.js.
  58. */
  59. export const stringifyStatic: HoistTransform = (children, context, parent) => {
  60. // bail stringification for slot content
  61. if (context.scopes.vSlot > 0) {
  62. return
  63. }
  64. let nc = 0 // current node count
  65. let ec = 0 // current element with binding count
  66. const currentChunk: StringifiableNode[] = []
  67. const stringifyCurrentChunk = (currentIndex: number): number => {
  68. if (
  69. nc >= StringifyThresholds.NODE_COUNT ||
  70. ec >= StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
  71. ) {
  72. // combine all currently eligible nodes into a single static vnode call
  73. const staticCall = createCallExpression(context.helper(CREATE_STATIC), [
  74. JSON.stringify(
  75. currentChunk.map(node => stringifyNode(node, context)).join('')
  76. ),
  77. // the 2nd argument indicates the number of DOM nodes this static vnode
  78. // will insert / hydrate
  79. String(currentChunk.length)
  80. ])
  81. // replace the first node's hoisted expression with the static vnode call
  82. replaceHoist(currentChunk[0], staticCall, context)
  83. if (currentChunk.length > 1) {
  84. for (let i = 1; i < currentChunk.length; i++) {
  85. // for the merged nodes, set their hoisted expression to null
  86. replaceHoist(currentChunk[i], null, context)
  87. }
  88. // also remove merged nodes from children
  89. const deleteCount = currentChunk.length - 1
  90. children.splice(currentIndex - currentChunk.length + 1, deleteCount)
  91. return deleteCount
  92. }
  93. }
  94. return 0
  95. }
  96. let i = 0
  97. for (; i < children.length; i++) {
  98. const child = children[i]
  99. const hoisted = getHoistedNode(child)
  100. if (hoisted) {
  101. // presence of hoisted means child must be a stringifiable node
  102. const node = child as StringifiableNode
  103. const result = analyzeNode(node)
  104. if (result) {
  105. // node is stringifiable, record state
  106. nc += result[0]
  107. ec += result[1]
  108. currentChunk.push(node)
  109. continue
  110. }
  111. }
  112. // we only reach here if we ran into a node that is not stringifiable
  113. // check if currently analyzed nodes meet criteria for stringification.
  114. // adjust iteration index
  115. i -= stringifyCurrentChunk(i)
  116. // reset state
  117. nc = 0
  118. ec = 0
  119. currentChunk.length = 0
  120. }
  121. // in case the last node was also stringifiable
  122. stringifyCurrentChunk(i)
  123. }
  124. const getHoistedNode = (node: TemplateChildNode) =>
  125. ((node.type === NodeTypes.ELEMENT && node.tagType === ElementTypes.ELEMENT) ||
  126. node.type == NodeTypes.TEXT_CALL) &&
  127. node.codegenNode &&
  128. node.codegenNode.type === NodeTypes.SIMPLE_EXPRESSION &&
  129. node.codegenNode.hoisted
  130. const dataAriaRE = /^(data|aria)-/
  131. const isStringifiableAttr = (name: string) => {
  132. return isKnownAttr(name) || dataAriaRE.test(name)
  133. }
  134. const replaceHoist = (
  135. node: StringifiableNode,
  136. replacement: JSChildNode | null,
  137. context: TransformContext
  138. ) => {
  139. const hoistToReplace = (node.codegenNode as SimpleExpressionNode).hoisted!
  140. context.hoists[context.hoists.indexOf(hoistToReplace)] = replacement
  141. }
  142. const isNonStringifiable = /*#__PURE__*/ makeMap(
  143. `caption,thead,tr,th,tbody,td,tfoot,colgroup,col`
  144. )
  145. /**
  146. * for a hoisted node, analyze it and return:
  147. * - false: bailed (contains runtime constant)
  148. * - [nc, ec] where
  149. * - nc is the number of nodes inside
  150. * - ec is the number of element with bindings inside
  151. */
  152. function analyzeNode(node: StringifiableNode): [number, number] | false {
  153. if (node.type === NodeTypes.ELEMENT && isNonStringifiable(node.tag)) {
  154. return false
  155. }
  156. if (node.type === NodeTypes.TEXT_CALL) {
  157. return [1, 0]
  158. }
  159. let nc = 1 // node count
  160. let ec = node.props.length > 0 ? 1 : 0 // element w/ binding count
  161. let bailed = false
  162. const bail = (): false => {
  163. bailed = true
  164. return false
  165. }
  166. // TODO: check for cases where using innerHTML will result in different
  167. // output compared to imperative node insertions.
  168. // probably only need to check for most common case
  169. // i.e. non-phrasing-content tags inside `<p>`
  170. function walk(node: ElementNode): boolean {
  171. for (let i = 0; i < node.props.length; i++) {
  172. const p = node.props[i]
  173. // bail on non-attr bindings
  174. if (p.type === NodeTypes.ATTRIBUTE && !isStringifiableAttr(p.name)) {
  175. return bail()
  176. }
  177. if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') {
  178. // bail on non-attr bindings
  179. if (
  180. p.arg &&
  181. (p.arg.type === NodeTypes.COMPOUND_EXPRESSION ||
  182. (p.arg.isStatic && !isStringifiableAttr(p.arg.content)))
  183. ) {
  184. return bail()
  185. }
  186. }
  187. }
  188. for (let i = 0; i < node.children.length; i++) {
  189. nc++
  190. const child = node.children[i]
  191. if (child.type === NodeTypes.ELEMENT) {
  192. if (child.props.length > 0) {
  193. ec++
  194. }
  195. walk(child)
  196. if (bailed) {
  197. return false
  198. }
  199. }
  200. }
  201. return true
  202. }
  203. return walk(node) ? [nc, ec] : false
  204. }
  205. function stringifyNode(
  206. node: string | TemplateChildNode,
  207. context: TransformContext
  208. ): string {
  209. if (isString(node)) {
  210. return node
  211. }
  212. if (isSymbol(node)) {
  213. return ``
  214. }
  215. switch (node.type) {
  216. case NodeTypes.ELEMENT:
  217. return stringifyElement(node, context)
  218. case NodeTypes.TEXT:
  219. return escapeHtml(node.content)
  220. case NodeTypes.COMMENT:
  221. return `<!--${escapeHtml(node.content)}-->`
  222. case NodeTypes.INTERPOLATION:
  223. return escapeHtml(toDisplayString(evaluateConstant(node.content)))
  224. case NodeTypes.COMPOUND_EXPRESSION:
  225. return escapeHtml(evaluateConstant(node))
  226. case NodeTypes.TEXT_CALL:
  227. return stringifyNode(node.content, context)
  228. default:
  229. // static trees will not contain if/for nodes
  230. return ''
  231. }
  232. }
  233. function stringifyElement(
  234. node: ElementNode,
  235. context: TransformContext
  236. ): string {
  237. let res = `<${node.tag}`
  238. for (let i = 0; i < node.props.length; i++) {
  239. const p = node.props[i]
  240. if (p.type === NodeTypes.ATTRIBUTE) {
  241. res += ` ${p.name}`
  242. if (p.value) {
  243. res += `="${escapeHtml(p.value.content)}"`
  244. }
  245. } else if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') {
  246. // constant v-bind, e.g. :foo="1"
  247. let evaluated = evaluateConstant(p.exp as SimpleExpressionNode)
  248. const arg = p.arg && (p.arg as SimpleExpressionNode).content
  249. if (arg === 'class') {
  250. evaluated = normalizeClass(evaluated)
  251. } else if (arg === 'style') {
  252. evaluated = stringifyStyle(normalizeStyle(evaluated))
  253. }
  254. res += ` ${(p.arg as SimpleExpressionNode).content}="${escapeHtml(
  255. evaluated
  256. )}"`
  257. }
  258. }
  259. if (context.scopeId) {
  260. res += ` ${context.scopeId}`
  261. }
  262. res += `>`
  263. for (let i = 0; i < node.children.length; i++) {
  264. res += stringifyNode(node.children[i], context)
  265. }
  266. if (!isVoidTag(node.tag)) {
  267. res += `</${node.tag}>`
  268. }
  269. return res
  270. }
  271. // __UNSAFE__
  272. // Reason: eval.
  273. // It's technically safe to eval because only constant expressions are possible
  274. // here, e.g. `{{ 1 }}` or `{{ 'foo' }}`
  275. // in addition, constant exps bail on presence of parens so you can't even
  276. // run JSFuck in here. But we mark it unsafe for security review purposes.
  277. // (see compiler-core/src/transformExpressions)
  278. function evaluateConstant(exp: ExpressionNode): string {
  279. if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
  280. return new Function(`return ${exp.content}`)()
  281. } else {
  282. // compound
  283. let res = ``
  284. exp.children.forEach(c => {
  285. if (isString(c) || isSymbol(c)) {
  286. return
  287. }
  288. if (c.type === NodeTypes.TEXT) {
  289. res += c.content
  290. } else if (c.type === NodeTypes.INTERPOLATION) {
  291. res += toDisplayString(evaluateConstant(c.content))
  292. } else {
  293. res += evaluateConstant(c)
  294. }
  295. })
  296. return res
  297. }
  298. }