详解从vue-loader源码分析CSS Scoped的实现

虽然写了很长一段时间的Vue了,对于CSS Scoped的原理也大致了解,但一直未曾关注过其实现细节。最近在重新学习webpack,因此查看了vue-loader源码,顺便从vue-loader的源码中整理CSS Scoped的实现。

本文展示了vue-loader中的一些源码片段,为了便于理解,稍作删减。参考

Vue CSS SCOPED实现原理
Vue loader官方文档

相关概念

CSS Scoped的实现原理

在Vue单文件组件中,我们只需要在style标签上加上scoped属性,就可以实现标签内的样式在当前模板输出的HTML标签上生效,其实现原理如下

  • 每个Vue文件都将对应一个唯一的id,该id可以根据文件路径名和内容hash生成
  • 编译template标签时时为每个标签添加了当前组件的id,如<div class="demo"></div>会被编译成<div class="demo" data-v-27e4e96e></div>
  • 编译style标签时,会根据当前组件的id通过属性选择器和组合选择器输出样式,如.demo{color: red;}会被编译成.demo[data-v-27e4e96e]{color: red;}

了解了大致原理,可以想到css scoped应该需要同时处理template和style的内容,现在归纳需要探寻的问题

  • 渲染的HTML标签上的data-v-xxx属性是如何生成的
  • CSS代码中的添加的属性选择器是如何实现的

resourceQuery

在此之前,需要了解首一下webpack中Rules.resourceQuery的作用。在配置loader时,大部分时候我们只需要通过test匹配文件类型即可

{
 test: /\.vue$/,
 loader: 'vue-loader'
}
// 当引入vue后缀文件时,将文件内容传输给vue-loader进行处理
import Foo from './source.vue'

resourceQuery提供了根据引入文件路径参数的形式匹配路径

{
 resourceQuery: /shymean=true/,
 loader: path.resolve(__dirname, './test-loader.js')
}
// 当引入文件路径携带query参数匹配时,也将加载该loader
import './test.js?shymean=true'
import Foo from './source.vue?shymean=true'

vue-loader中就是通过resourceQuery并拼接不同的query参数,将各个标签分配给对应的loader进行处理。

loader.pitch

参考

pitching-loader官方文档
webpack的pitching loader

webpack中loaders的执行顺序是从右到左执行的,如loaders:[a, b, c],loader的执行顺序是c->b->a,且下一个loader接收到的是上一个loader的返回值,这个过程跟"事件冒泡"很像。

但是在某些场景下,我们可能希望在"捕获"阶段就执行loader的一些方法,因此webpack提供了loader.pitch的接口。
一个文件被多个loader处理的真实执行流程,如下所示

a.pitch -> b.pitch -> c.pitch -> request module -> c -> b -> a

loader和pitch的接口定义大概如下所示

// loader文件导出的真实接口,content是上一个loader或文件的原始内容
module.exports = function loader(content){
 // 可以访问到在pitch挂载到data上的数据
 console.log(this.data.value) // 100
}
// remainingRequest表示剩余的请求,precedingRequest表示之前的请求
// data是一个上下文对象,在上面的loader方法中可以通过this.data访问到,因此可以在pitch阶段提前挂载一些数据
module.exports.pitch = function pitch(remainingRequest, precedingRequest, data) {
 data.value = 100
}}

正常情况下,一个loader在execution阶段会返回经过处理后的文件文本内容。如果在pitch方法中直接返回了内容,则webpack会视为后面的loader已经执行完毕(包括pitch和execution阶段)。

在上面的例子中,如果b.pitch返回了result b,则不再执行c,则是直接将result b传给了a。

VueLoaderPlugin

接下来看看与vue-loader配套的插件:VueLoaderPlugin,该插件的作用是:

将在webpack.config定义过的其它规则复制并应用到 .vue 文件里相应语言的块中。

其大致工作流程如下所示

  • 获取项目webpack配置的rules项,然后复制rules,为携带了?vue&lang=xx...query参数的文件依赖配置xx后缀文件同样的loader
  • 为Vue文件配置一个公共的loader:pitcher
  • 将[pitchLoder, ...clonedRules, ...rules]作为webapck新的rules
