我该如何手动编译Svelte组件为最终的JavaScript和CSS,以达到Sapper/Svelte生成的效果?

31

我们公司生产一种使用svelte/sapper编写的自动化框架。其中一个功能是,开发者可以创建自定义UI小部件,目前是使用普通的js/html/css和我们的客户端API。这些小部件存储在数据库中,而不是文件系统中。

我认为,允许他们以svelte组件的形式创建小部件将是一个重要的优点,因为它将所有标记、js和css放在一个位置,使他们享受到svelte响应式所带来的所有好处。

我已经创建了一个端点,使用svelte的服务器API编译组件,但似乎只生成了一个模块,需要使用rollup-plugin-svelte/sapper/babel完成生成浏览器可以使用的东西。

如何手动将svelte组件编译为最终的javascript和css,与sapper/svelte生成的代码相同?


4
你可以在这里使用REPL,在右侧点击"JS output"或"CSS output"标签。 https://svelte.dev/repl/hello-world?version=3 - Romain Durand
2个回答

80

哎呀,这有点难。请稍等。

你实际上缺少的是 "链接",也就是将编译后的代码中的 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目录中的路径,并将它们重写为对浏览器有意义的内容即URL...

看起来并不是很有趣,对吧?(或太多,这取决于您如何看待事物...)幸运的是,您在这个需求上并不孤单,我们拥有非常强大的工具专门用于此任务:打包程序!

解决链接问题

解决这个问题的一个相对简单的方法(更多方法即将到来,请不要过早激动)是使用Rollup和Svelte插件编译小部件,而不是使用Svelte的编译器API。

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(),
  ],
}

这里没有什么特别的……这基本上是官方Svelte模板的Rollup配置,减去了与开发服务器相关的部分。

