用Python和Pyodide构建单页应用程序 - 第一部分

733 阅读11分钟

如果你用一个人听得懂的语言和他说话,那就会进入他的大脑。如果你用他自己的语言与他交谈,那就会进入他的内心。

- 纳尔逊-曼德拉

WebAssembly(WASM)为许多语言在不同的环境中使用打开了大门--如浏览器、云、无服务器和区块链等,仅举几例--它们以前是无法使用的。例如,通过利用WASM的Pyodide,你可以在浏览器中运行Python。你还可以使用Wasmer等运行时,将Python编译成WebAssembly,将其容器化,然后在不同的目的地,如边缘、物联网和操作系统中运行容器。

在本教程中,你将使用Python和Pyodide构建一个单页应用程序,以操纵DOM和管理状态。

--

Python单页应用系列。

  1. 第一部分(本教程!)。学习Pyodide的基础知识并创建基础应用程序
  2. 第二部分(即将推出)。用Pandas分析和处理数据,并使用网络工作者来加快应用程序的速度
  3. 第三部分(即将推出)。创建一个Python包,添加额外的功能,并添加一个持久的数据层。

目标

在本教程结束时,你应该能够。

  1. 与JavaScript一起使用Pyodide,在两者之间共享和访问对象
  2. 直接从Python代码中操纵DOM
  3. 在浏览器中运行Python的强大数据科学库
  4. 创建一个单页应用程序(SPA),从远程文件中获取数据,用Pandas操作数据,并在浏览器中显示出来。

浏览器中的 Python

这是什么意思?

在浏览器中运行Python意味着我们可以直接在客户端执行Python代码,而不需要在服务器上托管和执行代码。这是由WebAssembly实现的,它允许我们建立真正的 "无服务器 "应用程序。

为什么它很重要?为什么不直接使用JavaScript?

在浏览器中运行Python背后的主要目标不是要取代JavaScript,而是要把两种语言结合起来,让每个社区都能使用对方强大的工具和库。例如,在本教程中,我们将与 JavaScript 一起使用 Python 的Pandas库。

我们正在构建的东西

在本系列教程中,我们将构建一个无服务器的单页应用程序(SPA),该程序获取Netflix的电影和节目数据集,并使用Pandas来读取、净化、处理和分析数据。然后将结果显示在DOM上,供终端用户查看。

我们的最终项目是一个SPA,显示电影和电视节目的列表,推荐的电影和节目,以及有趣的事实。终端用户将能够删除和过滤电影和节目。数据在PouchDB中持久化。

在这一部分,我们将重点关注。

  1. 学习Pyodide和Pandas的基础知识
  2. 在Python和JavaScript之间共享对象和方法
  3. 从Python代码中操纵DOM
  4. 构建基本的应用结构

Sample App

你可以在这里找到第一部分中你要创建的应用程序的现场演示。

用Python对DOM进行操作

在我们深入到构建应用程序之前,让我们快速地看看我们如何使用Python与DOM API交互来直接操作它。

首先,创建一个名为index.html的新的HTML文件。

<!DOCTYPE html> <html> <head> <script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script> </head> <body> <p id="title">My first Pyodide app</p> <script> async function main() { let pyodide = await loadPyodide(); pyodide.runPython(`print('Hello world, from the browser!')`); }; main(); </script> </body> </html>

在这里,我们从CDN上加载了Pyodide的主运行时间以及Pyodide的内置,并使用runPython方法运行了一个简单的Python脚本。

在你的浏览器中打开该文件。然后,在浏览器的开发者工具的控制台内,你应该看到。

Loading distutils Loaded distutils Python initialization complete Hello world, from the browser!

最后一行显示,我们的Python代码在浏览器中被执行了。现在我们来看看如何访问DOM。要做到这一点,我们可以导入js库来访问JavaScript范围。

像这样更新HTML文件。