// vue-loader/lib/plugin.js
const rawRules = compiler.options.module.rules // 原始的rules配置信息
const { rules } = new RuleSet(rawRules)

// cloneRule会修改原始rule的resource和resourceQuery配置,携带特殊query的文件路径将被应用对应rule
const clonedRules = rules
   .filter(r => r !== vueRule)
   .map(cloneRule)
// vue文件公共的loader
const pitcher = {
 loader: require.resolve('./loaders/pitcher'),
 resourceQuery: query => {
  const parsed = qs.parse(query.slice(1))
  return parsed.vue != null
 },
 options: {
  cacheDirectory: vueLoaderUse.options.cacheDirectory,
  cacheIdentifier: vueLoaderUse.options.cacheIdentifier
 }
}
// 更新webpack的rules配置,这样vue单文件中的各个标签可以应用clonedRules相关的配置
compiler.options.module.rules = [
 pitcher,
 ...clonedRules,
 ...rules
]

因此,为vue单文件组件中每个标签执行的lang属性,也可以应用在webpack配置同样后缀的rule。这种设计就可以保证在不侵入vue-loader的情况下,为每个标签配置独立的loader,如

  1. 可以使用pug编写template,然后配置pug-plain-loader
  2. 可以使用scss或less编写style,然后配置相关预处理器loader

可见在VueLoaderPlugin主要做的两件事,一个是注册公共的pitcher,一个是复制webpack的rules。

vue-loader

接下来我们看看vue-loader做的事情。

pitcher

前面提到在VueLoaderPlugin中,该loader在pitch中会根据query.type注入处理对应标签的loader

  • 当type为style时,在css-loader后插入stylePostLoader,保证stylePostLoader在execution阶段先执行
  • 当type为template时,插入templateLoader
// pitcher.js
module.exports = code => code
module.exports.pitch = function (remainingRequest) {
 if (query.type === `style`) {
  // 会查询cssLoaderIndex并将其放在afterLoaders中
  // loader在execution阶段是从后向前执行的
  const request = genRequest([
   ...afterLoaders,
   stylePostLoaderPath, // 执行lib/loaders/stylePostLoader.js
   ...beforeLoaders
  ])
  return `import mod from ${request}; export default mod; export * from ${request}`
 }
 // 处理模板
 if (query.type === `template`) {
  const preLoaders = loaders.filter(isPreLoader)
  const postLoaders = loaders.filter(isPostLoader)
  const request = genRequest([
   ...cacheLoader,
   ...postLoaders,
   templateLoaderPath + `??vue-loader-options`, // 执行lib/loaders/templateLoader.js
   ...preLoaders
  ])
  return `export * from ${request}`
 }
 // ...
}

由于loader.pitch会先于loader,在捕获阶段执行,因此主要进行上面的准备工作:检查query.type并直接调用相关的loader

  • type=style,执行stylePostLoader
  • type=template,执行templateLoader

这两个loader的具体作用我们后面再研究。

vueLoader

接下来看看vue-loader里面做的工作,当引入一个x.vue文件时

// vue-loader/lib/index.js 下面source为Vue代码文件原始内容

// 将单个*.vue文件内容解析成一个descriptor对象,也称为SFC(Single-File Components)对象
// descriptor包含template、script、style等标签的属性和内容,方便为每种标签做对应处理
const descriptor = parse({
 source,
 compiler: options.compiler || loadTemplateCompiler(loaderContext),
 filename,
 sourceRoot,
 needMap: sourceMap
})

// 为单文件组件生成唯一哈希id
const id = hash(
 isProduction
 ? (shortFilePath + '\n' + source)
 : shortFilePath
)
// 如果某个style标签包含scoped属性,则需要进行CSS Scoped处理,这也是本章节需要研究的地方
const hasScoped = descriptor.styles.some(s => s.scoped)

处理template标签,拼接type=template等query参数

