vuejs / eslint-plugin-vue

Official ESLint plugin for Vue.js
https://eslint.vuejs.org/
MIT License
4.47k stars 664 forks source link

Rule proposition: `no-side-effects-in-computed-properties` #58

Closed michalsnik closed 7 years ago

michalsnik commented 7 years ago

This rule would check if there are no side effects inside computed properties.

So this example would throw an error:

export default {
  computed: {
    hello() {
      this.lastName = 'Dolor' // <- error "Unexpected side effect in computed property 'hello'"
      return `Hello ${this.firstName} ${this.lastName}`
    }
  },
  data() {
    return {
      firstName: 'Lorem',
      lastName: 'Ipsum'
    }
  }
}

What do you think @mysticatea @chrisvfritz ?

chrisvfritz commented 7 years ago

I like it!

LinusBorg commented 7 years ago

Would this rule also check for things like:

export default {
  computed: {
    hello() {
      this.sideEffect() // <- error "Unexpected side effect in computed property 'hello'"
      const formattedValue = this.format(value) // <- no error
      return `Hello ${this.firstName} ${this.lastName}`
    }
  },
methods: {
  sideEffect() {
    // some side effect.
  },
  format(value) {
    return // ...
  }
}
michalsnik commented 7 years ago

We could @LinusBorg, so the assumptions would be to throw an error when:

Am I missing something?

// Update Damn we actually could simplify this and consider that even simple this.xxx() is a side effect. That makes it even simpler. But we still need to remember that it should be possible to use map or other non-mutable methods on properties.

LinusBorg commented 7 years ago

But we still need to remember that it should be possible to use map or other non-mutable methods on properties.

When using map etc., we would use the return value, right?

this.something.map(/* ... */) // doesn't make sense
const result = this.something.map(/* ... */) // makes sense

The same is true for method calls - if we don't do something with the return value, it's safe to assume that the method we call has a side-effect.

this.myMethod(value) // this must have a side-effect, otherwise there's no reason in calling it, right?
const result =  this.myMethod(value) // this *might* have a side-effect, but could just be a helper.
michalsnik commented 7 years ago

When using map etc., we would use the return value, right?

I would check all CallExpressions, in terms of non-mutable methods like map I don't have to care about assignments, as it would not proudce any side effect in any case. And in terms of mutable methods like splice we also don't need to check for any assignment because it will mutate the initial value anyway, so presence of any this.something.<any mutable method> should trigger error.

The same is true for method calls - if we don't do something with the return value, it's safe to assume that the method we call has a side-effect.

This case has more sense, I do however think that methods you want to call in computed properties should be pure, and perhaps using standalone functions instead of component's methods should be considered better practice? Not sure entirely though... E.g:

function add2(num) {
  return num + 2;
}

export default {
  data() {
    return {
      num: 10
    }
  },
  computed: {
    result() {
      return add2(this.num);
    }
  }
}

over this:

export default {
  data() {
    return {
      num: 10
    }
  },
  computed: {
    result() {
      return this.add2(this.num);
    }
  },
  methods: {
    add2(num) {
      return num + 2;
    }
  }
}

The best would be to actually check all methods bodies for any side effect presences and then if this method is called trigger error, but that complicates things. For example one method doesn't introduce side-effect but only calls other method which has side effect and we end up with nice complex tree of side-or-not-effects hard to properly detect.

Akryum commented 7 years ago

A pure function can call other pure functions and still be pure I think. :smile_cat:

Akryum commented 7 years ago

@michalsnik What about method that transform your data with other props?

export default {
  props: {
    repeat: {
      default: 1,
    },
  },
  data() {
    return {
      num: 10
    }
  },
  computed: {
    result() {
      return this.add(this.num);
    }
  },
  methods: {
    add(num) {
      for (let i = 0; i < this.repeat; i++) {
        num += i
      }
      return num
    }
  }
}

(Sorry for the boring example.)

LinusBorg commented 7 years ago

@Akryum that example is not a pure functions, but it doesn't have any side-effects either.

michalsnik commented 7 years ago

Hah, I don't think we can catch everything. I'll just start with the simplest implementation as the initial version of this rule and we'll see what's next then.

In your example @Akryum I'd opt for moving this function up, so that there is no method called in computed property that uses other props or data. But it's maybe the case for another rule 🤔 (Being explicit in computed properties seems like a good practice to me).

function add(num, repeatTimes) {
  for (let i = 0; i < repeatTimes; i++) {
    num += i
  }
  return num
}

export default {
  props: {
    repeat: {
      default: 1,
    },
  },
  data() {
    return {
      num: 10
    }
  },
  computed: {
    result() {
      return add(this.num, this.repeat);
    }
  },
  methods: {}
}
michalsnik commented 7 years ago

My PR is almost ready https://github.com/vuejs/eslint-plugin-vue/pull/69 I think we can stick with those simple checks at this moment. These will probably catch most cases. Overthinking can lead to problems hard to solve. @Akryum @LinusBorg

michalsnik commented 7 years ago

Okay, there is maybe one more thing @LinusBorg mentioned we can add to this implementation:

this.sideEffect() // <- error "Unexpected side effect in computed property 'hello'"

I'll consider this too in initial implementation.

armano2 commented 7 years ago

It will be nice if we we have support for plugins provided by vuejs team

Vue-router

export default {
  computed: {
    foo () {
      this.$router.go(-1)
      this.$router.push({})
      this.$router.replace({})
      return true
    }
  }
}

Vuex

export default {
  computed: {
    foo () {
      this.$store.commit({}) // Mutations
      this.$store.dispatch('', {}) // Actions
      return true
    }
  }
}
LinusBorg commented 7 years ago

@armano2 The proposed rule should reject all of these examples.

armano2 commented 7 years ago

@LinusBorg you are right i missed last message :rabbit:

michalsnik commented 7 years ago

Interesting idea @armano2, however I'd prefer to add this in a separate PR after initial rule is merged.

armano2 commented 7 years ago

There is also one case with prototype function witch is not supported

return Array.prototype.unshift.apply(this.object, data) // i know this is invalid
return Object.assign(this.object, data);
michalsnik commented 7 years ago

Ready in v3.6.0 🚀 Let's leave such edge cases for further consideration, we need to be agile and constantly improve those rules starting with simple solutions. I'm going to move those ideas to separate issue and closing this one. Thanks for a great discussion here guys!