详解从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 相关文章推荐
不错的新闻标题颜色效果
Dec 10 Javascript
IE与Firefox下javascript getyear年份的兼容性写法
Dec 20 Javascript
js活用事件触发对象动作
Aug 10 Javascript
JavaScript高级程序设计 事件学习笔记
Sep 10 Javascript
JavaScript判断密码强度(自写代码)
Sep 06 Javascript
利用js读取动态网站从服务器端返回的数据
Feb 10 Javascript
javascript中Date()函数在各浏览器中的显示效果
Jun 18 Javascript
js实现上传图片及时预览
May 07 Javascript
AngularJS基于ngInfiniteScroll实现下拉滚动加载的方法
Dec 14 Javascript
详解Webpack实战之构建 Electron 应用
Dec 25 Javascript
详解JavaScript的变量
Apr 04 Javascript
JS前端知识点 运算符优先级,URL编码与解码,String,Math,arguments操作整理总结
Jun 27 Javascript
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 session文件独占锁引起阻塞问题解决方法
2015/05/12 PHP
各种快递查询--Api接口
2016/04/26 PHP
PHP开发之用微信远程遥控服务器
2018/01/25 PHP
PHP创建文件及写入数据(覆盖写入,追加写入)的方法详解
2019/02/15 PHP
php面试实现反射注入的详细方法
2019/09/30 PHP
json跟xml的对比分析
2008/06/10 Javascript
用原生JS获取CLASS对象(很简单实用)
2014/10/15 Javascript
同一个网页中实现多个JavaScript特效的方法
2015/02/02 Javascript
jquery实现鼠标经过显示下划线的渐变下拉菜单效果代码
2015/08/24 Javascript
使用JQuery FancyBox插件实现图片展示特效
2015/11/16 Javascript
JavaScript组合模式学习要点
2016/08/26 Javascript
关于javascript事件响应的基础语法总结(必看篇)
2016/12/26 Javascript
微信小程序 下拉菜单简单实例
2017/04/13 Javascript
20行js代码实现的贪吃蛇小游戏
2017/06/20 Javascript
AngularJs每天学习之总体介绍
2017/08/07 Javascript
jQuery发请求传输中文参数乱码问题的解决方案
2018/05/22 jQuery
浅谈Python中列表生成式和生成器的区别
2015/08/03 Python
Django+Ajax+jQuery实现网页动态更新的实例
2018/05/28 Python
Python实现的大数据分析操作系统日志功能示例
2019/02/11 Python
详解Python 定时框架 Apscheduler原理及安装过程
2019/06/14 Python
python实现从wind导入数据
2019/12/03 Python
python logging.basicConfig不生效的原因及解决
2020/02/20 Python
在PyTorch中使用标签平滑正则化的问题
2020/04/03 Python
Python Selenium截图功能实现代码
2020/04/26 Python
中国网上药店领导者:1药网
2017/02/16 全球购物
现代家居用品及礼品:LBC Modern
2018/06/24 全球购物
Jack Rogers官网:美国经典的女性鞋靴品牌
2019/09/04 全球购物
客服专员岗位职责范本
2013/11/29 职场文书
《与朱元思书》的教学反思
2014/04/17 职场文书
日语专业毕业生自荐书
2014/06/18 职场文书
家长意见书
2015/06/04 职场文书
禁毒心得体会范文
2016/01/15 职场文书
小学体育课教学反思
2016/02/16 职场文书
python制作图形界面的2048游戏, 基于tkinter
2021/04/06 Python
python实现监听键盘
2021/04/26 Python
5个实用的JavaScript新特性
2022/06/16 Javascript