<!DOCTYPE html> <html> <head> <script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script> </head> <body> <p id="title">My first Pyodide app</p> <script> async function main() { let pyodide = await loadPyodide(); pyodide.runPython(` print('Hello world, from the browser!') import js js.document.title = "Hello from Python" `); }; main(); </script> </body> </html>

所以,js 代表全局对象window ,然后可以用来直接操作DOM和访问全局变量和函数。我们用它来改变浏览器/文档的标题为 "Hello from Python"。

刷新浏览器以确保它的工作。

接下来,让我们将该段的内部文本从 "我的第一个Pyodide应用程序 "更新为 "被Python取代"。

<!DOCTYPE html> <html> <head> <script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script> </head> <body> <p id="title">My first Pyodide app</p> <script> async function main() { let pyodide = await loadPyodide(); pyodide.runPython(` print('Hello world, from the browser!') import js js.document.title = "Hello from Python" js.document.getElementById("title").innerText = "Replaced by Python" `); }; main(); </script> </body> </html>

保存该文件,并再次刷新页面。

类型翻译

Pyodide的一个伟大功能是你可以在Python和JavaScript之间传递对象。有两种翻译方法。

  1. 隐式转换转换存在于两种语言中的基本数据类型--例如,将Pythonstr 转换为JavaScriptString
  2. 代理对象转换语言之间不共享的对象/类型。

隐式转换

如前所述,基本数据类型将在Python和JavaScript之间直接转换,而不需要创建特殊的对象。

PythonJavaScript
intNumberBigInt
floatNumber
strString
boolBoolean
Noneundefined
JavaScriptPython
Numberintfloat
BigIntint
Stringstr
Booleanbool
undefinedNone
nullNone

让我们看一个例子。

首先,在script 标签上添加一个新的变量,叫做name

<script> var name = "John Doe"; // NEW! async function main() { let pyodide = await loadPyodide(); pyodide.runPython(` print('Hello world, from the browser!') import js js.document.title = "Hello from Python" js.document.getElementById("title").innerText = "Replaced by Python" `); }; main(); </script>

接下来,让我们用js看一下类型和值。

