本文同步在我的博客shymean.com上,欢迎关注css
虽然写了很长一段时间的Vue了,对于CSS Scoped
的原理也大体了解,但一直不曾关注过其实现细节。最近在从新学习webpack,所以查看了vue-loader
源码,顺便从vue-loader
的源码中整理CSS Scoped
的实现。vue
本文展现了vue-loader
中的一些源码片断,为了便于理解,稍做删减。参考node
在Vue单文件组件中,咱们只须要在style
标签上加上scoped
属性,就能够实现标签内的样式在当前模板输出的HTML标签上生效,其实现原理以下webpack
每一个Vue文件都将对应一个惟一的id,该id能够根据文件路径名和内容hash生成git
编译template
标签时时为每一个标签添加了当前组件的id,如<div class="demo"></div>
会被编译成<div class="demo" data-v-27e4e96e></div>
github
编译style
标签时,会根据当前组件的id经过属性选择器和组合选择器输出样式,如.demo{color: red;}
会被编译成.demo[data-v-27e4e96e]{color: red;}
web
了解了大体原理,能够想到css scoped应该须要同时处理template和style的内容,如今概括须要探寻的问题api
data-v-xxx
属性是如何生成的在此以前,须要了解首一下webpack中Rules.resourceQuery的做用。在配置loader时,大部分时候咱们只须要经过test
匹配文件类型便可markdown
{ test: /\.vue$/, loader: 'vue-loader' } // 当引入vue后缀文件时,将文件内容传输给vue-loader进行处理 import Foo from './source.vue' 复制代码
resourceQuery
提供了根据引入文件路径参数的形式匹配路径app
{ 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进行处理。
参考
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。
接下来看看与vue-loader
配套的插件:VueLoaderPlugin
,该插件的做用是:
将在
webpack.config
定义过的其它规则复制并应用到.vue
文件里相应语言的块中。
其大体工做流程以下所示
webpack
配置的rules
项,而后复制rules
,为携带了?vue&lang=xx...
query参数的文件依赖配置xx
后缀文件一样的loaderpitcher
[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,如
pug
编写template,而后配置pug-plain-loader
scss
或less
编写style,而后配置相关预处理器loader可见在VueLoaderPlugin
主要作的两件事,一个是注册公共的pitcher
,一个是复制webpack的rules
。
接下来咱们看看vue-loader
作的事情。
前面提到在VueLoaderPlugin
中,该loader在pitch中会根据query.type
注入处理对应标签的loader
css-loader
后插入stylePostLoader
,保证stylePostLoader
在execution阶段先执行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的具体做用咱们后面再研究。
接下来看看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。
回到开头提到的第一个问题:当前组件中,渲染出来的每一个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
中,须要作的工做就是将全部选择器都增长一个属性选择器的组合限制,
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
的工做流程
VueLoaderPlugin
query.lang
时经过resourceQuery
匹配相同的rules并执行对应loader时pitch
阶段根据query.type
插入对应的自定义loader*.vue
时会调用vue-loader
,
descriptor
对象,包含template
、script
、styles
等属性对应各个标签,src?vue&query
引用代码,其中src
为单页面组件路径,query为一些特性的参数,比较重要的有lang
、type
和scoped
lang
属性,会匹配与该后缀相同的rules并应用对应的loaderstype
执行对应的自定义loader,template
将执行templateLoader
、style
将执行stylePostLoader
templateLoader
中,会经过vue-template-compiler
将template转换为render函数,在此过程当中,
scopeId
追加到每一个标签的segments
上,最后做为vnode的配置属性传递给createElemenet
方法,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
,整理了其工做原理,感受收获颇丰
Rules.resourceQuery
和pitch loader
的使用css scoped
的实现原理虽然一直在使用webpack和PostCSS,但也仅限于勉强会用的阶段,好比我甚至历来没有过编写一个PostCSS插件的想法。尽管目前大部分项目都使用了封装好的脚手架,但对于这些基础知识,仍是颇有必要去了解其实现的。