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


Posted in Javascript onSeptember 23, 2019

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

参考

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插件的想法。尽管目前大部分项目都使用了封装好的脚手架,但对于这些基础知识,还是很有必要去了解其实现的。

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

Javascript 相关文章推荐
js下利用控制器载入对应脚本
Jul 17 Javascript
深入理解JavaScript系列(13) This? Yes,this!
Jan 18 Javascript
jQuery.each()用法分享
Jul 31 Javascript
javascript操作css属性
Dec 30 Javascript
angular.bind使用心得
Oct 26 Javascript
jquery设置表单元素为不可用的简单代码
Jul 04 Javascript
js实现音频控制进度条功能
Apr 01 Javascript
VsCode插件整理(小结)
Sep 14 Javascript
浅谈vue自定义全局组件并通过全局方法 Vue.use() 使用该组件
Dec 07 Javascript
JS 实现缓存算法的示例(FIFO/LRU)
Mar 20 Javascript
在HTML文档中嵌入JavaScript的四种方法
May 07 Javascript
jquery操作checkbox的常用方法总结【附测试源码下载】
Jun 10 jQuery
layer ui 导入文件之前传入数据的实例
Sep 23 #Javascript
Node.js实现简单管理系统
Sep 23 #Javascript
webpack的pitching loader详解
Sep 23 #Javascript
layui 富文本图片上传接口与普通按钮 文件上传接口的例子
Sep 23 #Javascript
深入探索VueJS Scoped CSS 实现原理
Sep 23 #Javascript
小程序实现锚点滑动效果
Sep 23 #Javascript
React Native 混合开发多入口加载方式详解
Sep 23 #Javascript
You might like
php 冒泡排序 交换排序法
2011/05/10 PHP
使用php验证复选框有效性的示例
2013/11/13 PHP
php获取一定范围内取N个不重复的随机数
2016/05/28 PHP
Thinkphp 中 distinct 的用法解析
2016/12/14 PHP
php获取微信openid方法总结
2019/10/10 PHP
Javascript 去除数组的重复元素
2010/05/04 Javascript
js遍历td tr等html元素
2012/12/13 Javascript
jQueryMobile之Helloworld与页面切换的方法
2015/02/04 Javascript
对Web开发中前端框架与前端类库的一些思考
2015/03/27 Javascript
封装好的javascript前端分页插件pagination
2016/01/04 Javascript
js实现的彩色方块飞舞奇幻效果
2016/01/27 Javascript
JavaScript 轮播图和自定义滚动条配合鼠标滚轮分享代码贴
2016/10/28 Javascript
判断横屏竖屏(三种)
2017/02/13 Javascript
js鼠标移动时禁止选中文字
2017/02/19 Javascript
jquery.validate表单验证插件使用详解
2017/06/21 jQuery
基于vue如何发布一个npm包的方法步骤
2019/05/15 Javascript
详解vue中使用axios对同一个接口连续请求导致返回数据混乱的问题
2019/11/06 Javascript
[53:52]OG vs EG 2018国际邀请赛淘汰赛BO3 第二场 8.23
2018/08/24 DOTA
Python3.0与2.X版本的区别实例分析
2014/08/25 Python
python 画二维、三维点之间的线段实现方法
2019/07/07 Python
在django中,关于session的通用设置方法
2019/08/06 Python
python多线程同步实例教程
2019/08/11 Python
python计算波峰波谷值的方法(极值点)
2020/02/18 Python
意大利领先的线上奢侈品销售电商:Eleonora Bonucci
2017/10/17 全球购物
DJI美国:消费类无人机领域的领导者
2018/04/27 全球购物
德国家具折扣店:POCO
2020/02/28 全球购物
String是最基本的数据类型吗?
2013/06/13 面试题
高一学生期末评语
2014/04/25 职场文书
英文求职信范文
2014/05/23 职场文书
支部鉴定材料
2014/06/02 职场文书
2014年食堂工作总结
2014/11/20 职场文书
检讨书范文500字
2015/01/28 职场文书
培训通知
2015/04/17 职场文书
python识别围棋定位棋盘位置
2021/07/26 Python
详解Alibaba Java诊断工具Arthas查看Dubbo动态代理类
2022/04/08 Java/Android
Redis Lua脚本实现ip限流示例
2022/07/15 Redis