【向大家求助】讨论一种 Package 的懒加载方案,实现需要帮助

为了加速 Emacs 的启动,以及适配不同的开发环境,我有个想法,但是我对于 Elisp 语法熟悉度不高,在这里向大家求助。

  1. 我的想法是 Emacs 加载只加载几个 Major Mode,比如 Python、JS、TypeScript 这些
  2. 在 Python 文件第一次被打开时,加载相关的包,比如 LSP、DAP 等等

我这样做可以吗?如果可以的话,Elisp 有没有类似 JS 中 Promise 的组件,或类似实现?

我现在用的包加载工具是 use-package,但是只能延迟加载,延迟一段时间后还是会加载,不是我想要的不加载。或者我用法有问题,也有可能是我理解有误,如果有大佬给我一部分代码实现,或许我就能做出来一个适合自己的实现。

当然,如果我这需求有现成的 Package 已经做了,也欢迎大佬批评我,我转向一种成熟的包管理方式。

看 with-eval-after-load

我也困惑很久,说一下当前的个人理解,供参考

实际 use-package 为了你提到的需求做了大量设计,先用好它就行了:


(use-package youdao-dictionary
  :commands (youdao-dictionary-search
             youdao-dictionary-search-at-point)
  :bind ("C-c y" . youdao-dictionary-search-at-point-posframe))

比如以上的例子,youdao-dictionary 这个包就是懒加载的,emacs 启动后 只记住了 youdao-dictionary 的入口,而没有真正去读取这个包的 el 文件。什么时候真正加载呢?以上配置中 commands 后就表示只有在第一次执行其中的两个命令之一时才会去读取 el 文件,读取完后再执行对应命令。这是通过命令来触发加载的例子。

而 emacs 本身就是键盘导向,因此在第一次按某个按键时再加载是很自然的需求, :bind 这个语法就是用来做这类延迟的,以上的例子中,另一种加载 youdao 的契机是按 C-c y 之后(实际也是执行一个命令,只不过这个命令绑定了 C-c y)

你所说 use-package 会在一段时间后又把包加载起来,可能是以下 defer 设置的,可以把它理解成 emacs 启动后执行了一个定时器,1s 后定时器到点了就去加载 projectile 包(这和按完 C-c y 后加载 youdao 是类似的,只是这里是定时器命令触发)

