Vue样式隔离是怎么实现的?

3,765 阅读2分钟

使用vueSFC的时候,在<style> 标签加上scoped就可以实现样式隔离,只会作用在当前组件。

<template>
  <div class="about">
    <h1>This is an about page</h1>
  </div>
</template>

<style scoped>
  .about {
    color: red
  }
</style>

chrome上面: scoped-style.png

我们通过调试源码来分析下这个是怎么实现的。

vue-loader源码流程分析

先上一张vue-loader工作流程图,先混个印象,回头可细看。

image.png

查看配置

不知道你是否看过vue-cli默认的webpack配置, 可以通过vue inspect > config.js命令把项目的配置通过文件输出,@vue/cli 4.5.10默认配置如下。只关注对.vue文件的配置

module.exports = {
  module: {
    rules: [
      /* config.module.rule('vue') */
      {
        test: /\.vue$/,
        use: [
          {
            loader: 'D:\\work_space\\qiankun-example\\sub-vue\\node_modules\\cache-loader\\dist\\cjs.js',
            options: {
            // 之前打包结果的保存路径
              cacheDirectory: 'D:\\work_space\\qiankun-example\\sub-vue\\node_modules\\.cache\\vue-loader',
              cacheIdentifier: 'ffa56dac'
            }
          },
          {
            loader: 'D:\\work_space\\qiankun-example\\sub-vue\\node_modules\\vue-loader\\lib\\index.js',
            options: {
              compilerOptions: {
                whitespace: 'condense'
              },
              cacheDirectory: 'D:\\work_space\\qiankun-example\\sub-vue\\node_modules\\.cache\\vue-loader',
              cacheIdentifier: 'ffa56dac'
            }
          }
        ]
      },
    ]
  },

  plugins: [
    /* config.plugin('vue-loader') */
    new VueLoaderPlugin(),
    // ....
  ],

}

通过配置看出两点

  • vue-loader之后还有cache-loader,这个loader主要作用是缓存上次编译的结果,针对未被修改的文件,可以直接拉去缓存,大大缩短编译时间,也是性能提升最明显的一个loader。
  • 处理vue文件同时需要vue-loaderVueLoaderPlugin插件,缺一不可

VueLoaderPlugin

这个插件主要作用有以下几点

  • webpack中Compiler对象添加一个标识,vue-loader在执行的时候会监测是否注册了VueLoaderPlugin
  • 检测compilerrules中是否存在处理.vue文件的loader,没有抛出错误
  • 添加一个RuleSet格式的全局pitcher-loader, 如import Foo from './foo.css?vue会匹配这个loader,带有vue参数
  • 复制一份Ruleset格式的rules覆盖之前的rules

源码片段分析:

sub-vue\node_modules\vue-loader\lib\plugin-webpack4.js:

    // 添加一个标记,用于检测是是否已经配置了vueLoaderPlugin
    if (compiler.hooks) {
      // webpack 4
      compiler.hooks.compilation.tap(id, compilation => {
        const normalModuleLoader = compilation.hooks.normalModuleLoader
        normalModuleLoader.tap(id, loaderContext => {
          loaderContext[NS] = true
        })
      })
    }
    
    // 前置全局的pitcher-loader,用于处理vue中的template、script、style块
    const pitcher = {
      loader: require.resolve('./loaders/pitcher'),
      resourceQuery: query => {
          // 这个loader只有这种格式的请求才会被命中,query中存在vue字段
          // import script from \"./App.vue?vue&type=script&lang=js
        const parsed = qs.parse(query.slice(1))
        return parsed.vue != null
      },
      options: {
         // 缓存存放的路径
         // sub-vue\node_modules\.cache\vue-loader
        cacheDirectory: vueLoaderUse.options.cacheDirectory,
      }
    }

    // 使用ruleset格式的rules替换原来的rule
    compiler.options.module.rules = [
      pitcher,
      ...clonedRules,
      ...rules
    ]
  }

VueLoader

配置完插件,npm run build后进入webpackbuildModule阶段,此时会使用loaderRunner解析源文件。