使用上述配置的命令如下:

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.formates。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 依赖项(即从sveltesvelte/*导入的内容)。这不完全是坏事,因为它为您提供了非常独立的小部件……不幸的是,这也增加了您的小部件代码的一些负担。我们在讨论每个小部件中可能会添加大约20-30 kb的 JavaScript。

此外,正如我们很快将看到的那样,在应用程序中具有 Svelte 内部的独立副本也有一些缺点,我们需要考虑到这些问题……

一种简单的方法是提取共同依赖项,以便它们可以共享而不是重复。这对于所有用户的所有小部件来说可能并不可行,但也许在个别用户级别上可以实现?

无论如何,这里有一个一般想法。您将更改上面的 Rollup 配置,变成像这样:

rollup.config.widget-all.js

...

export default {
  input: ['widgets/Foo.svelte', 'widgets/Bar.svelte', ...],
  output: {
    format: 'es',
    dir: 'public/build/widgets',
  },
  ...
}

我们传递了一个文件数组,而不是只传递一个文件作为输入(你可能会通过列出给定目录中的文件来自动完成此步骤),并且我们将 output.file 更改为 output.dir,因为现在我们将一次生成多个文件。这些文件将包括 Rollup 提取的小部件的共同依赖项,并且所有小部件都将共享它们以进行重用。

进一步的展望

甚至可以进一步推动,手动提取一些共享依赖项(例如 Svelte...)并将其作为 URL 提供给浏览器(即使用 Web 服务器提供它们)。这样,您可以将编译后的代码中的这些导入重写为已知的 URL,而不是依靠 Rollup 解决它们。

这将完全减少代码重复,节省空间,也将允许所有使用它们的小部件共享这些依赖项的单个版本。这样做还将解除构建共享依赖项的所有小部件的需求,这很有吸引力...但是,这将非常(!)复杂设置,并且您会很快遇到收益递减的情况。

实际上,当您将一堆或甚至只有一个小部件捆绑在一起并让 Rollup 提取依赖项时,捆绑器可以知道消费代码实际上需要哪些依赖项的部分,并跳过其余部分(记住:Rollup 的主要优先事项之一是树摇优化,而 Svelte 是由同一位开发者构建的,这意味着您可以期望 Svelte 很好地支持树摇!)。另一方面,如果您手动提取某些依赖项:它会减轻一次捆绑所有消费代码的需求,但您将不得不公开整个已使用依赖项,因为您无法预先知道其中将需要的部分。

这是您需要在效率和实际情况之间找到的平衡点,考虑到每种解决方案对您设置的增加的复杂性。鉴于您的用例,我自己的感觉是甜蜜点可能是完全独立地捆绑每个小部件,或者将来自同一用户的一堆小部件捆绑在一起以节省一些空间,如上所述。再进一步可能会是一个有趣的技术挑战,但它只会带来一点额外的收益,却是爆炸性的复杂...

好的,现在我们知道如何为浏览器捆绑我们的小部件了。我们甚至可以对如何打包我们的小部件完全独立或者承担一些额外的基础设施复杂度来共享它们之间的依赖项有某种程度的控制。现在,我们需要考虑一个特殊的依赖关系,那就是 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 模板的 Rollup 配置中有 dedupe: ['svelte'] 选项的原因…… 这旨在防止捆绑不同版本的 Svelte,例如如果您曾经使用过链接包。

无论如何,在您的情况下,很难避免在浏览器中出现多个 Svelte 的副本,因为您可能不想在用户添加或更改其小部件时重新构建整个主应用程序……除非您花费很大力气来提取、集中和重写 Svelte 导入;但是,正如我所说,我不相信这将是一个合理和可持续的方法。

所以我们陷入了困境。

或者说我们吗?

重复的 Svelte 副本只会在冲突组件属于同一组件树时出现问题。也就是说,当你让 Svelte 创建和管理组件实例时,像上面那样。当您自己创建和管理组件实例时,问题不存在。

...

const foo = new Foo({ target: document.querySelector('#foo') })

const bar = new Bar({ target: document.querySelector('#bar') })

在Svelte中,foobar将被视为完全独立的组件树。无论何时以及如何编译和捆绑这些组件(以及使用哪个Svelte版本等),这样的代码始终起作用。

据我所知,这对您的使用情况并不是一个主要障碍。您将无法像<svelte:component />那样将用户小部件嵌入到您的主应用程序中...但是,您可以在适当的位置自己创建和管理小部件实例。您可以创建一个包装器组件(在您的主应用程序中),以通用化此方法。类似于:

Widget.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} />

我们从主应用程序创建一个目标DOM元素,在其中渲染“外部”组件,将所有属性传递下去(我们代理响应),并在代理组件销毁时不要忘记清除。

这种方法的主要限制是,应用程序的Svelte上下文(setContext / getContext)对于代理组件不可见。

再次强调,这在小部件使用情况下似乎并不是问题 - 甚至更好:我们是否真的希望小部件可以访问周围应用程序的每个细节? 如果确实需要,您可以通过props向小部件组件传递上下文位。

上述Widget代理组件在您的主应用程序中将像此示例中使用:

<script>
  import Widget from './Widget.svelte'

  const widgetName = 'Foo'

  let widget

  import(`/build/widgets/${widgetName}.js`)
    .then(module => {
      widget = module.default
    })
    .catch(err => {
      console.error(`Failed to load ${widgetName}`, err)
    })
</script>

{#if widget}
  <Widget this={widget} prop="Foo" otherProp="Bar" />
{/if}

那么...我们来总结一下吧!

总结

  • 使用Rollup编译你的小部件,而不是直接使用Svelte编译器,以生成浏览器可用的包。

  • 在简洁性、重复性和额外负荷之间找到正确的平衡点。

  • 使用动态导入来消耗你的小部件,在浏览器中独立构建。

  • 不要试图混合使用不使用相同Svelte副本的组件(基本上意味着捆绑在一起,除非你进行了某些非凡的黑客)。虽然一开始看起来可能有效,但实际上它不会有效。


4
我还在消化这一切,但是让我说声谢谢你抽出时间回答得如此详尽。 - mr.freeze
2
好的,这是我在S.O上看过的最棒的答案。获得了1000个赞。再次感谢。 - mr.freeze
2
@rixo,很抱歉,StackOverflow不是写一本关于这个主题的精彩书籍的地方,你这个传奇人物。 (说真的,谢谢你,这是非常有价值的资源。) :) - Aral Balkan
3
@rixo,哇!这篇文章写得太棒了!我使用Rollup的external选项成功地移除了sveltesvelte/internals运行时。具体来说,是这样设置的:external: ['svelte', 'svelte/internal']。现在我可以在浏览器或父级打包程序中进行后期绑定。再次感谢您的文章! - BruceJo
正是我所需要的。谢谢!@rixo:有没有办法在代理/包装组件上监听分派事件?典型的“on”指令与子组件的“dispatch”操作结合起来并不起作用。 - MarkL
非常棒的回答,非常有帮助。谢谢! - Ruur

5

感谢 @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,
        sourcemap: true,
    },
    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>

几个注意事项/观察:

  1. 我使用rollup/dist/loadConfigFile比直接使用rollup.rollup的效果要好得多。
  2. 我试图在widget rollup中为所有svelte模块创建客户端和服务器全局变量,并将它们标记为外部,以便所有内容都使用相同的svelte内部机制。但实际上这弄得一团糟,而且让小部件能够访问超出我的预期范围。
  3. 如果你尝试使用<svelte:component将动态编译的widget嵌入到主应用程序中,它会有些作用,但是如果你试图从另一个组件引用动态widget,它就会出现可怕的outros.c未定义错误。一旦出现了这种情况,现实就崩溃了,应用程序会进入奇怪的状态。
  4. @rixo总是正确的。他事先警告了我这些问题的每一个,并且结果完全符合预期。

1
也想知道是否可以使用 esbuild 实现这一点。本周会尝试并报告结果,看能否让它起作用。 - Aral Balkan
有趣。我肯定很好奇esbuild的方法会怎样。 - mr.freeze

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接