linkedin / css-blocks

High performance, maintainable stylesheets.
http://css-blocks.com/
BSD 2-Clause "Simplified" License
6.33k stars 152 forks source link

Intent to implement: Vue SFC integration #151

Open zephraph opened 6 years ago

zephraph commented 6 years ago

Current State: Research

This ticket will guide the discussion of implementing css-blocks in Vue Single File Components.

Motivation

Vue provides a rich interface for creating single file components. In the ecosystem, vue-loader provides both an implementation of scoped styles and easy hooks into css-modules. Scoped styles work well until they don't. Css-modules relies on a lot of dynamic class bindings and has its own set of issues making it a non-optimal solution. I think css-blocks has an opportunity to shine.

Scope

Notes

@chriseppstein I'm sure I'll have a lot of questions. Once we can work through the basic approach I'll create a POC PR.

chriseppstein commented 6 years ago

I'd definitely like this to be part of the monorepo, so it can be more easily maintained as things evolve.

I don't know much about Vue's SFC format, so I have some questions about how it works and what expectations developer have based on what I read in the documentation.

  1. It looks like the goal of the <style> tag that lacks a scoped attribute is to allow a component to shove styles into the global namespace so they will match all components. This is mostly antithetical to css-blocks' design. It seems like the expectation by developers is that they can target classes in other components according to their class names in those global styles. For example, if two components have a class .button, then .button would match both components? What about third party components -- does it match a .button there? What about in the other direction? In theory, we can make opticss support this by passing it an option that some classnames should never be removed from elements, even if they're unused by all the styles in the element. That list would include all the class names that are referenced in global styles, but... in css-block's syntax it's not the authored syntax, it's a BEM class so... I guess I'm saying I don't see how components that are using css-blocks integrate cleanly into that scheme.... which is probably why you said "No scoped attributes needed." 😅
  2. It looks like Vue supports an arbitrary number of <style> tags per SFC. What are the semantics of that? Can the same element be targeted by styles in both?
  3. Does a SFC have only one <template>?
  4. Does vue put this data hash (e.g. data-v-f3f3eg9) on every element that matches the selector? If there are two scoped style sections, does that element get two different hashes? or is that hash the same hash on every element in the template regardless of what element it is? Is it a hash in production? This is mostly just curiosity, because it looks just atrocious for the runtime compiled output's size. CSS Blocks would eliminate the need for this hash completely. It should dramatically reduce the output size.
  5. Does vue allow the <style> to have javascript in them? If so, how is that handled?
  6. Where does an app usually put styles that are common to the entire application? E.g. a reset.css. Is that done with a globally unscoped <style> or is it just a css file that gets bundled into the build?
  7. Is there a public vue app you think is done well in terms of how it uses SFC's and the styles for it? I'd like to poke around and see how a non-trivial app is put together.
zephraph commented 6 years ago

1.) style tags without the scoped tag would be treated the same way if you did import './style.css' in a js file. No scoping or special targeting happens, vue-loader just ensures that file is pulled in with the overall component. In this implementation we would assume the style block in an SFC would be a Block. 2.) That would be like importing multiple css files. Yes, you can absolutely target the same element in multiple style blocks. As a stipulation of this implementation we could require only one style block. 3.) Yes, an SFC can only have one template and script block. See the spec. 4.) That attribute is only added when the scoped attribute is present on a style tag in the SFC. That wouldn't be a concern for us. 5.) No, style tags are treated like CSS by default. They can have a lang attribute that allows them to be in a preprocessor language though. 6.) That really depends. You could import it via webpack in the main js entry file. You could include them in an unscoped style tag. Either or. 7.) I don't actually know of one off the top of my head, but I'll look around. You can peek at @chrisvfritz's enterprise boilerplate though. I'll look around for a larger public app.

chriseppstein commented 6 years ago

It's possible to hook into the compilation process to support custom template features. However, beware that by injecting custom compile-time modules, your templates will not work with other build tools built on standard built-in modules, e.g vue-loader and vueify.

I think we need to understand what this means and if it's an ok tradeoff to be making. It might be a good idea to loop @yyx990803 in to help us understand what is the right way to produce a globally optimized css output across all vue files and tie that back into vue's compilation. Right now, when we use webpack, we traverse all the jsx files and produce a dependency graph, compile all the styles we discover in use, then take what we learn back to components so they are processed correctly. The loader we inject for those jsx files is async and forces the template being loaded to wait until all the css compilation/optimization is done.

zephraph commented 6 years ago

I think that means if you're explicitly hooking into the template compilation process to support new template features, e.g. compile time directives. We won't be doing that. Getting Evan's input would be valuable though. My idea was to create a wrapper around vue-template-compiler that did the analysis first and then used vue-template-compiler to complete the compilation. That might not work however, because you don't have access to webpacks compilation scope and it'd be difficult to know when the processing phase was complete.

chriseppstein commented 6 years ago

As a stipulation of this implementation we could require only one style block.

I think this helps some things, but if people think it's important, we can design around it.

The way that vue is currently handling component style interactions with the /deep/ or global styles won't work for css-blocks' intended design. Instead, I think we'd want to allow the primary style block from another component to be referenced. This is common when a component wants to provide styles for use inside of the content that provided by the caller.