if (descriptor.template) {
 const src = descriptor.template.src || resourcePath
 const idQuery = `&id=${id}`
 // 传入文件id和scoped=true,在为组件的每个HTML标签传入组件id时需要这两个参数
 const scopedQuery = hasScoped ? `&scoped=true` : ``
 const attrsQuery = attrsToQuery(descriptor.template.attrs)
 const query = `?vue&type=template${idQuery}${scopedQuery}${attrsQuery}${inheritQuery}`
 const request = templateRequest = stringifyRequest(src + query)
 // type=template的文件会传给templateLoader处理
 templateImport = `import { render, staticRenderFns } from ${request}`

 // 比如,<template lang="pug"></template>标签
 // 将被解析成 import { render, staticRenderFns } from "./source.vue?vue&type=template&id=27e4e96e&lang=pug&"
}

处理script标签

let scriptImport = `var script = {}`
if (descriptor.script) {
 // vue-loader没有对script做过多的处理
 // 比如vue文件中的<script></script>标签将被解析成
 // import script from "./source.vue?vue&type=script&lang=js&"
 // export * from "./source.vue?vue&type=script&lang=js&"
}

处理style标签,为每个标签拼接type=style等参数

// 在genStylesCode中,会处理css scoped和css moudle
stylesCode = genStylesCode(
 loaderContext,
 descriptor.styles,
 id,
 resourcePath,
 stringifyRequest,
 needsHotReload,
 isServer || isShadow // needs explicit injection?
)

// 由于一个vue文件里面可能存在多个style标签,对于每个标签,将调用genStyleRequest生成对应文件的依赖
function genStyleRequest (style, i) {
 const src = style.src || resourcePath
 const attrsQuery = attrsToQuery(style.attrs, 'css')
 const inheritQuery = `&${loaderContext.resourceQuery.slice(1)}`
 const idQuery = style.scoped ? `&id=${id}` : ``
 // type=style将传给stylePostLoader进行处理
 const query = `?vue&type=style&index=${i}${idQuery}${attrsQuery}${inheritQuery}`
 return stringifyRequest(src + query)
}

可见在vue-loader中,主要是将整个文件按照标签拼接对应的query路径,然后交给webpack按顺序调用相关的loader。

templateLoader

回到开头提到的第一个问题:当前组件中,渲染出来的每个HTML标签中的hash属性是如何生成的。

我们知道,一个组件的render方法返回的VNode,描述了组件对应的HTML标签和结构,HTML标签对应的DOM节点是从虚拟DOM节点构建的,一个Vnode包含了渲染DOM节点需要的基本属性。

那么,我们只需要了解到vnode上组件文件的哈希id的赋值过程,后面的问题就迎刃而解了。

// templateLoader.js
const { compileTemplate } = require('@vue/component-compiler-utils')

module.exports = function (source) {
 const { id } = query
 const options = loaderUtils.getOptions(loaderContext) || {}
 const compiler = options.compiler || require('vue-template-compiler')
 // 可以看见,scopre=true的template的文件会生成一个scopeId
 const compilerOptions = Object.assign({
  outputSourceRange: true
 }, options.compilerOptions, {
  scopeId: query.scoped ? `data-v-${id}` : null,
  comments: query.comments
 })
 // 合并compileTemplate最终参数,传入compilerOptions和compiler
 const finalOptions = {source, filename: this.resourcePath, compiler,compilerOptions}
 const compiled = compileTemplate(finalOptions)

 const { code } = compiled

 // finish with ESM exports
 return code + `\nexport { render, staticRenderFns }`
}

关于compileTemplate的实现,我们不用去关心其细节,其内部主要是调用了配置参数compiler的编译方法

function actuallyCompile(options) {
 const compile = optimizeSSR && compiler.ssrCompile ? compiler.ssrCompile : compiler.compile
 const { render, staticRenderFns, tips, errors } = compile(source, finalCompilerOptions);
 // ...
}

在Vue源码中可以了解到,template属性会通过compileToFunctions编译成render方法;在vue-loader中,这一步是可以通过vue-template-compiler提前在打包阶段处理的。

vue-template-compiler是随着Vue源码一起发布的一个包,当二者同时使用时,需要保证他们的版本号一致,否则会提示错误。这样,compiler.compile实际上是Vue源码中vue/src/compiler/index.js的baseCompile方法,追着源码一致翻下去,可以发现

