详解从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实现层随着内容大小动态渐变改变 推荐
Dec 19 Javascript
jquery 关键字“拖曳搜索”之“拖曳”以及 图片“提示自适应放大”效果 的实现
Apr 18 Javascript
JavaScript SetInterval与setTimeout使用方法详解
Nov 15 Javascript
AngularJS入门教程之Hello World!
Dec 06 Javascript
jQuery中offset()方法用法实例
Jan 16 Javascript
拥Bootstrap入怀——导航栏篇
May 30 Javascript
Angular和百度地图的结合实例代码
Oct 19 Javascript
浅谈js数组和splice的用法
Dec 04 Javascript
从vue基础开始创建一个简单的增删改查的实例代码(推荐)
Feb 11 Javascript
Angular6封装http请求的步骤详解
Aug 13 Javascript
JavaScript提升机制Hoisting详解
Oct 23 Javascript
TypeScript 内置高级类型编程示例
Sep 23 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 Curl多线程原理实例详解
2013/11/06 PHP
显示youtube视频缩略图和Vimeo视频缩略图代码分享
2014/02/13 PHP
浅析PHP中json_encode与json_decode的区别
2020/07/15 PHP
javascript使用activex控件的代码
2011/01/27 Javascript
6款新颖的jQuery和CSS3进度条插件推荐
2013/03/05 Javascript
js使用正则实现ReplaceAll全部替换的方法
2014/08/22 Javascript
jquery获取css的color值返回RGB的方法
2015/12/18 Javascript
老生常谈遮罩层 滚动条的问题
2016/04/29 Javascript
AngularJS 实现弹性盒子布局的方法
2016/08/30 Javascript
jQuery EasyUI tree 使用拖拽时遇到的错误小结
2016/10/10 Javascript
使用JS中的Replace()方法遇到的问题小结
2017/10/20 Javascript
基于滚动条位置判断的简单实例
2017/12/14 Javascript
vue.js input框之间赋值方法
2018/08/24 Javascript
JavaScript实现与使用发布/订阅模式详解
2019/01/19 Javascript
React+Redux实现简单的待办事项列表ToDoList
2019/09/29 Javascript
vue-cli和v-charts实现可视化图表过程解析
2019/10/08 Javascript
VUE实现密码验证与提示功能
2019/10/18 Javascript
angular inputNumber指令输入框只能输入数字的实现
2019/12/03 Javascript
webpack+express实现文件精确缓存的示例代码
2020/06/11 Javascript
Taro小程序自定义顶部导航栏功能的实现
2020/12/17 Javascript
Python3爬虫学习入门教程
2018/12/11 Python
Python+OpenCV图片局部区域像素值处理改进版详解
2019/01/23 Python
win10系统Anaconda和Pycharm的Tensorflow2.0之CPU和GPU版本安装教程
2019/12/03 Python
基于Python检测动态物体颜色过程解析
2019/12/04 Python
基于Python和PyYAML读取yaml配置文件数据
2020/01/13 Python
python ImageDraw类实现几何图形的绘制与文字的绘制
2020/02/26 Python
Django 删除upload_to文件的步骤
2020/03/30 Python
python爬虫调度器用法及实例代码
2020/11/30 Python
Python实现一个论文下载器的过程
2021/01/18 Python
Canvas实现贝赛尔曲线轨迹动画的示例代码
2019/04/25 HTML / CSS
香港最新科技与优质家居产品购物网站:J SELECT
2018/08/21 全球购物
物理教学随笔感言
2014/02/22 职场文书
汽车维修求职信
2014/06/15 职场文书
商场租赁意向书
2014/07/30 职场文书
公司租房协议书
2014/10/14 职场文书
2014年食堂工作总结
2014/11/20 职场文书