下面两种请求都会经过vue-loader,区别就是是否带有resourceQuery

  1. import App from './App.vue
  2. import * from "./App.vue?vue&type=template&id=1511d40d&

import App from './App.vue

  • 解析resourceQuery,拿到loader的query参数,上面的解析结果是{}
  • 通过vue-template-compilerparseComponent.vueSFC解析为三大块template、script、style, script还会生成mapping传递给后面的loader,这里用到了LRU缓存算法,因为后续相同的源文件还会继续调用parse方法,需要缓存防止性能浪费,同时还使用hash-sum模块计算出源文件对应的唯一的cacheKey,如果源文件被修改,缓存自然自然会失效。
// sub-vue\node_modules\@vue\component-compiler-utils\dist\parse.js
const cache = new (require('lru-cache'))(100);
function parse(options) {
    // ...忽略
    // 缓存编译结果
    const cacheKey = hash(filename + source + JSON.stringify(compilerParseOptions));
    let output = cache.get(cacheKey);
    if (output)
        return output;
    output = compiler.parseComponent(source, compilerParseOptions);
  • 因为resourceQuery为空不会走这里的逻辑
  // sub-vue\node_modules\vue-loader\lib\index.js
  // if the query has a type field, this is a language block request
  // e.g. foo.vue?type=template&id=xxxxx
  // and we will return early
  if (incomingQuery.type) {
    return selectBlock(
      descriptor,
      loaderContext,
      incomingQuery,
      !!options.appendExtension
    )
  }
  • 计算css-scoped id, 用于css隔离,id就是类似data-v-039c5b43中的039c5b43
// sub-vue\node_modules\vue-loader\lib\index.js
  const id = hash(
    isProduction
      ? (shortFilePath + '\n' + source.replace(/\r\n/g, '\n'))
      : shortFilePath
  )
  • 组装这次要返回的字符串,对template、script、style分别转换成带参数的请求,主要是加上?vue&type=template&id=1511d40d&这样的resourceQuery。 如果是开发模式还会带上vue-loader自己实现模块热替换接口,同时还会带上运行时的一段normalize component函数用于格式化组件暴露的值。

image.png

  • 此时请求import App from './App.vuevue-loader返回的JSON字符串是这样的。它把一个请求重新拆分为三个请求,通过type=区分

image.png

import * from "./App.vue?vue&type=template&id=1511d40d&

这个模块请求还是通过vue-loader解析,但是处理顺序会有区别。上面请求带有vue命中注册过的全局pitcher-loader,这个是一种特殊的loader,普通loader都是按照倒序执行的。但是如果存在pitcher-loader顺序会有变化,这里稍微说下区别

假如配置一个处理css的rule如下

{
    test: /\.css$/,
    use: [normalLoader1, normalLoader2, normalLoader3, normalLoader4]
}

那么就是倒序串联执行

image.png

如果遇到pitcher-loader

{
    test: /\.css$/,
    use: [normalLoader1, pitcherLoader, normalLoader3, normalLoader4]
}

从pitcher-loader先执行,跳过normalLoader3, normalLoader4,pticher-loader函数入参不再是源文件buffer,而是剩余请求normalLoader3, normalLoader4,把然后再到normalLoader1。具体的实现可以查看LoaderRunner源码sub-vue\node_modules\loader-runner\lib\LoaderRunner.js。一般来说,pitcher-loader后面不会有normalLoader,常见的pticher-loader就是开发模式使用的style-loader,把css通过style标签插入到head。

image.png

import * from "./App.vue?vue&type=template&id=1511d40d&时会发生下面这些事情

  • 先经过pitcher-loader处理,pitcher-loader会以下处理
    • 过滤loader,移除eslint-loader和自身
    • 匹配当前的type, type为template,则会匹配template逻辑部分,重新序列化请求,加上cache-loader和templateLoader, 上面的请求被转为了,会经过三个inline-loader处理
    -!../node_modules/cache-loader/dist/cjs.js?{\\"cacheDirectory\\":\\"node_modules/.cache/vue-loader\\",\\"cacheIdentifier\\":\\"7e12f88b-vue-loader-template\\"}
    !../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../node_modules/cache-loader/dist/cjs.js??ref--0-0
    !../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=template&id=1511d40d&
    
    vue中template处理流程:

image.png

其中-!, !, !!符号意思参考wepack-inline-loader

给template标签元素加上_scoped

  • 转换后的行内请求首先回到vue-loader处理, 之前已经计算过descriptor结果,缓存直接返回。

image.png

  • 进入selectBlock, slectBlock会分别处理不同类型的type,loaderContext.callback可以知道vue-loader是一个异步loader, templateLoader会等待它完成后再调用。

image.png

  • template-loader会接收vue-loader的处理结果,他的作用是
    • vue-template-compiler经过parser、optimization、generate阶段会返回render函数,和静态节点staticRender image.png

你可能发现scopedId也传给了compiler,是不是生成render函数已经带上了data-v-1511d40d,其实并没有。出于打包后体积考虑,compiler没有再render函数就添加上{attrs: {'data-v-1511d40d'}},而是把_scopedId运行时patch阶段再加上。

回到上面说的normalize component,当代码浏览器运行的时候,会发现$options有个_scopedId, 这个会在首次patch时候动态设置上。

image.png

自此我们已经知道标签上的_scopedid是怎么来的,那么css属性选择器是怎么加上的呢?

给css样式加上_scopedId

上面的分析都是针对template的,我们还存在style要处理,同样的pitch-loader再处理src\\App.vue?vue&type=style&index=0&lang=css&, 把请求转换为行内请求,如下

"-!../node_modules/mini-css-extract-plugin/dist/loader.js??ref--6-oneOf-1-0!../node_modules/css-loader/dist/cjs.js??ref--6-oneOf-1-1
!../node_modules/vue-loader/lib/loaders/stylePostLoader.js
!../node_modules/postcss-loader/src/index.js??ref--6-oneOf-1-2
!../node_modules/cache-loader/dist/cjs.js??ref--0-0
!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=style&index=0&lang=css"

npm run build模式,style会经过5个loader。 image.pngvue-loader处理的时候,跟处理template流程一样,只是单纯返回缓存。而_scpoedId就是在style-post-loader处理时加上的

// sub-vue\node_modules\vue-loader\lib\loaders\stylePostLoader.js
const qs = require('querystring')
const { compileStyle } = require('@vue/component-compiler-utils')

// 这是一个后置loader用来转换css, 用于加上css-scoped
module.exports = function (source, inMap) {
  const query = qs.parse(this.resourceQuery.slice(1))
  // 通过postcss解析器重新组装css
  const { code, map, errors } = compileStyle({
    source,
    filename: this.resourcePath,
    id: `data-v-${query.id}`,
    map: inMap,
    scoped: !!query.scoped,
    trim: true
  })
}

compileStyle方法时通过postcss能力返回新的css,postcss跟babel有点类似,它可以处理一些css语法,如变量,自动加上浏览器前缀之类。它也是可以把css解析为css-ast,然后通过插件机制对不同的节点类型进行编辑,通过generate重新生成新的css。而vue则通过自定义一个postcss-plugin来给css加上_scopedId和实现/deep/能力

一个postcss-plugin插件格式是这样的

module.exports = (opts = {}) => {
  return {
    postcssPlugin: '插件名字',
    prepare (result) {
      // 这里可以放一些公共的逻辑
      return {
        Declaration (node) {},
        Rule (node) {},
        AtRule (node) {}
      }
    }
  }
}

css 的 AST 比 js 的简单多了,主要有这么几种:

  • @符号样式开头的,比如@meida,媒体查询
  • Rule,就是具体的css类名
  • Dec就是具体的样式padding: 5px这种

那么只要写一个插件,遍历这几种类型,修改css-ast,加上_scopedId即可,实现并不难。 具体可以查看\node_modules\@vue\component-compiler-utils\lib\stylePlugins\scoped.ts文件的具体实现。

image.png

至此css样式隔离实现。最近看的京东那套微前端实现,它的css隔离也是类似的方式,只是不依赖postcss,自己撸了一个cssParser,通过正则暴力替换。

总结

因为vue-loader跟webpack是强相关的,因此需要了解一些webpack流程的知识,不然实在难啃。话说你们的vite上生产了吗?