// elementToOpenTagSegments.js
// 对于单个标签的属性,将拆分成一个segments
function elementToOpenTagSegments (el, state): Array<StringSegment> {
 applyModelTransform(el, state)
 let binding
 const segments = [{ type: RAW, value: `<${el.tag}` }]
 // ... 处理attrs、domProps、v-bind、style、等属性

 // _scopedId
 if (state.options.scopeId) {
  segments.push({ type: RAW, value: ` ${state.options.scopeId}` })
 }
 segments.push({ type: RAW, value: `>` })
 return segments
}

以前面的<div class="demo"></div>为例,解析得到的segments为

[
  { type: RAW, value: '<div' },
  { type: RAW, value: 'class=demo' },
  { type: RAW, value: 'data-v-27e4e96e' }, // 传入的scopeId
  { type: RAW, value: '>' },
]

至此,我们知道了在templateLoader中,会根据单文件组件的id,拼接一个scopeId,并作为compilerOptions传入编译器中,被解析成vnode的配置属性,然后在render函数执行时调用createElement,作为vnode的原始属性,渲染成到DOM节点上。

stylePostLoader

在stylePostLoader中,需要做的工作就是将所有选择器都增加一个属性选择器的组合限制,

const { compileStyle } = require('@vue/component-compiler-utils')
module.exports = function (source, inMap) {
 const query = qs.parse(this.resourceQuery.slice(1))
 const { code, map, errors } = compileStyle({
  source,
  filename: this.resourcePath,
  id: `data-v-${query.id}`, // 同一个单页面组件中的style,与templateLoader中的scopeId保持一致
  map: inMap,
  scoped: !!query.scoped,
  trim: true
 })
 this.callback(null, code, map)
}

我们需要了解compileStyle的逻辑

// @vue/component-compiler-utils/compileStyle.ts
import scopedPlugin from './stylePlugins/scoped'
function doCompileStyle(options) {
 const { filename, id, scoped = true, trim = true, preprocessLang, postcssOptions, postcssPlugins } = options;
 if (scoped) {
  plugins.push(scopedPlugin(id));
 }
 const postCSSOptions = Object.assign({}, postcssOptions, { to: filename, from: filename });
 // 省略了相关判断
 let result = postcss(plugins).process(source, postCSSOptions);
}

最后让我们在了解一下scopedPlugin的实现,

export default postcss.plugin('add-id', (options: any) => (root: Root) => {
 const id: string = options
 const keyframes = Object.create(null)
 root.each(function rewriteSelector(node: any) {
  node.selector = selectorParser((selectors: any) => {
   selectors.each((selector: any) => {
    let node: any = null
    // 处理 '>>>' 、 '/deep/'、::v-deep、pseudo等特殊选择器时,将不会执行下面添加属性选择器的逻辑

    // 为当前选择器添加一个属性选择器[id],id即为传入的scopeId
    selector.insertAfter(
     node,
     selectorParser.attribute({
      attribute: id
     })
    )
   })
  }).processSync(node.selector)
 })
})

由于我对于PostCSS的插件开发并不是很熟悉,这里只能大致整理,翻翻文档了,相关API可以参考Writing a PostCSS Plugin

至此,我们就知道了第二个问题的答案:通过selector.insertAfter为当前styles下的每一个选择器添加了属性选择器,其值即为传入的scopeId。由于只有当前组件渲染的DOM节点上上面存在相同的属性,从而就实现了css scoped的效果。

小结

回过头来整理一下vue-loader的工作流程

首先需要在webpack配置中注册VueLoaderPlugin

  1. 在插件中,会复制当前项目webpack配置中的rules项,当资源路径包含query.lang时通过resourceQuery匹配相同的rules并执行对应loader时
  2. 插入一个公共的loader,并在pitch阶段根据query.type插入对应的自定义loader

准备工作完成后,当加载*.vue时会调用vue-loader,

  • 一个单页面组件文件会被解析成一个descriptor对象,包含template、script、styles等属性对应各个标签,
  • 对于每个标签,会根据标签属性拼接src?vue&query引用代码,其中src为单页面组件路径,query为一些特性的参数,比较重要的有lang、type和scoped
    • 如果包含lang属性,会匹配与该后缀相同的rules并应用对应的loaders
    • 根据type执行对应的自定义loader,template将执行templateLoader、style将执行stylePostLoader

