详解从vue-loader源码分析CSS Scoped的实现
虽然写了很长一段时间的Vue了,对于CSSScoped的原理也大致了解,但一直未曾关注过其实现细节。最近在重新学习webpack,因此查看了vue-loader源码,顺便从vue-loader的源码中整理CSSScoped的实现。
本文展示了vue-loader中的一些源码片段,为了便于理解,稍作删减。参考
VueCSSSCOPED实现原理
Vueloader官方文档
相关概念
CSSScoped的实现原理
在Vue单文件组件中,我们只需要在style标签上加上scoped属性,就可以实现标签内的样式在当前模板输出的HTML标签上生效,其实现原理如下
- 每个Vue文件都将对应一个唯一的id,该id可以根据文件路径名和内容hash生成
- 编译template标签时时为每个标签添加了当前组件的id,如
了解了大致原理,可以想到cssscoped应该需要同时处理template和style的内容,现在归纳需要探寻的问题
- 渲染的HTML标签上的data-v-xxx属性是如何生成的
- CSS代码中的添加的属性选择器是如何实现的
resourceQuery
在此之前,需要了解首一下webpack中Rules.resourceQuery的作用。在配置loader时,大部分时候我们只需要通过test匹配文件类型即可
{ test:/\.vue$/, loader:'vue-loader' } //当引入vue后缀文件时,将文件内容传输给vue-loader进行处理 importFoofrom'./source.vue'
resourceQuery提供了根据引入文件路径参数的形式匹配路径
{ resourceQuery:/shymean=true/, loader:path.resolve(__dirname,'./test-loader.js') } //当引入文件路径携带query参数匹配时,也将加载该loader import'./test.js?shymean=true' importFoofrom'./source.vue?shymean=true'
vue-loader中就是通过resourceQuery并拼接不同的query参数,将各个标签分配给对应的loader进行处理。
loader.pitch
参考
pitching-loader官方文档
webpack的pitchingloader
webpack中loaders的执行顺序是从右到左执行的,如loaders:[a,b,c],loader的执行顺序是c->b->a,且下一个loader接收到的是上一个loader的返回值,这个过程跟"事件冒泡"很像。
但是在某些场景下,我们可能希望在"捕获"阶段就执行loader的一些方法,因此webpack提供了loader.pitch的接口。
一个文件被多个loader处理的真实执行流程,如下所示
a.pitch->b.pitch->c.pitch->requestmodule->c->b->a
loader和pitch的接口定义大概如下所示
//loader文件导出的真实接口,content是上一个loader或文件的原始内容 module.exports=functionloader(content){ //可以访问到在pitch挂载到data上的数据 console.log(this.data.value)//100 } //remainingRequest表示剩余的请求,precedingRequest表示之前的请求 //data是一个上下文对象,在上面的loader方法中可以通过this.data访问到,因此可以在pitch阶段提前挂载一些数据 module.exports.pitch=functionpitch(remainingRequest,precedingRequest,data){ data.value=100 }}
正常情况下,一个loader在execution阶段会返回经过处理后的文件文本内容。如果在pitch方法中直接返回了内容,则webpack会视为后面的loader已经执行完毕(包括pitch和execution阶段)。
在上面的例子中,如果b.pitch返回了resultb,则不再执行c,则是直接将resultb传给了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 constrawRules=compiler.options.module.rules//原始的rules配置信息 const{rules}=newRuleSet(rawRules) //cloneRule会修改原始rule的resource和resourceQuery配置,携带特殊query的文件路径将被应用对应rule constclonedRules=rules .filter(r=>r!==vueRule) .map(cloneRule) //vue文件公共的loader constpitcher={ loader:require.resolve('./loaders/pitcher'), resourceQuery:query=>{ constparsed=qs.parse(query.slice(1)) returnparsed.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
接下来我们看看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阶段是从后向前执行的 constrequest=genRequest([ ...afterLoaders, stylePostLoaderPath,//执行lib/loaders/stylePostLoader.js ...beforeLoaders ]) return`importmodfrom${request};exportdefaultmod;export*from${request}` } //处理模板 if(query.type===`template`){ constpreLoaders=loaders.filter(isPreLoader) constpostLoaders=loaders.filter(isPostLoader) constrequest=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-FileComponents)对象 //descriptor包含template、script、style等标签的属性和内容,方便为每种标签做对应处理 constdescriptor=parse({ source, compiler:options.compiler||loadTemplateCompiler(loaderContext), filename, sourceRoot, needMap:sourceMap }) //为单文件组件生成唯一哈希id constid=hash( isProduction ?(shortFilePath+'\n'+source) :shortFilePath ) //如果某个style标签包含scoped属性,则需要进行CSSScoped处理,这也是本章节需要研究的地方 consthasScoped=descriptor.styles.some(s=>s.scoped)
处理template标签,拼接type=template等query参数
if(descriptor.template){ constsrc=descriptor.template.src||resourcePath constidQuery=`&id=${id}` //传入文件id和scoped=true,在为组件的每个HTML标签传入组件id时需要这两个参数 constscopedQuery=hasScoped?`&scoped=true`:`` constattrsQuery=attrsToQuery(descriptor.template.attrs) constquery=`?vue&type=template${idQuery}${scopedQuery}${attrsQuery}${inheritQuery}` constrequest=templateRequest=stringifyRequest(src+query) //type=template的文件会传给templateLoader处理 templateImport=`import{render,staticRenderFns}from${request}` //比如,标签 //将被解析成import{render,staticRenderFns}from"./source.vue?vue&type=template&id=27e4e96e&lang=pug&" }
处理script标签
letscriptImport=`varscript={}` if(descriptor.script){ //vue-loader没有对script做过多的处理 //比如vue文件中的标签将被解析成 //importscriptfrom"./source.vue?vue&type=script&lang=js&" //export*from"./source.vue?vue&type=script&lang=js&" }
处理style标签,为每个标签拼接type=style等参数
//在genStylesCode中,会处理cssscoped和cssmoudle stylesCode=genStylesCode( loaderContext, descriptor.styles, id, resourcePath, stringifyRequest, needsHotReload, isServer||isShadow//needsexplicitinjection? ) //由于一个vue文件里面可能存在多个style标签,对于每个标签,将调用genStyleRequest生成对应文件的依赖 functiongenStyleRequest(style,i){ constsrc=style.src||resourcePath constattrsQuery=attrsToQuery(style.attrs,'css') constinheritQuery=`&${loaderContext.resourceQuery.slice(1)}` constidQuery=style.scoped?`&id=${id}`:`` //type=style将传给stylePostLoader进行处理 constquery=`?vue&type=style&index=${i}${idQuery}${attrsQuery}${inheritQuery}` returnstringifyRequest(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 constoptions=loaderUtils.getOptions(loaderContext)||{} constcompiler=options.compiler||require('vue-template-compiler') //可以看见,scopre=true的template的文件会生成一个scopeId constcompilerOptions=Object.assign({ outputSourceRange:true },options.compilerOptions,{ scopeId:query.scoped?`data-v-${id}`:null, comments:query.comments }) //合并compileTemplate最终参数,传入compilerOptions和compiler constfinalOptions={source,filename:this.resourcePath,compiler,compilerOptions} constcompiled=compileTemplate(finalOptions) const{code}=compiled //finishwithESMexports returncode+`\nexport{render,staticRenderFns}` }
关于compileTemplate的实现,我们不用去关心其细节,其内部主要是调用了配置参数compiler的编译方法
functionactuallyCompile(options){ constcompile=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 functionelementToOpenTagSegments(el,state):Array{ applyModelTransform(el,state) letbinding constsegments=[{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:`>`}) returnsegments }
以前面的