搭建场景下的页面编译提速方案探索
前言
可视化搭建平台已经比较普及了,具体原理本文不再赘述,这里和大家聊一聊搭建平台重要的一步:c端页面的生成流程。实际上主流的方式有以下两种:
动态化方案:c端维护一个统一的入口页,动态拉取所需配置项以及组件代码。这种方式的优点是发布迅速,可批量更新、修复代码,但相应的需要维护一套后端服务,用以处理配置项的存储解析,组件的版本管理,页面组件的关系映射等。 静态固化方案:每次生成页面时,根据特定活动的配置数据生成一个包含组件以及配置信息的入口文件,然后走一遍完整的前端编译流程。其优点是无需后端服务,不用再担心大促活动时的高并发影响,且页面与页面之间完全隔离、更可控,同时在c端页面的体验上要优于动态化方案。
以下就静态固化方案介绍下我们在编译速度上所做的优化。
基于webpack组件源码编译方案
搭建类工具的使用方法相对简单,通常需要运营同学在系统中新建一个活动,添加所需要的组件并进行相应的配置,然后发布活动即可。我们的页面编译发布服务可大致划分为以下几个阶段:
import componentA from './components/componentA'
import componentB from './components/componentB'
import componentConfig from './componentConfig'
const componentMap = { componentA, componentB }
new Vue({
render(h) {
return h('div',
componentConfig.map(
conf => h(componentMap[conf.componentName], { props: conf.config }
)
)
}
}).$mount('#root')
这套流程中,发布一个页面通常需要30s~60s左右,根据配置页面的复杂程度可能有所浮动。伴随着搭建平台在公司运营活动中的占比越来越高,尤其是在大促中的广泛使用,发布耗时的缩短能极大提高运营同学的使用体验。事实上,整个编译流程的耗时主要集中在webpack编译
步骤中,其余三个步骤的耗时可忽略不计。编译耗时久的原因可分为两点:
采用组件源码编译,即入口文件中引入的组件指向 src 目录,没有经过任何处理,这也意味着每次发布页面都需要对组件走一遍 webpack loader,比较耗时。 webpack 自身较慢,众所周知 webpack 是一款强大的打包工具,通过 loader 和 插件系统几乎能满足任何定制化需求,但是功能强大也使其架构相对复杂,再加上其内置的压缩工具 terser 也比较慢,以及 js 动态语言的局限性等,这种种原因导致了 webpack 不可能很快。
事实上如果公司搭建的页面量不大,搭建服务调用处于较低量级时,是可以选择这种方案的,优势也很明显:组件开发完毕发布即可,构建服务无需对组件做额外处理。
基于webpack组件预打包方案
上文提到打包慢很大的一部分原因是由于采用了组件源码编译,我们很自然就会想到可以将组件提前打包好,引用组件时指向打包好的代码即可。
// webpack.config.js
externals: {
vue: 'commonjs vue',
xxx: 'commonjs xxx',
...
}
但其实我们很难枚举出所有可能出现的外部依赖,并且组件源码经 babel 转译后也会引入很多的诸如 core-js 之类的潜在依赖,理想情况是组件的打包产物只包含组件自身业务代码,所有组件依赖等到页面打包时再统一引入,这样就可以避免依赖的重复打包。我们可以利用 externals 自定义函数将所有的外部依赖排除掉
// webpack.config.js
externals: function(context, request, callback) {
if (isExternalsPath(request)) {
return callback(null, `commonjs ${request}`)
}
callback()
}
isExternalsPath
怎么实现呢?简单粗暴的做法是禁止组件内使用 alias,所有非相对路径导入的模块均视为外部依赖。
组件预打包带来的另一个问题是模块的路径信息被抹除了,所有的模块均由 dist 文件导出。但对于搭建场景来言这些信息其实是很有用的,想象有如下一个组件,提供两种样式,分别为 style-a、style-b
|- src
- style-a.vue
- style-b.vue
当选择 style-a 时,style-b.vue 事实上可以被认为是 dead code,我们可以使用 NormalModuleReplacementPlugin
将模块 style-b.vue 替换掉。由于搭建页面为了满足多样化的页面效果,组件通常会提供多种样式,利用上面的方法可以很好的将打包产物瘦身,但采用组件预打包后,类似于此种的优化就很难做到了。
如果说上面的问题我们可以接受,真正导致我们放弃此方案的原因还是打包速度达不到我们的期望(2s内),虽然我们暂时处理了组件的重复编译问题,但 webpack 自身的局限性很难解决。
基于esbuild组件预编译方案
esbuild 作为新一代的构建工具,构建速度比传统的构建工具可以快到 10 ~ 100 倍
因此将编译链路迁移至 esbuild 生态理论上可以使编译速度得到质的飞跃。我们知道对于前端项目来讲,一个完备的构建工具需要能够支持 transform、bundle、minify 这几个基本功能,但对于 esbuild 而言,bundle、minify 是其比较擅长做的事情,包括 vite、umi 等已经在用 esbuild 做一些打包和压缩相关的事情。虽然社区中已经存在 vue、scss 等 esbuild 相关插件,但是其作为 transformer 的能力还没有足够丰富、成熟,同时如上文所讲,在页面编译时做组件代码的 transform 其实是一件重复工作,因此需要我们对组件进行预编译,编译后的组件代码需要满足:
vue 单文件组件拆分为 js 文件和 css 文件 js、ts、tsx 文件转为 es5 版本 js 文件 scss 文件转为 css 文件 保留模块路径信息,即按照原有文件结构输出代码
现在的编译流程:
组件预编译核心流程如下
const compile = (filePath, compiledPaths = []) => {
// 防止循环引用
if (compiledPaths.includes(filePath)) return
compiledPaths.push(filePath)
// js文件
if (isScript(filePath)) {
const deps = analysisImport(filePath)
// 编译引入的文件
deps.forEach(depFilePath => {
compile(depFilePath, compiledPaths)
})
const { code } = babel.transformFileSync(filePath, babelConfig)
outputFileSync(outputFilePath, code)
// 单文件组件
} else if (isSfc(filePath)) {
const source = fs.readFileSync(filePath, 'utf-8')
// 单文件组件拆分
const { templage, scripts, styles } = parse({
source,
compiler,
filename: filePath
})
const runtimeCode = genRuntimeCode({ templage, scripts, styles })
outputFileSync(outputFilePath, runtimeCode)
// css文件
} else if (isStyle(filePath)) {
let content = readFileSync(filePath, 'utf-8')
// scss -> css
content = preprocess(content)
// postcss
content = postprocess(content)
outputFileSync(outputFilePath, content)
// 其余文件类型 直接拷贝
} else {
copyFileSync(filePath, outputFilePath)
}
}
经过以上步骤后,esbuild 只需要做打包和压缩代码的工作即可。
另外在一般在前端项目中,我们会将 node_modules 中的依赖打成一个 common chunk 做长效缓存,但这种做法对搭建页面不是很合适,因为单个页面的生命周期很短暂,我们更希望的是将所有页面共用的依赖抽离成一个 chunk 做缓存。esbuild 没有提供类似于 webpack 的 splitChunks 功能,我们通过将公共依赖暴露为全局变量,然后打包成一个单独的文件实现类似的缓存能力,相应的组件代码中引入依赖时需要我们重定向至全局变量,这一步可以通过自定义 plugin 实现
// 公共依赖模块
const commonChunks = ['vue', 'xxx']
await esbuild.build({
...
plugins: [
{
name: 'global',
setup(build) {
// common chunks缓存
build.onResolve({ filter: new RegExp(`^(${commonChunks.join('|')})$`) }, args => {
return {
path: args.path,
namespace: 'global-ns'
}
})
commonChunks.forEach(path => {
build.onLoad({ filter: new RegExp(`^${path}$`), namespace: 'global-ns' }, () => {
return {
contents: `var mod = window['${path}']; export default mod;`
}
})
})
}
}
]
})
构建完成后,需要我们将构建产物路径写入至 html 文件,然后推向 cdn 即可。
最后
以上是我们在搭建系统中针对构建速度优化所做的一些尝试,优化后的页面发布速度可以控制在 2s 内,也达到了我们的预期。最后感谢您的阅读,欢迎评论交流。