在templateLoader中,会通过vue-template-compiler将template转换为render函数,在此过程中,

  • 会将传入的scopeId追加到每个标签的segments上,最后作为vnode的配置属性传递给createElemenet方法,
  • 在render函数调用并渲染页面时,会将scopeId属性作为原始属性渲染到页面上

在stylePostLoader中,通过PostCSS解析style标签内容,同时通过scopedPlugin为每个选择器追加一个[scopeId]的属性选择器

由于需要Vue源码方面的支持(vue-template-compiler编译器),CSS Scoped可以算作为Vue定制的一个处理原生CSS全局作用域的解决方案。除了 css scoped之外,vue还支持css module,我打算在下一篇整理React中编写CSS的博客中一并对比整理。

小结

最近一直在写React的项目,尝试了好几种在React中编写CSS的方式,包括CSS Module、Style Component等方式,感觉都比较繁琐。相比而言,在Vue中单页面组件中写CSS要方便很多。

本文主要从源码层面分析了Vue-loader,整理了其工作原理,感觉收获颇丰

  1. webpack中Rules.resourceQuery和pitch loader的使用
  2. Vue单页面文件中css scoped的实现原理
  3. PostCSS插件的作用

虽然一直在使用webpack和PostCSS,但也仅限于勉强会用的阶段,比如我甚至从来没有过编写一个PostCSS插件的想法。尽管目前大部分项目都使用了封装好的脚手架,但对于这些基础知识,还是很有必要去了解其实现的。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • 深入探索VueJS Scoped CSS 实现原理

    使用VueJS进行应用开发, 脱离不了对应用间的模块进行拆分, 将大块界面拆解为组件的过程. 我们可以很方便的在单文件中使用<template>块维护组件的视图, 使用<script>维护组件的逻辑部分, 使用<style>维护组件的样式. 在我们编写 VueJS 组件样式时, 不得忽略的一点就是样式污染. 样式污染产生原因 提及样式污染, 主要要追溯到Webpack对CSS文件的打包过程, 这里我们以Vue-Element-Admin中的Webpack配置项举例: c

  • Vue中对比scoped css和css module的区别

    scoped css 官方文档 scoped css可以直接在能跑起来的vue项目中使用. 使用方法: <style scoped> h1 { color: #f00; } </style> 使用scoped划分本地样式的结果编译结果如下: h1[data-v-4c3b6c1c] { color: #f00; } 即在元素中添加了一个唯一属性用来区分. 缺点 一.如果用户在别处定义了相同的类名,也许还是会影响到组件的样式. 二.根据css样式优先级的特性,scoped这种处理会造成

  • 详解从vue-loader源码分析CSS Scoped的实现

    虽然写了很长一段时间的Vue了,对于CSS Scoped的原理也大致了解,但一直未曾关注过其实现细节.最近在重新学习webpack,因此查看了vue-loader源码,顺便从vue-loader的源码中整理CSS Scoped的实现. 本文展示了vue-loader中的一些源码片段,为了便于理解,稍作删减.参考 Vue CSS SCOPED实现原理 Vue loader官方文档 相关概念 CSS Scoped的实现原理 在Vue单文件组件中,我们只需要在style标签上加上scoped属性,就可

  • 详解从Vue.js源码看异步更新DOM策略及nextTick

    写在前面 因为对Vue.js很感兴趣,而且平时工作的技术栈也是Vue.js,这几个月花了些时间研究学习了一下Vue.js源码,并做了总结与输出. 文章的原地址:https://github.com/answershuto/learnVue. 在学习过程中,为Vue加上了中文的注释https://github.com/answershuto/learnVue/tree/master/vue-src,希望可以对其他想学习Vue源码的小伙伴有所帮助. 可能会有理解存在偏差的地方,欢迎提issue指出,

  • 详解Element 指令clickoutside源码分析

    clickoutside是Element-ui实现的一个自定义指令,顾名思义,该指令用来处理目标节点之外的点击事件,常用来处理下拉菜单等展开内容的关闭,在Element-ui的Select选择器.Dropdown下拉菜单.Popover 弹出框等组件中都用到了该指令,所以这个指令在实现一些自定义组件的时候非常有用. 要分析该源码,首先要了解一下Vue的自定义指令.自定义指令的定义方式如下: // 注册一个全局自定义指令 Vue.directive('directiveName', { bind:

  • Vue编译器源码分析compileToFunctions作用详解

    目录 引言 Vue.prototype.$mount函数体 源码出处 options.delimiters & options.comments compileToFunctions函数逐行分析 createFunction 函数源码 引言 Vue编译器源码分析 接上篇文章我们来分析:compileToFunctions的作用. 经过前面的讲解,我们已经知道了 compileToFunctions 的真正来源你可能会问为什么要弄的这么复杂?为了搞清楚这个问题,我们还需要继续接触完整的代码. 下面

  • Vue.js源码分析之自定义指令详解

    前言 除了核心功能默认内置的指令 (v-model 和 v-show),Vue 也允许注册自定义指令. 官网介绍的比较抽象,显得很高大上,我个人对自定义指令的理解是:当自定义指令作用在一些DOM元素或组件上时,该元素在初次渲染.插入到父节点.更新.解绑时可以执行一些特定的操作(钩子函数() 自定义指令有两种注册方式,一种是全局注册,使用Vue.directive(指令名,配置参数)注册,注册之后所有的Vue实例都可以使用,另一种是局部注册,在创建Vue实例时通过directives属性创建局部指

  • Mybatis-plus使用TableNameHandler分表详解(附完整示例源码)

    为什么要分表 Mysql是当前互联网系统中使用非常广泛的关系数据库,具有ACID的特性. 但是mysql的单表性能会受到表中数据量的限制,主要原因是B+树索引过大导致查询时索引无法全部加载到内存.读取磁盘的次数变多,而磁盘的每次读取对性能都有很大的影响. 这时一个简单可行的方案就是分表(当然土豪也可以堆硬件),将一张数据量庞大的表的数据,拆分到多个表中,这同时也减少了B+树索引的大小,减少磁盘读取次数,提高性能. 两种基础分表逻辑 说完了为什么要分表,下面聊聊业务开发中常见的两种基础的分表逻辑.

  • 详解go中panic源码解读

    panic源码解读 前言 本文是在go version go1.13.15 darwin/amd64上进行的 panic的作用 panic能够改变程序的控制流,调用panic后会立刻停止执行当前函数的剩余代码,并在当前Goroutine中递归执行调用方的defer: recover可以中止panic造成的程序崩溃.它是一个只能在defer中发挥作用的函数,在其他作用域中调用不会发挥作用: 举个栗子 package main import "fmt" func main() { fmt.

  • 详解SpringBoot自动配置源码

    一.引导加载自动配置类 @SpringBootApplication注解相当于@SpringBootConfiguration.@EnableAutoConfiguration.@ComponentScan这三个注解的整合 @SpringBootConfiguration 这个注解也使用了@Configuration标注,代表当前是一个配置类 @ComponentScan 包扫描,指定扫描哪些注解 @EnableAutoConfiguration 这个注解也是一个合成注解 @AutoConfig

  • Vue.use源码分析

    我想有过vue开发经验的,对于vue.use并不陌生.当使用vue-resource或vue-router等全局组件时,必须通过Vue.use方法引入,才起作用.那么vue.use在组件引入之前到底做了那些事情呢?让我们一窥究竟. 先上vue.use源码 // javascript的方法是可以传递的,哈哈 Vue.use = function (plugin) { /* istanbul ignore if */ if (plugin.installed) { return } // addit

  • 深入理解Vue transition源码分析

    这两天学习了Vue transition感觉这个地方知识点挺多的,而且很重要,所以,今天添加一点小笔记. 本来打算自己造一个transition的轮子,所以决定先看看源码,理清思路.Vue的transition组件提供了一系列钩子函数,并且具有良好可扩展性. 了解构建过程 既然要看源码,就先让Vue在开发环境跑起来,首先从GitHub clone下来整个项目,在文件./github/CONTRIBUTING.md中看到了如下备注,需要强调一下的是,npm run dev构建的是runtime +

随机推荐