(use-package projectile
  :diminish projectile-mode
  :custom ((projectile-completion-system 'ivy))
  :defer 1
...)

其他的一些触发方式: 打开 py 后缀的文件会触发加载 python-mode:

(use-package python-mode
  :ensure nil
  :mode ("\\.py\\'" . python-mode)
  )

打开 org 文件,而 org 默认加载 org-mode ,接着 org-mode 会触发 rainbow-mode

(use-package rainbow-mode
  :hook (org-mode . rainbow-mode))

这两个例子应该覆盖了你说的点,另外emacs 启动后不会无故去加载某些 major-mode,我理解 major-mode 是属于 buffer 的附带品, 只有打开对应的文件才会去加载 。所以你说的 python,JS, TypeScript 这些mode 正常都不会加载,除非启动 emacs 后默认打开了 py,js 和 ts 文件,那么这些文件启动会触发对应的 mode 被加载。

当然还有一些级联的情况,用 afterhook 都会不断套下去,比如如果还有以下配置

(use-package happy-mode
  :after rainbow-mode)

那么在打开 org-mode 就触发了 rainbow-mode, 然后 rainbow 继续触发 happy 的加载(这个不知道我理解对不对,比如 after 和 hook 的差别是啥,我也不清楚了) 似乎也可以写成:

(use-package happy-mode
  :hook (rainbow-mode . happy-mode))
7 个赞

我简单理解是只执行一次和每次都会重复执行的区别,with-eval-after-load 是只触发时执行一次,而hook是每次打开某个某个文件时都会执行一次

2 个赞

直接读代码,with-eval-after-load 执行的时候会看 load-history 变量,已经有相关文件的话就马上执行 body,没有的话会在 after-load-functions 这个 hook 里加相关代码,一但执行完就会把自己从 hook 里去除。

如果不是从配置文件里运行的话,with-eval-after-load 会马上执行。

另外值得一提,文件参数可以是正则表达式

1 个赞

按照 @twiddling@LdBeth 两位大佬的说法,我尝试了很久,发现 Major Mode 加载过程中仍然有些包不应该加载。

我的意思是初始化 Major Mode 的时候,只初始化自身即可。有两方面的要求:

  • Emacs 启动加载 Python Mode,此时只初始化自身即可,附加的 LSP 配置不要运行;
  • 第一次加载 Python 文件的时候,初始化 LSP 和 DAP 调试相关的 Package;
  • LSP 和 DAP 调试相关的 Package 加载完成后,再执行一些必要的命令,比如启动 LSP 服务;
  • 再次打开 Python 文件,就跳过初始化 LSP 和 DAP,直接启动 LSP 服务。

我目前能写出来的配置是下面这样的:

;; Python 开发主模式
(use-package python-mode
  :commands python-mode
  :custom
  (python-shell-interpreter "python3")
  (dap-python-executable "python3")
  (dap-python-debugger 'debugpy)
  :config
  (require 'dap-python)
  (use-package with-venv
    :init
    (defun dap-python--pyenv-executable-find (command)
      (with-venv (executable-find "python"))))
  (add-hook 'python-mode-hook
            (lambda ()
              ;; 设置关闭自动换行
              (setq truncate-lines t)
              ;; 开启显示行号
              (display-line-numbers-mode +1)
              ;; 启动行号左侧对齐,并且不随着宽度变化而变化
              (setq display-line-numbers-width-start t)
              ;; 开启代码折叠子模式
              (hs-minor-mode t)
              ;; 设置列参考线:120
              (setq display-fill-column-indicator-column 120)
              (display-fill-column-indicator-mode t)
              ;; 开启代码折叠快捷键
              (define-key hs-minor-mode-map (kbd "C-c C-f") 'hs-toggle-hiding))))

;; LSP 自动完成服务端
(use-package lsp-pyright
  :after python-mode
  :hook (python-mode . (lambda ()
                         (require 'lsp-pyright)
                         (lsp-deferred))))

;; Python 保存自动格式化工具
(use-package python-black
  :after python-mode
  :hook (python-mode . python-black-on-save-mode))

其中 Python Mode 我更进了一步,只有第一次打开 Python 文件的时候才加载这个 Package,使用 commands 指令延迟到第一次打开文件时。这样做的目的是因为 config 指令会在 python-mode 加载后自动调用,不符合我第一次打开文件时再调用的想法。所以我想要 Emacs 在我打开 Python 文件调用 Python Mode 时再初始化,但是这样会导致 python-mode-hook 中的内容失效,LSP 的服务也没有启动起来。

所以,我的这种想法是正确的吗?

或者,因为依赖的 Package 加载完成后,触发加载 Python Mode 的文件不会触发 python-mode-hook,需要我手工触发一次?那么手工触发这个 Hook 的方法是什么呢?我网上没有查到相关的 Api。

默认行为应该就是这个,auto-mode-alist就是实现这个功能的

如前面所说,python-mode是内置的吧,这一步其实应该是多余的。

很奇怪,一般来说,major-mode-hook这种形式的hook,会在启动这个major-mode之后紧接着执行,而且这个是要major-mode。

我大概能明白你为什么想这么做,比如你想只在打开第一个文件之后才去加载python-mode的相关的包,但是这个功能一般不需要,因为auto-mode-alist已经让实现了在第一次打开文件时加载,这时候config关键字后面的东西执行的时机如果是python-mode文件加载之后,应该是你想要的效果。

我感觉应该是某个地方直接require了python-mode,导致加载顺序和预想不一致,可以直接pp-macroexpand-last-sexp把use-package的代码块展开看下

想向楼主安利一下不用use-package/leaf的做法,依赖autoload with-eval-after-load这两个就能满足大部分lazy-load的需求,use-package/leaf一旦出错只能定位到代码块,是哪一行出错都不太好找,而且use-package的关键字副作用还不少,虽然大部分情况下很好,但是当lazy-load粒度比较细的时候反而可能碍事,造成实际表现和预期不符。

我也是这么想的,但是不清楚怎么能达到我的要求,Lisp 语法不过关,想短期内达到你说的这种理想状态不现实,所以只能退而求其次。use-package 其实已经简化了我大部分的需求,只有一小部分没满足,自己找不到解决方案,这才向论坛上的大佬们求救。

目前来看,我只要再熟悉一些 Lisp 语法,在 use-package 基础上还是能达到自己想要的目的的。继续试试看吧,我觉得没问题。

我比较推荐 once:

我之前也是 use-package 的使用者,后来我发现我实际的需求和你差不多,我想要的是懒加载而不是延迟加载。我的启动时间可以在一台老机器上压到 0.08 秒 (150 个包),很大原因得益于此。一开始我是把 doom-emacs 里面的 :after-call 这个关键字加到了我的配置,后来干脆把 use-package 去掉了,直接用 once。

28都出来好久了,直接 pdumper 吧

我不一定理解清楚了你描述的需求,但我直觉上觉得,这里的困惑来自好几个点,一是要说清楚 python-mode 指的是一个Package 还是 major-mode, 因为 python-mode 其实有多重意思;要达到这种很细粒度的加载顺序,可能得再往下一层,理清到底什么是 major-mode, 搞清楚哪些配置是和 major-mode 紧密相连的,比如 indent 的设置,是应该在文件打开前就设置好,还是打开 py 文件触发 python-mode 后重置?最后还得知道 DAP 和 LSP 内部之间命令的关系,因为他们都不是一个简单的命令,里面可能包括很多独立的功能。 我也没有认真读过 major mode 的文档,只能以使用者的角度来说:

比如你说的这句话,我其实是困惑的,因为我理解 emacs 启动后,假设默认是打开 scratch buffer, 那么此时应该就只是加载了 lisp-interaction-mode, 这是 scratch 的 major mode,而不会加载 python-mode 这个 major mode, 只能说会加载 python-mode package(这是写python-mode 的人留的坑)。 比如如果在 init.el 里有以下语句

(use-package nov
  :mode ("\\.epub\\'" . nov-mode)
  )
(require 'python-mode)

添加 nov 这个包的目的是和 python 对比,它的名字比 python-mode 清晰很多,首先这个Package 的名字叫做 nov, 它作用在 epub 文件上,并且打开 epub 后,会启动一个叫做 nov-mode 的 major-mode , 而它的定义来自 nov package。

在这个语境下,你说的那句话的意思应该就是 emacs 启动的时候加载的是 python mode package, 而不是 python-mode major-mode(后面就叫 python-mode major 好了),因为启动时加载 package 是不会把 major mode 加载出来的,major mode 附着于 py 文件,只有打开 py 才会继续加载 major-mode。

我的疑问是, python-mode package 里除了定义了 major-mode 外,还有哪些变量和函数?是否有必要在 emacs 启动后就加载它们?比如是否也包括对 python-shell 的设置?因此如果改成以下设置会不会更好

(use-package python-mode
  :ensure nil
  :init 
  (setq python-before-load 't)
  :mode ("\\.py\\'" . python-mode)
  :config
  (setq python-after-load 't)
  )

在这个配置下,我理解启动 emacs 后是不会加载 python-mode package 里的任何变量和函数的(也许会有 autoload 接口),也不会改变 python-after-load 变量, 但是会设置 python-before-load 变量。只有在你第一次打开 python 文件后,emacs 才去加载 python-mode.el packge , package 里所有函数和变量定义都进入了 emacs 解释器, 然后 emacs 才找到了 python-mode major 的定义,才能真正加载这个 major-mode(其中包括高亮,key-map 等更细的模块),最后也把 python-after-load 给设置了。

如果以上我理解的没问题的话,那么要实现以下需求

就应该:

(use-package lsp-pyright

  :hook (python-mode . (lambda ()
                         (require 'lsp-pyright)
                         (lsp-deferred))))

hook 是加给 python-mode major 的,major-mode 才有 hook, package 没有 hook 的说法,比如有 nov-mode-hook 但没有 nov-hook。

另外不需要 :after python-mode 因为 after 是跟随一个 package (回答这个问题也让我多理解了一下 after 和 hook 的区别),如果有 after, 那么意味着 emacs 启动时如果 require 了 python-mode, 那么 lsp-pyright 会在 emacs 启动后就加载。同样 python-black 也不需要那个 after (但如果用上文的方式设置 python-mode package, 我认为 emacs 启动时是不会加载它的,因此就算不删除 after 我觉得也不会加载 black 和 lsp )

这个需求得看 LSP 或者 DAP包是怎么设计的,我理解初始化 LSP 时就会启动默认后台服务器,不过先理清前面讲的过程再说了。

还有一个问题是,python-mode 似乎是 emacs 内置加载的,所以不知道能不能延迟加载。但其他的第三方包,比如以上的 nov, 以上方法我自己是验证过的。

避免不礼貌,我先回复你的问题。

首先先前我不知道 Emacs 竟然内置了 Python Mode,然后让你产生了误解。我的意思是 Emacs 启动时不加载第三方的 Major Mode,而是我打开第一个相关的文件时再去初始化,比如 xx.epub 文件。

再次感谢你,你的回答给了我很大的启发,也让我理解到我先前的用法很很大的问题。后面 @alexluigit 大佬提到的 once 包更加切中了我的精细化管理需求。更加 amazing 的是和 use-package 可以集成,这样就解决了我要精细化管理时,还需要手工使用 with-eval-after-load 这个 Api 所带来的繁重工作量。

你的几次回答,都给了我很重要的修正。目前我仍然在整合这些信息,在进一步尝试。后面等我完成后,我会以一个简短的 demo 演示和讲解我的最终收获。这样可以方便坛子里其它人取用,也算是回馈各位大佬对我的帮助。

python-mode 这个内置包本来就是 autoload 的, 只有在打开 auto-mode-alist对应的文件类型才会调用吧

我现在用的包加载工具是 use-package ,但是只能延迟加载,延迟一段时间后还是会加载,不是我想要的不加载。

use-package 中使用:defer 就是延迟加载,并不会加载包。内置的python-mode也是autoload,启动时并没有实际加载。只有真正打开python文件才会加载python-mode。

内置 python-mode 加载好像不是 autoload,比如我在 emacs 28.1 上 emacs -q 启动 然后 describe-function, 输入 python 按 tab 会弹出补全列表,有以下这么多函数在一启动就已经加载了

比如其中的 python-mark-defun 在 python.el 的定义如下,没有 ;;;###autoload 声明

(defun python-mark-defun (&optional allow-extend)
   "Put mark at end of this defun, point at beginning.
 The defun marked is the one that contains point or follows point.

除非还有别的方式进行 autoload ?那以上猜测就不对了,下文也就不用看了

但如果 python-mode package 是 emacs 自动加载,且不是 autoload, 那么以下写法都没法真正延迟加载 xxx 包

(use-package xxx
:after python-mode)

emacs 一启动就加载 python-mode package,xxx 包也就跟着加载了。当然 python-mode 这个 major mode 还是要打开 py 文件才真正执行 python-mode 函数来激活 (在 python.el 里定义的,如下)

;;;###autoload
(add-to-list 'auto-mode-alist (cons (purecopy "\\.py[iw]?\\'") 'python-mode))

要在打开 python 文件后加载得写成

(use-package xxx
:hook (python-mode . xxx))

我也不知道 python 是内置启动的,所以谈不上误解,感觉主要还是得区分 python-mode 这个 package 和 major-mode ,很多问题是两个概念混在一起导致的

回答这问题我自己也了解了不少启动加载的知识,相互学习了 :smile_cat:

要用emacs -Q 测试。我的环境上pyton-mode是不会加载的。