Skip to content

Commit 455e9b9

Browse files
authored
Add no-side-effects-in-computed-properties rule (#69)
* Add no-side-effects-in-computed-properties rule * Prepare example tests for no-side-effects-in-computed-properties rule, Move component detection logic to utils * Add getComputedProperties logic * Finalize initial logic for no-side-effects-in-computed-properties rule * Fix test * Add more methods in regex * Add no-side-effects-in-computed-properties rule * Prepare example tests for no-side-effects-in-computed-properties rule, Move component detection logic to utils * Add getComputedProperties logic * Add more test, move functions to utils * Make it work with Node 4
1 parent 159b01e commit 455e9b9

7 files changed

+585
-50
lines changed

Diff for: docs/rules/no-side-effects-in-computed-properties.md

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Do not introduce side effects in computed properties (no-side-effects-in-computed-properties)
2+
3+
It is considered a very bad practice to introduce side effects inside computed properties. It makes the code not predictable and hard to understand.
4+
5+
6+
## Rule Details
7+
8+
Examples of **incorrect** code for this rule:
9+
10+
```js
11+
12+
export default {
13+
computed: {
14+
fullName() {
15+
this.firstName = 'lorem' // <- side effect
16+
return `${this.firstName} ${this.lastName}`
17+
},
18+
somethingReversed() {
19+
return this.something.reverse() // <- side effect
20+
}
21+
}
22+
}
23+
24+
```
25+
26+
Examples of **correct** code for this rule:
27+
28+
```js
29+
30+
export default {
31+
computed: {
32+
fullName() {
33+
return `${this.firstName} ${this.lastName}`
34+
},
35+
somethingReversed() {
36+
return this.something.slice(0).reverse()
37+
}
38+
}
39+
}
40+
41+
```

Diff for: lib/rules/no-side-effects-in-computed-properties.js

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @fileoverview Don't introduce side effects in computed properties
3+
* @author Michał Sajnóg
4+
*/
5+
'use strict'
6+
7+
const utils = require('../utils')
8+
9+
function create (context) {
10+
const forbiddenNodes = []
11+
12+
return Object.assign({},
13+
{
14+
// this.xxx <=|+=|-=>
15+
'AssignmentExpression > MemberExpression' (node) {
16+
if (utils.parseMemberExpression(node)[0] === 'this') {
17+
forbiddenNodes.push(node)
18+
}
19+
},
20+
// this.xxx <++|-->
21+
'UpdateExpression > MemberExpression' (node) {
22+
if (utils.parseMemberExpression(node)[0] === 'this') {
23+
forbiddenNodes.push(node)
24+
}
25+
},
26+
// this.xxx.func()
27+
'CallExpression' (node) {
28+
const code = context.getSourceCode().getText(node)
29+
const MUTATION_REGEX = /(this.)((?!(concat|slice|map|filter)\().)*((push|pop|shift|unshift|reverse|splice|sort|copyWithin|fill)\()/g
30+
31+
if (MUTATION_REGEX.test(code)) {
32+
forbiddenNodes.push(node)
33+
}
34+
}
35+
},
36+
utils.executeOnVueComponent(context, (obj) => {
37+
const computedProperties = utils.getComputedProperties(obj)
38+
39+
computedProperties.forEach(cp => {
40+
forbiddenNodes.forEach(node => {
41+
if (
42+
node.loc.start.line >= cp.value.loc.start.line &&
43+
node.loc.end.line <= cp.value.loc.end.line
44+
) {
45+
context.report({
46+
node: node,
47+
message: `Unexpected side effect in "${cp.key}" computed property.`
48+
})
49+
}
50+
})
51+
})
52+
})
53+
)
54+
}
55+
56+
// ------------------------------------------------------------------------------
57+
// Rule Definition
58+
// ------------------------------------------------------------------------------
59+
60+
module.exports = {
61+
create,
62+
meta: {
63+
docs: {
64+
description: 'Don\'t introduce side effects in computed properties',
65+
category: 'Best Practices',
66+
recommended: false
67+
},
68+
fixable: null,
69+
schema: []
70+
}
71+
}

Diff for: lib/rules/order-in-components.js

+5-50
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
*/
55
'use strict'
66

7+
const utils = require('../utils')
8+
79
const defaultOrder = [
810
['name', 'delimiters', 'functional', 'model'],
911
['components', 'directives', 'filters'],
@@ -36,38 +38,6 @@ const groups = {
3638
]
3739
}
3840

39-
function isComponentFile (node, path) {
40-
const isVueFile = path.endsWith('.vue') || path.endsWith('.jsx')
41-
return isVueFile && node.declaration.type === 'ObjectExpression'
42-
}
43-
44-
function isVueComponent (node) {
45-
const callee = node.callee
46-
47-
const isFullVueComponent = node.type === 'CallExpression' &&
48-
callee.type === 'MemberExpression' &&
49-
callee.object.type === 'Identifier' &&
50-
callee.object.name === 'Vue' &&
51-
callee.property.type === 'Identifier' &&
52-
callee.property.name === 'component' &&
53-
node.arguments.length &&
54-
node.arguments.slice(-1)[0].type === 'ObjectExpression'
55-
56-
const isDestructedVueComponent = callee.type === 'Identifier' &&
57-
callee.name === 'component'
58-
59-
return isFullVueComponent || isDestructedVueComponent
60-
}
61-
62-
function isVueInstance (node) {
63-
const callee = node.callee
64-
return node.type === 'NewExpression' &&
65-
callee.type === 'Identifier' &&
66-
callee.name === 'Vue' &&
67-
node.arguments.length &&
68-
node.arguments[0].type === 'ObjectExpression'
69-
}
70-
7141
function getOrderMap (order) {
7242
const orderMap = new Map()
7343

@@ -106,28 +76,13 @@ function checkOrder (propertiesNodes, orderMap, context) {
10676
function create (context) {
10777
const options = context.options[0] || {}
10878
const order = options.order || defaultOrder
109-
const filePath = context.getFilename()
11079

11180
const extendedOrder = order.map(property => groups[property] || property)
11281
const orderMap = getOrderMap(extendedOrder)
11382

114-
return {
115-
ExportDefaultDeclaration (node) {
116-
// export default {} in .vue || .jsx
117-
if (!isComponentFile(node, filePath)) return
118-
checkOrder(node.declaration.properties, orderMap, context)
119-
},
120-
CallExpression (node) {
121-
// Vue.component('xxx', {}) || component('xxx', {})
122-
if (!isVueComponent(node)) return
123-
checkOrder(node.arguments.slice(-1)[0].properties, orderMap, context)
124-
},
125-
NewExpression (node) {
126-
// new Vue({})
127-
if (!isVueInstance(node)) return
128-
checkOrder(node.arguments[0].properties, orderMap, context)
129-
}
130-
}
83+
return utils.executeOnVueComponent(context, (obj) => {
84+
checkOrder(obj.properties, orderMap, context)
85+
})
13186
}
13287

13388
// ------------------------------------------------------------------------------

Diff for: lib/utils/index.js

+143
Original file line numberDiff line numberDiff line change
@@ -241,5 +241,148 @@ module.exports = {
241241
assert(typeof name === 'string')
242242

243243
return VOID_ELEMENT_NAMES.has(name.toLowerCase())
244+
},
245+
246+
/**
247+
* Parse member expression node to get array with all of it's parts
248+
* @param {ASTNode} MemberExpression
249+
* @returns {Array}
250+
*/
251+
parseMemberExpression (node) {
252+
const members = []
253+
let memberExpression
254+
255+
if (node.type === 'MemberExpression') {
256+
memberExpression = node
257+
258+
while (memberExpression.type === 'MemberExpression') {
259+
if (memberExpression.property.type === 'Identifier') {
260+
members.push(memberExpression.property.name)
261+
}
262+
memberExpression = memberExpression.object
263+
}
264+
265+
if (memberExpression.type === 'ThisExpression') {
266+
members.push('this')
267+
} else if (memberExpression.type === 'Identifier') {
268+
members.push(memberExpression.name)
269+
}
270+
}
271+
272+
return members.reverse()
273+
},
274+
275+
/**
276+
* Get all computed properties by looking at all component's properties
277+
* @param {ObjectExpression} Object with component definition
278+
* @return {Array} Array of computed properties in format: [{key: String, value: ASTNode}]
279+
*/
280+
getComputedProperties (componentObject) {
281+
const computedPropertiesNode = componentObject.properties
282+
.filter(p =>
283+
p.key.type === 'Identifier' &&
284+
p.key.name === 'computed' &&
285+
p.value.type === 'ObjectExpression'
286+
)[0]
287+
288+
if (!computedPropertiesNode) { return [] }
289+
290+
return computedPropertiesNode.value.properties
291+
.filter(cp => cp.type === 'Property')
292+
.map(cp => {
293+
const key = cp.key.name
294+
let value
295+
296+
if (cp.value.type === 'FunctionExpression') {
297+
value = cp.value.body
298+
} else if (cp.value.type === 'ObjectExpression') {
299+
value = cp.value.properties
300+
.filter(p =>
301+
p.key.type === 'Identifier' &&
302+
p.key.name === 'get' &&
303+
p.value.type === 'FunctionExpression'
304+
)
305+
.map(p => p.value.body)[0]
306+
}
307+
308+
return { key, value }
309+
})
310+
},
311+
312+
/**
313+
* Check whether the given node is a Vue component based
314+
* on the filename and default export type
315+
* export default {} in .vue || .jsx
316+
* @param {ASTNode} node Node to check
317+
* @param {string} path File name with extension
318+
* @returns {boolean}
319+
*/
320+
isVueComponentFile (node, path) {
321+
const isVueFile = path.endsWith('.vue') || path.endsWith('.jsx')
322+
return isVueFile &&
323+
node.type === 'ExportDefaultDeclaration' &&
324+
node.declaration.type === 'ObjectExpression'
325+
},
326+
327+
/**
328+
* Check whether given node is Vue component
329+
* Vue.component('xxx', {}) || component('xxx', {})
330+
* @param {ASTNode} node Node to check
331+
* @returns {boolean}
332+
*/
333+
isVueComponent (node) {
334+
const callee = node.callee
335+
336+
const isFullVueComponent = node.type === 'CallExpression' &&
337+
callee.type === 'MemberExpression' &&
338+
callee.object.type === 'Identifier' &&
339+
callee.object.name === 'Vue' &&
340+
callee.property.type === 'Identifier' &&
341+
callee.property.name === 'component' &&
342+
node.arguments.length &&
343+
node.arguments.slice(-1)[0].type === 'ObjectExpression'
344+
345+
const isDestructedVueComponent = callee.type === 'Identifier' &&
346+
callee.name === 'component'
347+
348+
return isFullVueComponent || isDestructedVueComponent
349+
},
350+
351+
/**
352+
* Check whether given node is new Vue instance
353+
* new Vue({})
354+
* @param {ASTNode} node Node to check
355+
* @returns {boolean}
356+
*/
357+
isVueInstance (node) {
358+
const callee = node.callee
359+
return node.type === 'NewExpression' &&
360+
callee.type === 'Identifier' &&
361+
callee.name === 'Vue' &&
362+
node.arguments.length &&
363+
node.arguments[0].type === 'ObjectExpression'
364+
},
365+
366+
executeOnVueComponent (context, cb) {
367+
const filePath = context.getFilename()
368+
const _this = this
369+
370+
return {
371+
'ExportDefaultDeclaration:exit' (node) {
372+
// export default {} in .vue || .jsx
373+
if (!_this.isVueComponentFile(node, filePath)) return
374+
cb(node.declaration)
375+
},
376+
'CallExpression:exit' (node) {
377+
// Vue.component('xxx', {}) || component('xxx', {})
378+
if (!_this.isVueComponent(node)) return
379+
cb(node.arguments.slice(-1)[0])
380+
},
381+
'NewExpression:exit' (node) {
382+
// new Vue({})
383+
if (!_this.isVueInstance(node)) return
384+
cb(node.arguments[0])
385+
}
386+
}
244387
}
245388
}

Diff for: package.json

+2
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
},
5050
"devDependencies": {
5151
"@types/node": "^4.2.6",
52+
"babel-eslint": "^7.2.3",
53+
"chai": "^4.1.0",
5254
"eslint": "^3.18.0",
5355
"eslint-plugin-eslint-plugin": "^0.7.1",
5456
"eslint-plugin-vue-libs": "^1.2.0",

0 commit comments

Comments
 (0)