Common block styles can be shared with a <style src>.

<script>
import Dropdown from './dropdown.vue'
</script>
<style src="./grid.block.css" title="grid">
<style title="FancyDropDown">
   :scope {
      extends: Dropdown; 
      font-size: 18px;
      color: green;
   }
    // this is the .contents class used in the drop down component, we reference it
    // by inheriting it and then we pass this entire block to the component to be used
    // instead
   .contents {
     color: black;
   }
</style>
<template>
  <div class="grid.span-3">
    <Dropdown :open="false">
      <a class="Dropdown.close-handle">I close the dropdown</a>
    </Dropdown>
  </div>
  <div class="grid.span-4">
   // this can be statically analyzed because the use of styles in the component can transferred
   // onto the inheriting block of styles. The style swapping is done by passing a simple id
   //  into the component that lets the component switch between all the possible blocks
   // that can replace the component's default block.
  <Dropdown :style="FancyDropDown">
    <a class="FancyDropdown.close-handle">I close the dropdown</a>
  </Dropdown>
  </div>
</template>
zephraph commented 6 years ago

/deep/ only applies to scoped components. That wouldn't be a consideration in our case because we wouldn't be utilizing the scoping mechanism.

My thoughts were

<template>
  <header>
    <h1 class="headline">Blah blah blah</h1>
    <h6 class="subheadline">blah</h6>
    <child-component class="child"/>
  </header>
</template>

<style>
// This still works fine
@block-reference heading from "./types.block.css";

:scope {
  // component styles
  extends: heading
}

.headline {
  // styles targeting headline
}

.child {
  // styles targeting root of child component
}
</style>

I do have major questions about state selectors. We could specify a special state prop and require that to explicitly list the possible states.

OEvgeny commented 6 years ago

Hello, I'm also interested in using css-blocks with Vue! There are a couple of notes related to current state notes:

A personal desire of mine is to have this functionality by default

I believe this could be a stop sign for existing Vue projects to start using css-blocks.

Also plain styles are useful at some point. Mixing them with scoped ones also a well-known practice. That's why limiting style tags to one per file may be not a good choice.

Can't we just introduce a new (block) keyword for style tags like scoped and module? And require only one style with block keyword for the component? E.g.

<template></template>

<script></script>

<style block></style>

Anyway, for SFCs compilation there is a set of utils: https://github.com/vuejs/component-compiler-utils may be useful!

OEvgeny commented 6 years ago

In case it is possible to get template AST we could use a standard directive syntax to handle state related logic:

<template>
  <button v-state:inverse="inverse" v-state:size="size">...</button>
</template>

<script>

export default {
  data: () => ({
    size: 'small',
    inverse: false
  })
}

</script>

Here is the template compiled to render function:

var render = function() {
  var _vm = this;
  var _h = _vm.$createElement;
  var _c = _vm._self._c || _h;
  return _c('button', {
    directives: [{
        name: "state",
        rawName: "v-state:size",
        value: (_vm.size),
        expression: "size",
        arg: "size"
      },
      {
        name: "state",
        rawName: "v-state:invert",
        value: (_vm.invert),
        expression: "invert",
        arg: "invert"
      }
    ]
  }, [
    _vm._v("...")
  ])
}

var staticRenderFns = [

]

Then code to handle classes could be added in place of a directive in the AST.

chriseppstein commented 6 years ago

I believe this could be a stop sign for existing Vue projects to start using css-blocks.

Also plain styles are useful at some point. Mixing them with scoped ones also a well-known practice. That's why limiting style tags to one per file may be not a good choice.

The static analysis requirement of css-blocks, coupled with the way that opticss takes liberties with selector specificity and order, means that mixing styles from css-blocks with other styles on the same element is unpredictable. it could work right now and later, the optimizer might change or the styles of the app might change such that it triggers an optimization that it didn't used to and then the element's style is broken.

Mixing styles in the same component is doable, but we'd have to make sure the syntax is unambiguous about which styles are being referenced.

Mixing css-blocks with other styling systems at component boundaries is an expected use case, but css-blocks styles cannot be "passed" to a non-css-blocks component (lest they mix them).

But this isn't as big of a deal as it sounds, Scoped styles are different from css-blocks, and I think @zephraph has it right by not wanting them to be called "scoped". Styles from CSS Blocks are globally addressable and guaranteed to be globally unique within an application. To accomplish this, the styles must be addressed in a way that is unambiguous. So I would not assume that the use cases that applied to scoped styles will automatically apply to css-blocks.

Can't we just introduce a new (block) keyword for style tags like scoped and module?

It seems good to me. I think there needs to be an unambiguous way of saying "these styles are block styles", we use a filename convention of *.block.css for that, but there's no filename in the SFC context.

And require only one style with block keyword for the component?

This limitation isn't an obvious requirement to me. I think there can be only one "default" block, but I think we could allow several blocks to be created in the SFC, with one block per <style block>. But the subsequent blocks would require a title="name".

Importing a component would automatically make the component's default block in scope for style references (open question: does there need to be some way to declare a block as private to that component?)

