feat(compiler-dom): handle constant expressions when stringifying static content

This commit is contained in:
Evan You 2020-02-12 15:00:00 -05:00
parent 1389d7b88c
commit 8b7c162125
4 changed files with 184 additions and 16 deletions

View File

@ -90,6 +90,8 @@ export function processExpression(
// fast path if expression is a simple identifier. // fast path if expression is a simple identifier.
const rawExp = node.content const rawExp = node.content
// bail on parens to prevent any possible function invocations.
const bailConstant = rawExp.indexOf(`(`) > -1
if (isSimpleIdentifier(rawExp)) { if (isSimpleIdentifier(rawExp)) {
if ( if (
!asParams && !asParams &&
@ -98,7 +100,7 @@ export function processExpression(
!isLiteralWhitelisted(rawExp) !isLiteralWhitelisted(rawExp)
) { ) {
node.content = `_ctx.${rawExp}` node.content = `_ctx.${rawExp}`
} else if (!context.identifiers[rawExp]) { } else if (!context.identifiers[rawExp] && !bailConstant) {
// mark node constant for hoisting unless it's referring a scope variable // mark node constant for hoisting unless it's referring a scope variable
node.isConstant = true node.isConstant = true
} }
@ -139,12 +141,13 @@ export function processExpression(
node.prefix = `${node.name}: ` node.prefix = `${node.name}: `
} }
node.name = `_ctx.${node.name}` node.name = `_ctx.${node.name}`
node.isConstant = false
ids.push(node) ids.push(node)
} else if (!isStaticPropertyKey(node, parent)) { } else if (!isStaticPropertyKey(node, parent)) {
// The identifier is considered constant unless it's pointing to a // The identifier is considered constant unless it's pointing to a
// scope variable (a v-for alias, or a v-slot prop) // scope variable (a v-for alias, or a v-slot prop)
node.isConstant = !(needPrefix && knownIds[node.name]) if (!(needPrefix && knownIds[node.name]) && !bailConstant) {
node.isConstant = true
}
// also generate sub-expressions for other identifiers for better // also generate sub-expressions for other identifiers for better
// source map support. (except for property keys which are static) // source map support. (except for property keys which are static)
ids.push(node) ids.push(node)
@ -234,7 +237,7 @@ export function processExpression(
ret = createCompoundExpression(children, node.loc) ret = createCompoundExpression(children, node.loc)
} else { } else {
ret = node ret = node
ret.isConstant = true ret.isConstant = !bailConstant
} }
ret.identifiers = Object.keys(knownIds) ret.identifiers = Object.keys(knownIds)
return ret return ret

View File

@ -0,0 +1,124 @@
import { compile, NodeTypes, CREATE_STATIC } from '../../src'
import {
stringifyStatic,
StringifyThresholds
} from '../../src/transforms/stringifyStatic'
describe('stringify static html', () => {
function compileWithStringify(template: string) {
return compile(template, {
hoistStatic: true,
prefixIdentifiers: true,
transformHoist: stringifyStatic
})
}
function repeat(code: string, n: number): string {
return new Array(n)
.fill(0)
.map(() => code)
.join('')
}
test('should bail on non-eligible static trees', () => {
const { ast } = compileWithStringify(
`<div><div><div>hello</div><div>hello</div></div></div>`
)
expect(ast.hoists.length).toBe(1)
// should be a normal vnode call
expect(ast.hoists[0].type).toBe(NodeTypes.VNODE_CALL)
})
test('should work on eligible content (elements with binding > 5)', () => {
const { ast } = compileWithStringify(
`<div><div>${repeat(
`<span class="foo"/>`,
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
)}</div></div>`
)
expect(ast.hoists.length).toBe(1)
// should be optimized now
expect(ast.hoists[0]).toMatchObject({
type: NodeTypes.JS_CALL_EXPRESSION,
callee: CREATE_STATIC,
arguments: [
JSON.stringify(
`<div>${repeat(
`<span class="foo"></span>`,
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
)}</div>`
)
]
})
})
test('should work on eligible content (elements > 20)', () => {
const { ast } = compileWithStringify(
`<div><div>${repeat(
`<span/>`,
StringifyThresholds.NODE_COUNT
)}</div></div>`
)
expect(ast.hoists.length).toBe(1)
// should be optimized now
expect(ast.hoists[0]).toMatchObject({
type: NodeTypes.JS_CALL_EXPRESSION,
callee: CREATE_STATIC,
arguments: [
JSON.stringify(
`<div>${repeat(
`<span></span>`,
StringifyThresholds.NODE_COUNT
)}</div>`
)
]
})
})
test('serliazing constant bindings', () => {
const { ast } = compileWithStringify(
`<div><div>${repeat(
`<span :class="'foo' + 'bar'">{{ 1 }} + {{ false }}</span>`,
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
)}</div></div>`
)
expect(ast.hoists.length).toBe(1)
// should be optimized now
expect(ast.hoists[0]).toMatchObject({
type: NodeTypes.JS_CALL_EXPRESSION,
callee: CREATE_STATIC,
arguments: [
JSON.stringify(
`<div>${repeat(
`<span class="foobar">1 + false</span>`,
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
)}</div>`
)
]
})
})
test('escape', () => {
const { ast } = compileWithStringify(
`<div><div>${repeat(
`<span :class="'foo' + '&gt;ar'">{{ 1 }} + {{ '<' }}</span>` +
`<span>&amp;</span>`,
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
)}</div></div>`
)
expect(ast.hoists.length).toBe(1)
// should be optimized now
expect(ast.hoists[0]).toMatchObject({
type: NodeTypes.JS_CALL_EXPRESSION,
callee: CREATE_STATIC,
arguments: [
JSON.stringify(
`<div>${repeat(
`<span class="foo&gt;ar">1 + &lt;</span>` + `<span>&amp;</span>`,
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
)}</div>`
)
]
})
})
})

View File

@ -18,7 +18,7 @@ import { transformModel } from './transforms/vModel'
import { transformOn } from './transforms/vOn' import { transformOn } from './transforms/vOn'
import { transformShow } from './transforms/vShow' import { transformShow } from './transforms/vShow'
import { warnTransitionChildren } from './transforms/warnTransitionChildren' import { warnTransitionChildren } from './transforms/warnTransitionChildren'
import { stringifyStatic } from './stringifyStatic' import { stringifyStatic } from './transforms/stringifyStatic'
export const parserOptions = __BROWSER__ export const parserOptions = __BROWSER__
? parserOptionsMinimal ? parserOptionsMinimal

View File

@ -6,12 +6,20 @@ import {
SimpleExpressionNode, SimpleExpressionNode,
createCallExpression, createCallExpression,
HoistTransform, HoistTransform,
CREATE_STATIC CREATE_STATIC,
ExpressionNode
} from '@vue/compiler-core' } from '@vue/compiler-core'
import { isVoidTag, isString, isSymbol, escapeHtml } from '@vue/shared' import {
isVoidTag,
isString,
isSymbol,
escapeHtml,
toDisplayString
} from '@vue/shared'
// Turn eligible hoisted static trees into stringied static nodes, e.g. // Turn eligible hoisted static trees into stringied static nodes, e.g.
// const _hoisted_1 = createStaticVNode(`<div class="foo">bar</div>`) // const _hoisted_1 = createStaticVNode(`<div class="foo">bar</div>`)
// This is only performed in non-in-browser compilations.
export const stringifyStatic: HoistTransform = (node, context) => { export const stringifyStatic: HoistTransform = (node, context) => {
if (shouldOptimize(node)) { if (shouldOptimize(node)) {
return createCallExpression(context.helper(CREATE_STATIC), [ return createCallExpression(context.helper(CREATE_STATIC), [
@ -22,6 +30,11 @@ export const stringifyStatic: HoistTransform = (node, context) => {
} }
} }
export const enum StringifyThresholds {
ELEMENT_WITH_BINDING_COUNT = 5,
NODE_COUNT = 20
}
// Opt-in heuristics based on: // Opt-in heuristics based on:
// 1. number of elements with attributes > 5. // 1. number of elements with attributes > 5.
// 2. OR: number of total nodes > 20 // 2. OR: number of total nodes > 20
@ -29,8 +42,8 @@ export const stringifyStatic: HoistTransform = (node, context) => {
// it is only worth it when the tree is complex enough // it is only worth it when the tree is complex enough
// (e.g. big piece of static content) // (e.g. big piece of static content)
function shouldOptimize(node: ElementNode): boolean { function shouldOptimize(node: ElementNode): boolean {
let bindingThreshold = 5 let bindingThreshold = StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
let nodeThreshold = 20 let nodeThreshold = StringifyThresholds.NODE_COUNT
// TODO: check for cases where using innerHTML will result in different // TODO: check for cases where using innerHTML will result in different
// output compared to imperative node insertions. // output compared to imperative node insertions.
@ -67,11 +80,13 @@ function stringifyElement(
if (p.type === NodeTypes.ATTRIBUTE) { if (p.type === NodeTypes.ATTRIBUTE) {
res += ` ${p.name}` res += ` ${p.name}`
if (p.value) { if (p.value) {
res += `="${p.value.content}"` res += `="${escapeHtml(p.value.content)}"`
} }
} else if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') { } else if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') {
// constant v-bind, e.g. :foo="1" // constant v-bind, e.g. :foo="1"
// TODO res += ` ${(p.arg as SimpleExpressionNode).content}="${escapeHtml(
evaluateConstant(p.exp as ExpressionNode)
)}"`
} }
} }
if (context.scopeId) { if (context.scopeId) {
@ -105,12 +120,9 @@ function stringifyNode(
case NodeTypes.COMMENT: case NodeTypes.COMMENT:
return `<!--${escapeHtml(node.content)}-->` return `<!--${escapeHtml(node.content)}-->`
case NodeTypes.INTERPOLATION: case NodeTypes.INTERPOLATION:
// constants return escapeHtml(toDisplayString(evaluateConstant(node.content)))
// TODO check eval
return (node.content as SimpleExpressionNode).content
case NodeTypes.COMPOUND_EXPRESSION: case NodeTypes.COMPOUND_EXPRESSION:
// TODO proper handling return escapeHtml(evaluateConstant(node))
return node.children.map((c: any) => stringifyNode(c, context)).join('')
case NodeTypes.TEXT_CALL: case NodeTypes.TEXT_CALL:
return stringifyNode(node.content, context) return stringifyNode(node.content, context)
default: default:
@ -118,3 +130,32 @@ function stringifyNode(
return '' return ''
} }
} }
// __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 += evaluateConstant(c.content)
} else {
res += evaluateConstant(c)
}
})
return res
}
}