问题描述
我们公司生产了一个用 svelte/sapper 编写的自动化框架。一项功能是开发人员可以创建自定义 ui 小部件,目前使用纯 js/html/css 和我们的客户端 api。这些小部件存储在数据库中,而不是文件系统中。
我认为允许他们将小部件创建为 svelte 组件是一个很大的优势,因为它在一个位置包含所有标记、js 和 css,并且会给他们带来 svelte 反应性的所有好处。
我已经创建了一个使用 svelte 的服务器 API 编译组件的端点,但这似乎只是生成了一个准备好用于 rollup-plugin-svelte/sapper/babel 的模块,以完成浏览器可以完成的工作使用。
如何手动将 svelte 组件编译为 sapper/svelte 生成的最终 javascript 和 css。
解决方法
哎呀,艰难的一个。坚持住。
您实际上缺少的是“链接”,即将编译代码中的 import
语句解析为浏览器可以使用的内容。这是通常由打包器完成的工作(例如 Rollup、Webpack...)。
这些导入可以来自用户(小部件开发者)代码。例如:
import { onMount } from 'svelte'
import { readable } from 'svelte/store'
import { fade } from 'svelte/transition'
import Foo from './Foo.svelte'
或者它们可以由编译器注入,具体取决于组件中使用的功能。例如:
// those ones are inescapable (bellow is just an example,you'll
// get different imports depending on what the compiled component
// actually does / uses)
import {
SvelteComponent,detach,element,init,insert,noop,safe_not_equal,} from 'svelte/internal'
Svelte 将 .svelte
编译为 .js
和可选的 .css
,但它不会对代码中的导入执行任何操作。相反,它添加了一些(但仍然没有解决它们,超出了它的范围)。
您需要解析编译后的代码以找到那些来自编译器的原始导入,这些导入可能指向您的文件系统和 node_modules
目录上的路径,并将它们重写为对浏览器——也就是 URLs...
看起来不是很有趣,是吗? (或者太多,取决于您如何看待事物...)幸运的是,您并不孤单,我们有非常强大的工具专门用于此任务:进入打包程序!
解决链接问题
解决这个问题的一个相对简单的方法(还有更多,不要太兴奋)是编译您的小部件,不是使用 Svelte 的编译器 API,而是使用 Rollup 和 Svelte 插件。
Svelte 插件基本上完成了您使用编译器 API 所做的工作,但 Rollup 还将完成重新连接导入和依赖项的所有繁重工作,以生成可供浏览器使用的整洁的小包(包)(即不依赖于您的文件系统)。
您可以使用一些 Rollup 配置来编译一个小部件(此处为 Foo.svelte
):
rollup.config.Foo.js
import svelte from 'rollup-plugin-svelte'
import commonjs from '@rollup/plugin-commonjs'
import resolve from '@rollup/plugin-node-resolve'
import css from 'rollup-plugin-css-only'
import { terser } from 'rollup-plugin-terser'
const production = !process.env.ROLLUP_WATCH
// include CSS in component's JS for ease of use
//
// set to true to get separate CSS for the component (but then,// you'll need to inject it yourself at runtime somehow)
//
const emitCss = false
const cmp = 'Foo'
export default {
// our widget as input
input: `widgets/${cmp}.svelte`,output: {
format: 'es',file: `public/build/widgets/${cmp}.js`,sourcemap: true,},// usual plugins for Svelte... customize as needed
plugins: [
svelte({
emitCss,compilerOptions: {
dev: !production,}),emitCss && css({ output: `${cmp}.css` }),resolve({
browser: true,dedupe: ['svelte'],commonjs(),production && terser(),],}
这里没什么特别的......这基本上是来自 Rollup 官方 Svelte 模板的配置,减去与开发服务器有关的部分。
使用上面的配置和这样的命令:
rollup --config rollup.config.Foo.js
您将在 public/build/Foo.js
中获得浏览器就绪的编译 Foo 小部件!
Rollup 还有一个 JS API,因此您可以根据需要从 Web 服务器或其他任何地方以编程方式运行它。
然后您将能够动态导入,然后在您的应用程序中将此模块与类似内容一起使用:
const widget = 'Foo'
const url = `/build/widgets/${widget}.js`
const { default: WidgetComponent } = await import(url)
const cmp = new WidgetComponent({ target,props })
在您的情况下可能需要动态导入,因为您在构建主应用程序时不知道小部件 - 因此您需要在运行时像上面一样动态构建导入 URL。请注意,导入 URL 是动态字符串这一事实将阻止 Rollup 在捆绑时尝试解析它。这意味着导入将在浏览器中以上述方式结束,并且它必须是浏览器能够解析的URL(不是您机器上的文件路径)。
那是因为我们使用浏览器原生动态导入来使用编译的小部件,我们需要在 Rollup 配置中将 output.format
设置为 es
。 Svelte 组件将使用 export default ...
语法公开,现代浏览器本身就可以理解。
当前浏览器很好地支持动态导入。值得注意的例外是“旧”Edge(在它基本上成为 Chrome 之前)。如果您需要支持旧版浏览器,则可以使用 polyfill(实际上有很多——例如 dimport)。
此配置可以进一步自动化,以便能够编译任何小部件,而不仅仅是 Foo
。例如,像这样:
rollup.config.widget.js
... // same as above essentially
// using Rollup's --configXxx feature to dynamically generate config
export default ({ configWidget: cmp }) => ({
input: `widgets/${cmp}.svelte`,output: {
...
file: `public/build/widgets/${cmp}.js`,...
})
然后你可以像这样使用它:
rollup --config rollup.config.widget.js --configTarget Bar
我们正在取得进展,但仍有一些注意事项和障碍需要注意(并且可能进一步优化 - 您的电话)。
警告:共享依赖
上述方法应该为您的小部件提供编译后的代码,您可以在浏览器中运行这些代码,没有未解析的导入。好的。但是,它通过在构建时解析给定小部件的所有依赖项并将所有这些依赖项捆绑在同一文件中来实现。
否则,多个小部件之间共享的所有依赖项将为每个小部件复制,特别是 Svelte 依赖项(即从 svelte
或 svelte/*
导入)。这并不全是坏事,因为它为您提供了非常独立的小部件......不幸的是,这也给您的小部件代码增加了一些重量。我们正在讨论的可能是 20-30 kb 的 JS 添加到每个可以在所有小部件之间共享的小部件。
此外,我们很快就会看到,在您的应用中拥有独立的 Svelte 内部副本存在一些我们需要考虑的缺点......
提取公共依赖项以便共享而不是重复的一种简单方法是一次性捆绑所有小部件。这可能不适用于所有用户的所有小部件,但也许在个人用户级别可行?
无论如何,这是大体的想法。您可以将上面的 Rollup 配置更改为如下所示:
rollup.config.widget-all.js
...
export default {
input: ['widgets/Foo.svelte','widgets/Bar.svelte',...],dir: 'public/build/widgets',...
}
我们传递了一组文件,而不是一个,作为 input
(您可能会通过列出给定目录中的文件来自动执行此步骤),并且我们正在更改 output.file
到 output.dir
,因为现在我们将同时生成多个文件。这些文件将包含 Rollup 将提取的小部件的通用依赖项,并且您的所有小部件将在它们之间共享以供重复使用。
进一步的观点
通过自己提取一些共享依赖项(例如 Svelte...)并将它们作为 URL 提供给浏览器(即通过您的 Web 服务器为它们提供服务),可以进一步推动。这样,您可以将编译代码中的导入重写为那些已知 URL,而不是依赖 Rollup 来解析它们。
这将完全减少代码重复,减轻重量,而且这将允许在使用它们的所有小部件之间共享这些依赖项的单个版本。这样做还可以减轻同时构建所有共享依赖项的小部件的需要,这很诱人......但是,这将非常(!)设置复杂,并且您实际上会很快遇到收益递减。
实际上,当您将一堆小部件捆绑在一起(甚至只是一个小部件)并让 Rollup 提取依赖项时,捆绑器有可能知道消费代码实际需要依赖项的哪些部分,并且跳过其余部分(请记住:Rollup 构建时将摇树作为其主要优先事项之一——如果不是其中之一——,而 Svelte 是由同一个人构建的——意思是:你可以期待 Svelte 成为 非常 摇树友好!)。另一方面,如果您自己手动提取一些依赖项:它减轻了一次性捆绑所有消耗代码的需要,但是您必须公开整个消耗的依赖项,因为您将无法提前知道需要的部分。
您需要在高效和实用之间找到平衡,考虑到每个解决方案对您的设置增加的复杂性。考虑到您的用例,我自己的感觉是,最佳点是将每个小部件完全独立地捆绑在一起,或者将来自同一用户的一堆小部件捆绑在一起以减轻重量,如上所述。加倍努力可能是一个有趣的技术挑战,但它只会获得很少的额外好处,但复杂性会有些爆炸......
好的,现在我们知道如何为浏览器捆绑我们的小部件。我们甚至可以在一定程度上控制如何完全独立地打包我们的小部件,或者承担一些额外的基础设施复杂性,以共享它们之间的依赖关系并减轻一些重量。现在,当我们决定如何制作漂亮的小包(错误,包)时,我们需要考虑一个特殊的依赖项:这就是 Svelte 本身...
注意陷阱:Svelte 无法复制
所以我们明白,当我们用 Rollup 捆绑单个小部件时,它的所有依赖项都将包含在“捆绑”中(在这种情况下只是一个小部件文件)。如果您以这种方式捆绑 2 个小部件并且它们共享一些依赖项,那么这些依赖项将在每个捆绑包中重复。特别是,您将获得 2 个 Svelte 副本,每个小部件中一个。同样,与某些小部件共享的“主”应用程序的依赖项仍将在浏览器中重复。您将拥有相同代码的多个副本,这些副本将被这些不同的包使用——您的应用、不同的小部件...
但是,您需要了解 Svelte 的一些特别之处:它不支持复制。 svelte/internal
模块是有状态的,它包含一些全局变量,如果您有此代码的多个副本(见上文),这些变量将被复制。这意味着,在实践中,不使用相同 Svelte 内部组件的 Svelte 组件不能一起使用。
例如,如果您有一个独立捆绑的 App.svelte
组件(您的主应用程序)和一个 Foo.svelte
组件(例如用户小部件),则您不能使用 {{1} }} 在 Foo
中,否则您会遇到奇怪的错误。
这行不通:
App
App.svelte
这也是您在官方 Svelte 模板的 Rollup 配置中有此 <script>
// as we've seen,in real life,this would surely be a
// dynamic import but whatever,you get the idea
import Foo from '/build/widgets/Foo.js'
</script>
<!-- NO -->
<Foo />
<!-- NO -->
<svelte:component this={Foo} />
选项的原因...这是为了防止捆绑 Svelte 的不同副本,例如,如果您曾经使用过链接包,就会发生这种情况.
无论如何,在您的情况下,最终在浏览器中出现多个 Svelte 副本是不可避免的,因为您可能不想在用户添加或更改其小部件之一的任何时候重建整个主应用程序。 . 除了不遗余力地自己提取、集中和重写 Svelte 导入;但是,正如我所说,我认为这不是一种合理且可持续的方法。
所以我们被困住了。
还是我们?
重复 Svelte 副本的问题仅在冲突组件是同一组件树的一部分时发生。也就是说,当您让 Svelte 像上面一样创建和管理组件实例时。当您自己创建和管理组件实例时,问题不存在。
dedupe: ['svelte']
这里的 ...
const foo = new Foo({ target: document.querySelector('#foo') })
const bar = new Bar({ target: document.querySelector('#bar') })
和 foo
将是完全独立的组件树,就 Svelte 而言。这样的代码将始终有效,与编译和捆绑 bar
和 Foo
的方式和时间(以及使用哪个 Svelte 版本等)无关。
据我了解您的用例,这不是主要障碍。您将无法使用 Bar
之类的东西将用户的小部件嵌入到您的主应用程序中……但是,没有什么可以阻止您自己在正确的位置创建和管理小部件实例。您可以创建一个包装器组件(在您的主应用程序中)来推广这种方法。像这样:
<svelte:component />
Widget.svelte
我们从我们的主应用程序创建一个目标 DOM 元素,在其中渲染一个“外部”组件,传递所有道具(我们代理反应性),并且不要忘记在我们的代理组件被销毁时进行清理。
这种方法的主要限制是应用的 Svelte 上下文 (<script>
import { onDestroy } from 'svelte'
let component
export { component as this }
let target
let cmp
const create = () => {
cmp = new component({
target,props: $$restProps,})
}
const cleanup = () => {
if (!cmp) return
cmp.$destroy()
cmp = null
}
$: if (component && target) {
cleanup()
create()
}
$: if (cmp) {
cmp.$set($$restProps)
}
onDestroy(cleanup)
</script>
<div bind:this={target} />
/ setContext
) 对代理组件不可见。
再一次,这在小部件用例中似乎不是问题——也许更好:我们真的希望小部件能够访问周围应用程序的每一部分吗?如果确实需要,您始终可以通过 props 将一些上下文传递给小部件组件。
上面的 getContext
代理组件将在您的主应用程序中像这样使用:
Widget
而且……我们到了?总结一下吧!
总结
-
使用 Rollup 编译您的小部件,而不是直接使用 Svelte 编译器,以生成浏览器就绪包。
-
在简单、重复和额外重量之间找到适当的平衡。
-
使用动态导入在浏览器中使用您的小部件,这些小部件将独立于您的主应用而构建。
-
不要尝试将不使用相同 Svelte 副本的组件混合在一起(本质上意味着捆绑在一起,除非您已经开始了一些非凡的黑客攻击)。乍一看似乎有效,但实际上并没有。
多亏了@rixo 的详细帖子,我才能完成这项工作。我基本上像这样创建了一个 rollup.widget.js:
import json from '@rollup/plugin-json';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import svelte from 'rollup-plugin-svelte';
import path from "path";
import fs from "fs";
let basePath = path.join(__dirname,'../widgets');
let srcFiles = fs.readdirSync(basePath).filter(f=>path.extname(f) === '.svelte').map(m=> path.join(basePath,m ));
export default {
input: srcFiles,output: {
format: 'es',dir: basePath,plugins: [
json(),svelte({
emitCss: false,compilerOptions: {
dev: false,resolve({
browser: true,dedupe: ['svelte']
}),commonjs()
]
}
然后从数据库生成svelte组件并编译:
const loadConfigFile = require('rollup/dist/loadConfigFile');
function compile(widgets){
return new Promise(function(resolve,reject){
let basePath = path.join(__dirname,'../widgets');
if (!fs.existsSync(basePath)){
fs.mkdirSync(basePath);
}
for (let w of widgets){
if (w.config.source){
let srcFile = path.join(basePath,w.name + '.svelte');
fs.writeFileSync(srcFile,w.config.source);
console.log('writing widget source file:',srcFile)
}
}
//ripped off directly from the rollup docs
loadConfigFile(path.resolve(__dirname,'rollup.widgets.js'),{ format: 'es' }).then(
async ({ options,warnings }) => {
console.log(`widget warning count: ${warnings.count}`);
warnings.flush();
for (const optionsObj of options) {
const bundle = await rollup(optionsObj);
await Promise.all(optionsObj.output.map(bundle.write));
}
resolve({success: true});
}
).catch(function(x){
reject(x);
})
})
}
然后按照@rixo 的建议使用动态小部件:
<script>
import {onMount,onDestroy,tick} from 'svelte';
import Widget from "../containers/Widget.svelte";
export let title = '';
export let name = '';
export let config = {};
let component;
let target;
$: if (name){
loadComponent().then(f=>{}).catch(x=> console.warn(x.message));
}
onMount(async function () {
console.log('svelte widget mounted');
})
onDestroy(cleanup);
async function cleanup(){
if (component){
console.log('cleaning up svelte widget');
component.$destroy();
component = null;
await tick();
}
}
async function loadComponent(){
await cleanup();
let url = `/widgets/${name}.js?${parseInt(Math.random() * 1000000)}`
let comp = await import(url);
component = new comp.default({
target: target,props: config.props || {}
})
console.log('loading svelte widget component:',url);
}
</script>
<Widget name={name} title={title} {...config}>
<div bind:this={target} class="svelte-widget-wrapper"></div>
</Widget>
一些注意事项/观察:
- 与尝试直接使用 rollup.rollup 相比,我使用 rollup/dist/loadConfigFile 的运气要好得多。
- 我尝试为所有 svelte 模块创建客户端和服务器全局变量,并在小部件汇总中将它们标记为外部,以便所有内容都使用相同的 svelte 内部结构。这最终弄得一团糟,让小部件可以访问我想要的更多东西。
- 如果您尝试使用
- @rixo 永远是对的。我事先被警告过这些事情,结果完全符合预期。