Non-default blocks could be referenced, in theory, via a @block-reference ... or <style src=...> with a url like path/to/component.vue#blockname.

Referencing blocks in other components becomes important for accessing @block-global states, and for passing sub-blocks to another component in order to override styles.

In case it is possible to get template AST

We have to make this possible. Our approach requires AST-level access and manipulation before the component is compiled.

Regarding this syntax proposal:

<template>
  <button v-state:inverse="inverse" v-state:size="size">...</button>
</template>

I don't know what it means for something to begin with v-.

Some notes:

zephraph commented 6 years ago

I'm leaning towards having more constraints early in this implementation and loosening them as we proceed. I'm fine w/ having a block attribute on the style tag, but I'll certainly make that configurable because I'm a firm believer that the project should apply best practices by default and give escape hatches as necessary.

The static analysis requirement of css-blocks, coupled with the way that opticss takes liberties with selector specificity and order, means that mixing styles from css-blocks with other styles on the same element is unpredictable.

To me this says having non-block or global styles in a component aside current would be a bad idea... but...

Mixing styles in the same component is doable, but we'd have to make sure the syntax is unambiguous about which styles are being referenced.

That makes me think otherwise. What does this mean exactly? So long as you know what's a css-block and what's not then it's doable?

As for the v- part, that signifies a directive in Vue. There are special parser semantics around directives that make them easier to work with in the AST. Here's a (messy) example.

The dotted notation would work fine in the case of static classes. Dynamic classes would be bound to some runtime variable which would make that more difficult. I believe therein lies an issue as well... there'll have to be special enforcement around how a class can be dynamically bound for css-blocks to be able to determine its usage...

Going to be a tough challenge, heh.

zephraph commented 6 years ago

I updated my example to be a bit cleaner. Feel free to use that as a way to play around with Vue's template compiler to see what AST would be generated for the template. Thinking about it more, we don't necessarily have to make state a directive, but it would require a little more heavy lifting on our side of the processing.

OEvgeny commented 6 years ago

Thanks for answers guys!

I don't know what it means for something to begin with v-.

As mentioned above this is the way how Vue folks can affect DOM called directives. Before vue@2.x all directives had to be evaluated on the fly. Vue 2.x added an ability to pre/compile templates to render functions. Almost all built-in directives were migrated to compile-time evaluation.

I think we could go the same way with v-state directive, since it should be well-known concept for devs.

Is it normal for applications or frameworks to define new v- attributes?

Yes it is. For example here is the official RxJS integration which introduces a new directive. Unfortunately It's just a user-land solution (doesn't touch AST). But I think you got the idea. There is no standard way for directives to modify AST, but template compiler just already implements that for built-in ones.

Isn't the convention that the value of v- attributes are values bound to the app scope?

I'd say yes. They are bound to current component's scope. Template compiler processes all custom directives the same way - pushes to corresponding vnode option (directives) object with the parameters. Something like: (here and below examples from generated render function vnode options object)

  // v-state:block1.size="sizeProp"
  attrs: {...},
  directives: [{
    name: "state",
    rawName: "v-state:block1.size",
    value: (sizeProp),
    expression: "sizeProp",
    arg: "block1",
    modifiers: {
      "size": true
    }
  }]

Since it is an expression, we can use static value here e.g. v-state:size="'small'". Which looks a bit strange but is something expectable to me as Vue dev ;)

In case we want to support syntax like state:size="small" template compiler will produce just:

  // state:block1.size="small"
  attrs: {
    "state:block1.size": "small"
  }

Note using expressions here will affect developer experience since expressions are highlighted properly in Vue templates and here they won't be highlighted.

It's worth pointing out that in #97, we're discussing possible ideas of allowing namespaces to be

This could be a problem for Vue (with directive syntax we are talking about) because delimiters are : and .. I quickly checked something like v-state:foo:bar:buz.qux.quux. It does compile (not sure it won't break):

  directives: [{
    name: "state",
    rawName: "v-state:foo:bar:buz.qux.quux",
    value: (small),
    expression: "small",
    arg: "foo:bar:buz",
    modifiers: {
      "qux": true,
      "quux": true
    }
  }]
yyx990803 commented 6 years ago

I haven't got time to fully investigate this topic, but if anything I'd stay away from directives. I'd suggest looking into custom compiler modules that are more powerful then directives. (API type interface)

These modules can be passed to the underlying compiler via vue-loader options as well.

OEvgeny commented 6 years ago

@yyx990803 Wow, didn't know there is such feature! Anyway, above we discussed using only syntax of directives but process them in compilation phase. Seems custom compiler modules is the right way to achieve that.

chriseppstein commented 6 years ago

@yyx990803 Thanks, evan. It's a lot to digest. If we get stuck, I'm sure we can form more succinct questions :)

betweenbrain commented 6 years ago

Hello all, this is fantastic! Is there another thread or repo that can be followed or contributed to for this?

zephraph commented 6 years ago

I started a new job a few weeks ago and haven't really had the time to dig into it. Fork and kick it off!

SasanFarrokh commented 4 years ago

Any updates on this?