我如何手动将一个 svelte 组件编译成 sapper/svelte 生成的最终 javascript 和 css?

How can I manually compile a svelte component down to the final javascript and css that sapper/svelte produces?

我们公司生产一个用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,因此您可以 运行 根据需要从网络服务器或其他任何地方以编程方式进行此操作。

然后您将能够动态导入此模块,然后在您的应用中使用类似以下内容的模块:

const widget = 'Foo'
const url = `/build/widgets/${widget}.js`

const { default: WidgetComponent } = await import(url)

const cmp = new WidgetComponent({ target, props })

在你的情况下可能需要动态导入,因为你在构建主应用程序时不知道小部件 - 因此你需要动态构建导入 URLs以上 运行 时间。请注意,import 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 依赖项(即从 sveltesvelte/* 导入)。这并不全是坏事,因为它为您提供了非常独立的小部件……不幸的是,这也增加了您的小部件代码的重量。我们说的是可能在每个小部件中添加 20-30 kb 的 JS,这些小部件可以在所有小部件之间共享。

此外,我们很快就会看到,在您的应用程序中拥有 Svelte 内部组件的独立副本有一些我们需要考虑的缺点...

提取公共依赖项以便共享而不是复制它们的一种简单方法是一次性捆绑所有小部件。这对于所有用户的所有小部件可能不可行,但也许在个人用户级别可行?

总之,这是总体思路。您可以将上述汇总配置更改为如下内容:

rollup.config.widget-all.js

...

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

我们正在传递一组文件,而不仅仅是一个文件,作为 input(您可能会通过在给定目录中列出文件来自动执行此步骤),并且我们正在更改 output.fileoutput.dir,因为现在我们将同时生成多个文件。这些文件将包含 Rollup 提取的小部件的公共依赖项,并且所有小部件将在它们之间共享以供重用。

进一步的观点

通过自己提取一些共享依赖项(例如,Svelte...)并将它们作为 URLs 提供给浏览器(即通过您的网络服务器提供它们),可以进一步推动).这样,您可以将已编译代码中的那些导入重写为那些已知的 URL,而不是依赖 Rollup 来解析它们。

这将完全减少代码重复,减轻重量,而且这将允许在所有使用它们的小部件之间共享这些依赖项的单一版本。这样做还可以减少同时构建所有共享依赖项的小部件的需要,这很诱人......但是,这将非常(!)设置起来很复杂,而且你实际上会减少 returns 快.

实际上,当您将一堆小部件(甚至只是一个)捆绑在一起并让 Rollup 提取依赖项时,捆绑器有可能知道消费方实际需要依赖项的哪些部分代码并跳过其余部分(请记住:Rollup 是在构建时将 tree shaking 作为其主要优先事项之一(如果不是的话),而 Svelte 是由同一个人构建的 - 意思是:你可以期望 Svelte 非常 tree shaking 友好!)。另一方面,如果您自己手动提取一些依赖项:它免除了一次捆绑所有使用代码的需要,但是您将不得不公开所有使用的依赖项,因为您无法提前知道他们需要的部分。

您需要在高效和实用之间找到平衡点,同时考虑到每个解决方案对您的设置所增加的复杂性。考虑到您的用例,我自己的感觉是,最佳点是完全独立地捆绑每个小部件,或者将来自同一用户的一堆小部件捆绑在一起以节省一些重量,如上所述。更努力地推动可能是一个有趣的技术挑战,但它只会获得很少的额外好处,但有点爆炸性的复杂性......

好的,我们现在知道如何为浏览器捆绑我们的小部件了。我们甚至可以在一定程度上控制如何完全独立地打包我们的小部件,或者承担一些额外的基础设施复杂性以共享它们之间的依赖关系并减轻一些负担。现在,当我们决定如何制作漂亮的小数据包(错误,捆绑包)时,我们需要考虑一个特殊的依赖性:那就是 Svelte 本身......

注意陷阱:苗条是无法复制的

因此我们了解到,当我们将单个小部件与 Rollup 捆绑在一起时,它的所有依赖项都将包含在“捆绑包”中(在这种情况下只是一个小部件文件)。如果您以这种方式捆绑 2 个小部件并且它们共享一些依赖项,那么这些依赖项将在每个捆绑包中重复。特别是,您将获得 2 个 Svelte 副本,每个小部件一个。同样,与某些小部件共享的“主”应用程序的依赖项仍然会在浏览器中复制。您将拥有相同代码的多个副本,这些副本将被那些不同的捆绑包使用——您的应用程序、不同的小部件...

但是,您需要了解 Svelte 的一些特殊之处:它不支持复制。 svelte/internal 模块是有状态的,它包含一些全局变量,如果您有此代码的多个副本(见上文),这些变量将被复制。这意味着,在实践中,不使用相同 Svelte 内部副本的 Svelte 组件不能一起使用。

例如,如果您有一个 App.svelte 组件(您的主应用程序)和一个 Foo.svelte 组件(例如用户小部件),它们是独立捆绑的,那么您不能使用 FooApp 中,否则你会遇到奇怪的错误。

这行不通:

App.svelte

<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') })

这里 foobar 将是完全独立的组件树,就 Svelte 而言。这样的代码将始终有效,与编译和捆绑的方式和时间(以及使用哪个 Svelte 版本等)无关。FooBar

据我了解您的用例,这不是主要障碍。您将无法使用 <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 副本的组件混合在一起(本质上意味着捆绑在一起,除非你已经开始了一些非凡的黑客攻击)。乍一看似乎可以,但实际上不行。

多亏了@rixo 的详细 [​​=36=],我才能够让它工作。我基本上像这样创建了一个 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>

几个notes/observations:

  1. 与尝试直接使用 rollup.rollup 相比,我使用 rollup/dist/loadConfigFile 的运气要好得多。
  2. 我尝试为所有 svelte 模块创建客户端和服务器全局变量并在小部件汇总中将它们标记为外部,以便所有东西都使用相同的 svelte 内部。这最终变得一团糟,让小部件可以访问比我想要的更多的东西。
  3. 如果您尝试使用
  4. @rixo 永远是对的。我事先被警告过这些事情中的每一个,结果完全符合预期。