<script> var name = "John Doe"; async function main() { let pyodide = await loadPyodide(); pyodide.runPython(` print('Hello world, from the browser!') import js js.document.title = "Hello from Python" js.document.getElementById("title").innerText = "Replaced by Python" `); // NEW !! pyodide.runPython(` import js name = js.name print(name, type(name)) `); }; main(); </script>

刷新浏览器。在控制台内,你应该看到以下输出。

正如你所看到的,我们可以直接访问这个变量的值,而且它被从JavaScriptString 转换为Pythonstr

代理

正如你所看到的,基本类型可以直接转换为目标语言中的等价物。另一方面,"非基本 "类型需要被转换为代理对象。有两种类型的代理对象。

  1. JSProxy是一种代理,用于使 JavaScript 对象表现得像 Python 对象。换句话说,它允许你从 Python 代码中引用内存中的 JavaScript 对象。你可以使用to_py()方法将代理转换为一个本地的Python对象。
  2. PyProxy是一个让Python对象表现得像JavaScript对象的代理,允许你从JavaScript代码中引用内存中的Python对象。你可以使用toJs()方法将该对象转换为一个本地的JavaScript对象。

要将Python字典转换为JavaScript对象,使用dict_converter 参数,其值为Object.fromEntries

dictionary_name.toJs({ dict_converter: Object.fromEntries })

如果没有这个参数,toJs()将会把字典转换成一个 JavaScriptMap对象。

JSProxy 示例

创建一个名为products 的新变量。

var products = [{ id: 1, name: "product 1", price: 100, }, { id: 2, name: "Product 2", price: 300, }];

把它导入到runPython ,并检查其类型。

pyodide.runPython(` import js print(type(js.products)) `);

Fullscript 标签。

<script> var name = "John Doe"; // NEW !! var products = [{ id: 1, name: "product 1", price: 100, }, { id: 2, name: "Product 2", price: 300, }]; async function main() { let pyodide = await loadPyodide(); pyodide.runPython(` print('Hello world, from the browser!') import js js.document.title = "Hello from Python" js.document.getElementById("title").innerText = "Replaced by Python" `); pyodide.runPython(` import js name = js.name print(name, type(name)) `); // NEW !! pyodide.runPython(` import js print(type(js.products)) `); }; main(); </script>

刷新页面后,你应该在控制台中看到,结果是<class 'pyodide.JsProxy'> 。通过这个代理,我们可以从我们的Python代码中访问内存中的JavaScript对象。

像这样更新新添加的pyodide.runPython 块。

pyodide.runPython(` import js products = js.products products.append({ "id": 3, "name": "Product 3", "price": 400, }) for p in products: print(p) `);

你应该在浏览器中看到一个AttributeError: append 错误,因为 JSProxy 对象没有一个append 方法。

如果你把.append 改为.push 会发生什么?

为了操作这个对象,你可以使用to_py() 方法将其转换为一个 Python 对象。

pyodide.runPython(` import js products = js.products.to_py() products.append({ "id": 3, "name": "Product 3", "price": 400, }) for p in products: print(p) `);

你现在应该看到。

{'id': 1, 'name': 'product 1', 'price': 100} {'id': 2, 'name': 'Product 2', 'price': 300} {'id': 3, 'name': 'Product 3', 'price': 400}

PyProxy 示例

像这样更新script 标签。

<script> async function main() { let pyodide = await loadPyodide(); pyodide.runPython(` import js products = [ { "id": 1, "name": "new name", "price": 100, "votes": 2 }, { "id": 2, "name": "new name", "price": 300, "votes": 2 } ] `); let products = pyodide.globals.get("products"); console.log(products.toJs({ dict_converter: Object.fromEntries })); }; main(); </script>

在这里,我们访问了Python变量,然后通过.toJs({ dict_converter: Object.fromEntries }) 将其从Python dict转换为JavaScript对象。

刷新页面后,你应该在控制台看到以下输出。

{id: 1, name: 'new name', price: 100, votes: 2} {id: 2, name: 'new name', price: 300, votes: 2}

就这样,让我们把你新发现的Pyodide知识用于构建一个应用程序吧

Netflix数据集

我们将开发一个无服务器的SPA应用,获取Netflix数据集。然后我们将使用Pandas来读取、处理、操作和分析数据。最后,我们将把结果传递给DOM,将分析后的数据显示给终端用户。

数据集是一个CSV,其中包括以下几列。

名称描述
IDJustWatch上的标题ID。
标题标题的名称。
节目类型电视节目或电影。
描述简短的描述。
发行年份发行年份。
年龄认证年龄认证。
运行时间剧集(节目)或电影的长度。
体裁体裁的列表。
生产国制作该标题的国家的列表。
季数如果是一个节目,则是季节的数量。
IMDB ID标题在IMDB上的ID。
IMDB分数在IMDB上的得分。
IMDB 投票IMDB上的投票。
TMDB人气TMDB上的受欢迎程度。
TMDB得分TMDB上的分数。

安装Pyodide和TailwindCSS

像这样更新你的index.html文件的内容。

<!DOCTYPE html> <html> <head> <script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script> <script src="https://cdn.tailwindcss.com"></script> </head> <body class="bg-slate-900"> <div id="app" class="relative h-full max-w-7xl mx-auto my-16"></div> <script> </script> </body> </html>

正如你所看到的,我们从CDN加载了Pyodide以及用于造型的Tailwind CSSid 我们还定义了一个<div> 元素,app ,以容纳我们接下来要建立的App组件。

创建应用程序组件

script 标签上添加以下JavaScript代码。

class App { state = { titles:[], } view() { return `<p class="text-slate-100">Hello, World!</p>` } render() { app.innerHTML = this.view(); } }

在这里,我们定义了一个名为App 的对象。我们把它称为一个组件,因为它是一个独立的、可重复使用的代码片段。

App 组件有一个用于保存数据的状态对象,同时还有两个嵌套的函数,叫做view()render()render() 只是把从view() 输出的 HTML 代码附加到 DOM 中,把dividapp

让我们创建一个新的App 的实例,称为appComponent ,并对其调用render() 。在Appclass 声明后添加以下代码。

var appComponent = new App(); appComponent.render();

在你的浏览器中打开该文件。你应该看到 "你好,世界!"。

添加样本数据

接下来,让我们把样本电影添加到state 。在script 标签中,就在调用appComponent.render(); 之前,用以下内容更新状态。

appComponent.state.titles = [ { "id": 1, "title": "The Shawshank Redemption", "release_year": 1994, "type": "MOVIE", "genres": [ "Crime", "Drama" ], "production_countries": [ "USA" ], "imdb_score": 9.3, "imdb_votes": 93650, "tmdb_score": 9.3, }, { "id": 2, "title": "The Godfather", "release_year": 1972, "type": "MOVIE", "genres": [ "Crime", "Drama" ], "production_countries": [ "USA" ], "imdb_score": 9.2, "imdb_votes": 93650, "tmdb_score": 9.3, } ];

现在,我们可以通过在我们的App 类中这样更新view() ,构造一个表格来显示数据。

view() { return (` <div class="px-4 sm:px-6 lg:px-8"> <div class="sm:flex sm:items-center"> <div class="sm:flex-auto"> <h1 class="text-4xl font-semibold text-gray-200">Netflix Movies and Shows</h1> </div> </div> <div class="mt-8 flex flex-col"> <div class="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8"> <div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8"> <div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg"> <table class="min-w-full divide-y divide-gray-300"> <thead class="bg-gray-50"> <tr> <th scope="col" class="whitespace-nowrap py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Title</th> <th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th> <th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Release Year</th> <th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Genere</th> <th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Production Country</th> </tr> </thead> <tbody class="divide-y divide-gray-200 bg-white"> ${this.state.titles.length > 0 ? this.state.titles.map(function (title) { return (` <tr id=${title.id}> <td class="whitespace-nowrap py-2 pl-4 pr-3 text-sm text-gray-500 sm:pl-6">${title.title}</td> <td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-gray-900">${title.type}</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">${title.release_year}</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">${title.genres}</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">${title.production_countries}</td> </tr> `) }).join('') : (` <tr> <td class="whitespace-nowrap py-2 pl-4 pr-3 text-sm text-gray-500 sm:pl-6">Titles are loading...</td> <td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-gray-900">Titles are loading...</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">Titles are loading...</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">Titles are loading...</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">Titles are loading...</td> </tr> `) } </tbody> </table> <div> </div> </div> </div> </div> `) }

所以,我们-

  1. 添加了一个表元素,其中有标题、类型、发行年份、流派和生产国等列。
  2. 检查了state.titles 数组的长度,看它是否包含任何标题。如果有,我们就循环查看,并为每个标题创建一个表行。如果没有,我们就创建一个带有加载信息的表行。

在浏览器中刷新页面。

潘达斯数据操作

安装潘达斯

为了在Pyodide中加载Python包,你可以在初始化Pyodide后直接使用loadPackage函数。

比如说。

let pyodide = await loadPyodide(); await pyodide.loadPackage("requests");

你可以使用一个列表来加载多个包。

await pyodide.loadPackage(["requests", "pandas", "numpy"]);

回到你的HTML文件中,在appComponent.render(); 之后添加一个main 函数。

async function main() { let pyodide = await loadPyodide(); await pyodide.loadPackage("pandas"); }

不要忘记调用它。

在你的浏览器中刷新页面。你应该在控制台中看到以下内容。

Loading pandas, numpy, python-dateutil, six, pytz, setuptools, pyparsing Loaded python-dateutil, six, pytz, pyparsing, setuptools, numpy, pandas

所以,Pandas和相关的子依赖已经被加载到浏览器中了!

读取和操作数据

在这一节中,我们将从互联网上获取一个CSV文件,将其读入一个Pandas DataFrame,对其进行消毒和处理,最后将其传递给状态。

Python代码。

import js import pandas as pd from pyodide.http import pyfetch # 1. fetching CSV from and write it to memory response = await pyfetch("https://raw.githubusercontent.com/amirtds/kaggle-netflix-tv-shows-and-movies/main/titles.csv") if response.status == 200: with open("titles.csv", "wb") as f: f.write(await response.bytes()) # 2. load the csv file all_titles = pd.read_csv("titles.csv") # 3. sanitize the data # drop unnecessary columns all_titles = all_titles.drop( columns=[ "age_certification", "seasons", "imdb_id", ] ) # drop rows with null values for important columns sanitized_titles = all_titles.dropna( subset=[ "id", "title", "release_year", "genres", "production_countries", "imdb_score", "imdb_votes", "tmdb_score", ] ) # Convert the DataFrame to a JSON object. ('orient="records"' returns a list of objects) titles_list = sanitized_titles.head(10).to_json(orient="records") # 4. set titles to first 10 titles to the state js.window.appComponent.state.titles = titles_list js.window.appComponent.render()

请注意代码的注释。

将这段代码添加到main 中的runPythonAsync方法中。

async function main() { let pyodide = await loadPyodide(); await pyodide.loadPackage("pandas"); await pyodide.runPythonAsync(` // add the code here `); }

接下来,删除appComponent.state.titles 。同时,我们需要修改view 方法中的这一行。

${this.state.titles.length > 0 ? this.state.titles.map(function (title) {

到。

${this.state.titles.length > 0 ? JSON.parse(this.state.titles).map(function (title) {

为什么?

titles_list ( )是一个JSON字符串,所以为了迭代它,我们需要对它进行反序列化。titles_list = sanitized_titles.head(10).to_json(orient="records")

在你的浏览器中刷新该页面。你应该首先在表格中看到一个加载信息。在Pyodide加载后,Pandas导入,并且在脚本执行完毕后,你应该看到完整的电影列表。

总结

我们在本教程中涵盖了很多内容。我们看了Pyodide如何让你在浏览器中运行Python代码,让你拥有以下能力。

  1. 在浏览器中直接加载和使用Python包。(我们使用Pandas来读取和分析一个CSV文件)。
  2. 从Python代码中访问和操作DOM。(在我们的 Python 代码中导入 js 使我们能够访问 DOM。)
  3. 在Python和JavaScript之间共享和访问对象和命名空间。(在我们的Javascript代码中,我们创建了一个组件,我们能够在我们的Python代码中访问这个组件,以便管理它的状态和调用它的方法。)

你可以在这里找到本教程的源代码。

--

不过,我们仍然缺少一些东西,我们需要解决一些问题。

  1. 首先,我们还没有对导入的CSV文件做很多处理。Pandas给了我们很大的权力来轻松分析和处理数据。
  2. 第二,Pyodide可能需要一些时间来初始化和运行Python脚本。由于它目前在主线程中运行,它使应用程序瘫痪,直到它运行完毕。我们应该把Pyodide和Python脚本移到一个Web Worker中,以防止这种情况。
  3. 第三,我们还没有看到完整的类似SPA的行为。我们仍然需要更新该组件,以添加事件监听器来响应用户的操作。
  4. 最后,Python脚本部分在代码编辑器中没有语法强调。另外,它开始变得难以阅读了。我们应该把这段代码移到一个Python包中,并把它导入Pyodide。这将使它更容易维护和扩展。

我们将在接下来的教程中介绍这四件事!

--

Python单页应用系列。

  1. 第一部分(本教程!)。学习Pyodide的基础知识并创建基础应用程序
  2. 第二部分(即将开始)。用Pandas分析和处理数据,并使用网络工作者来加快应用程序的速度
  3. 第三部分(即将推出)。创建一个Python包,添加额外的功能,并添加一个持久的数据层